Compare commits

..
Author SHA1 Message Date
code-infra-01andGPT-6.1-Sol de99f7263f fix(framework): validate milestone title before provider reads (#1841)
ci/woodpecker/pr/ci Pipeline was successful
Refuse missing or empty creation titles before provider detection while preserving title-free listing. Prove zero-provider usage failures and unchanged synthetic list and creation payload behavior.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 19:41:12 -06:00
code-infra-01andGPT-6.1-Sol ade6f3d26f test(framework): isolate issue usage fixture origins (#1841)
Use owned empty Git fixtures so parse-acceptance arms cannot inherit the enclosing checkout remote. Retain no-provider assertions and deliberate GitHub normalization coverage.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 19:41:04 -06:00
code-infra-01andGPT-6.1-Sol d3a2b8c870 fix(framework): validate raw provider header bytes (#1841)
Capture transport bytes without universal newline conversion and validate CRLF framing and field bytes before parsing. Prove malformed redirects refuse before any follow-up while valid OWS and response blocks remain supported.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 19:40:54 -06:00
code-infra-01andGPT-6.1-Sol 1a569f734c test(framework): isolate local PR diff provider fixture (#1841)
ci/woodpecker/pr/ci Pipeline failed
Inject a synthetic Gitea detector only for owned local fixture origins. Keep actual fetch/ref/diff and fail-closed reviewer assertions intact.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 19:00:27 -06:00
code-infra-01andGPT-6.1-Sol b03f3a70bd fix(framework): reject redirect URI glob expansion (#1841)
ci/woodpecker/pr/ci Pipeline was canceled
Validate raw and resolved URI components before transport and disable curl globbing. Preserve encoded literal targets and prove overlapping defenses with real loopback TLS controls.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 18:54:25 -06:00
code-infra-01andGPT-6.1-Sol 85ea376dc0 fix(framework): constrain authenticated read redirects (#1841)
ci/woodpecker/pr/ci Pipeline failed
Follow bounded same-origin HTTPS GET redirects without replaying mutations. Prove real wrapper entry paths and declare TLS fixture prerequisites in all consuming pipeline steps.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 18:26:34 -06:00
code-infra-01andGPT-6.1-Sol 79682f703f test(framework): make fake provider launchers portable to BusyBox (#1841)
ci/woodpecker/pr/ci Pipeline failed
Replace only the two env -S fixture prefixes with a POSIX shell trampoline. Keep Python isolation, quoted argv, fixture bodies and assertions unchanged.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 17:33:28 -06:00
code-infra-01andGPT-6.1-Sol 1effc9c143 docs(framework): index shipped forge wrappers and fixture helper (#1841)
ci/woodpecker/pr/ci Pipeline failed
Add the nine missing git-tool index rows to the existing tables. Identify the schema fixture as an offline test helper and retain all existing tool entries.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 17:02:18 -06:00
code-infra-01andGPT-6.1-Sol 00d62c62ca ci(framework): enumerate forge wrapper regression suites (#1841)
ci/woodpecker/pr/ci Pipeline failed
Add eleven shell suites and seven Python suites to the existing framework test command. Keep all prior invocations and exclusions unchanged.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 16:35:17 -06:00
code-infra-01andGPT-6.1-Sol 5cde8cd15b docs(framework): record round-two credential contract and evidence (#1841)
ci/woodpecker/pr/ci Pipeline failed
Resolve R2-C3 by documenting anonymous authentication consistently. Record complete URL, transport-error and wrapper trace controls, correct the earlier S6 coverage claim, and preserve the independent baseline failure.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 16:06:10 -06:00
code-infra-01andGPT-6.1-Sol fc7b9d61b3 fix(framework): keep shell credentials inside private workflows (#1841)
Disable tracing before caller-side token capture and retain privacy through the entire workflow. Use anonymous curl config descriptors, keep request stdin available, and cover all token-consuming wrappers with trace and argv controls. Preserve signal handling and stateful readback fixtures.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 16:03:00 -06:00
code-infra-01andGPT-6.1-Sol bec37b6fdf fix(framework): bind provider URLs and reject curl errors (#1841)
Validate complete public and enterprise API URLs in transport fixtures. Treat nonzero curl exits as transport failures for every provider, preserving unknown mutation outcomes.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 16:01:05 -06:00
code-infra-01andGPT-6.1-Sol a193cf2845 docs(framework): record forge review fixes and verification (#1841)
Map review findings to fix commits and regressions; preserve offline sweep, targeted correction, mutant controls and baseline failures separately. Record deferred inherited literals and unverified live transport behavior.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:31:26 -06:00
code-infra-01andGPT-6.1-Sol 8325df7063 test(framework): isolate issue-close actor credentials (#1841)
Replace the inherited shared-token fixture dependency with a synthetic actor and host-bound token in a private fixture home. Keep the comment-before-close negative and positive controls intact.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:30:47 -06:00
code-infra-01andGPT-6.1-Sol 02c7f2b183 test(framework): finish explicit credential fixture migration (#1841)
Keep metadata fixture arms offline, bind explicit/default-port fixture credentials and avoid new shell diagnostics in isolated credential arms.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:25:38 -06:00
code-infra-01andGPT-6.1-Sol e62adbcc27 test(framework): use independent offline workflow controls (#1841)
Exercise separate T3-wake and notifier wrapper paths against expected verdicts; reject wrong-ready and missing-full-stop mutants. Keep schema fixtures offline, operator patterns external and generated guard fixtures in scratch.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:22:25 -06:00
code-infra-01andGPT-6.1-Sol d939601116 fix(framework): bind forge policy and credentials to trusted identities (#1841)
Resolve merge authority from the pinned target commit; reject full PR identity races and malformed conversation state. Require explicit host-bound actors, verify mutation identity and deliver auth through anonymous descriptors. Migrate legacy fixture credentials to explicit actors.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 15:21:39 -06:00
code-infra-01andGPT-6.1-Sol 55d6cc18e3 feat(framework): package provider-neutral PR workflow skills and parity evidence (#1841)
Promote the four attributed workflows and their git/watch skill dependencies. Document capabilities, merge declarations, unsupported profiles, optional notification prerequisites and measured verification limits. Exclude generated Python bytecode from published git tools.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 14:41:45 -06:00
code-infra-01andGPT-6.1-Sol 5d187488d4 fix(framework): verify enterprise review URLs and ambiguous outcomes (#1841)
Preserve provider-qualified conversation ownership on enterprise API prefixes and keep post-write malformed readback outcomes unknown. Make the absence assertion independent of scratch directory names.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 14:36:25 -06:00
code-infra-01andGPT-6.1-Sol 630cc86355 fix(framework): harden forge adapters and preserve SSH compatibility (#1841)
Keep credentials out of client argv, validate snapshot schema and ownership evidence, reject opaque GraphQL writes and isolate suite scratch roots. Retain existing SCP and SSH transport parsing.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 14:31:15 -06:00
code-infra-01andGPT-6.1-Sol f59a4c3483 feat(framework): enforce declared merge methods and verified integration controls (#1841)
Add schema-v3 merge policy with squash-only defaults, protected scheduling, explicit capability failures, normalized CI checks and guarded review mutations. Qualify legacy fake providers with public metadata fixtures.

Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 14:17:10 -06:00
code-infra-01andGPT-6.1-Sol bbfdacaa38 feat(framework): draft normalized forge status and review adapters (#1841)
Co-Authored-By: GPT-6.1-Sol (Codex) <[email protected]>
2026-11-04 13:54:56 -06:00
code-be-02 924438016e fix(mosaic): honor seat-owned Gitea slots (#1529)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Mosaic fleet seat code-be-02 <[email protected]>
2026-10-10 00:50:38 +00:00
fred 2101c9b446 fix(git-tools): issue-comment.sh resolves API base without monolith GITEA_URL (#1502)
ci/woodpecker/push/publish Pipeline was successful
2026-09-11 22:50:23 +00:00
code-infra-01 5d27700026 fix(#1257): confirm delivery by draft transition, not prompt detection (adopts #1262) (#1332)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-infra-01 <[email protected]>
2026-09-04 22:25:13 +00:00
orch-01 d6302f8e6f docs: make Portainer optional deployment path (#1492)
ci/woodpecker/push/publish Pipeline was successful
2026-09-02 23:23:07 +00:00
marcie 9aa4983cf2 fix: use canonical dogfood seat identity (#1490)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-30 23:43:22 +00:00
marcie 736b0affc1 compose: add wrapper-first dogfood workspace (#1488)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-30 22:29:30 +00:00
orch-01 e18d13d36f fleet: split-home-safe mosaic launcher (T110, P5-RM-009 stack side) (#1480)
ci/woodpecker/push/publish Pipeline was successful
2026-08-30 10:05:40 +00:00
marcie 60bc5d2022 compose: pin MOSAIC_STORAGE_TIER=standalone (A5d formal-run fix) (#1486)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-30 07:49:35 +00:00
marcie ea91cfc421 compose: stack profile — one-command standalone deployment (A5) (#1485)
ci/woodpecker/push/publish Pipeline was successful
2026-08-30 06:03:39 +00:00
marcie acf640d00f docs: containerization plan + PRD D15 (tiered deployment, standalone v1 bar) (#1484)
ci/woodpecker/push/publish Pipeline was successful
2026-08-30 05:21:42 +00:00
fred 431ead3a18 feat(gateway,cli): agent enrollment command family (M4-4b) (#1483)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
2026-08-30 04:30:05 +00:00
fred 143ba0f57a db: agent enrollment schema (M4-4a, migration 0021) (#1482)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-30 01:47:06 +00:00
fred ee815a72b1 docs: agent enrollment command family v1 design (M4-4-0) (#1481)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-30 01:21:00 +00:00
marcie 94d626dff9 mosaic comms: socket resolution is tool-owned (B2) (#1476)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-29 23:35:37 +00:00
marcie ee6c842918 R3: --body-file <path>/- across body/comment carriers (#1474)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 22:49:20 +00:00
marcie ba3b854d50 D1/D3: --number canonical on issue wrappers, --labels alias on list wrappers (#1475)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 22:29:16 +00:00
fred c7a7fd07cc brain/gateway: prohibit mission_tasks.status as a write source (M4-3a phase 1) (#1479)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 21:59:13 +00:00
fred 5399c6b7e7 docs: P0 field-map currency verification at next@abb0c936 (M4-3a-0) (#1478)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 21:17:05 +00:00
marcie e09b8783b4 P1b: read-only viewers join the R1/R4 usage contract (#1472)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 21:03:07 +00:00
marcie abb0c93601 framework tools/tmux: agent-send socket default resolution + ambiguity guard (B1) (#1466)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 20:25:50 +00:00
marcie e67cced273 mosaic fleet logins: per-seat credential pass-through (P4 gap closure) (#1473)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 20:05:23 +00:00
fred 635cb1f666 docs: contract 2 Amendment 1 — company-CRUD capability (S2 follow-up) (#1477)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 19:34:48 +00:00
marcie 09d24b9275 pr-merge --base-line: gated intra-line exception (B5) (#1471)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-29 18:14:40 +00:00
marcie ed4c543872 mosaic coord: board subcommand + roll alias (P3 gap closure) (#1470)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 17:52:30 +00:00
fred 215faeda0a feat(hierarchy): M4-1b-ii hierarchy command family, grant evaluation, visibility (#1465)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 16:54:39 +00:00
orch-01 5125fe21b0 p0 line landing: coord board/roll + wrapper usage contract + dispatch layer (successor to #1467) (#1468)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: orch-01 <[email protected]>
2026-08-29 16:24:08 +00:00
marcie 41e8046371 framework tools/git: issue-comment R1/R4 usage-error contract (sync from brain) (#1462)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 01:58:42 +00:00
fred 6e16675ea2 docs: deployment mode and conversion contract (S2 contract 6) (#1439)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 22:57:11 +00:00
fred 19ebc422aa docs: tool-gateway mapping contract (S2 contract 5) (#1438)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 22:04:03 +00:00
210 changed files with 28063 additions and 1884 deletions

No files matched your search

+15 -155
View File
@@ -1,156 +1,16 @@
# ─────────────────────────────────────────────────────────────────────────────
# Mosaic — Environment Variables Reference
# Copy this file to .env and fill in the values for your deployment.
# Lines beginning with # are comments; optional vars are commented out.
# ─────────────────────────────────────────────────────────────────────────────
# Mosaic Stack standalone deployment (compose `stack` profile)
# Copy to .env and adjust. Port overrides exist because the defaults
# collide with common host services (and with the dev compose itself).
PG_HOST_PORT=5433
VALKEY_HOST_PORT=6380
GATEWAY_HOST_PORT=14242
# Registry image override (defaults to a local build of docker/gateway.Dockerfile):
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
# Full connection string used by the gateway, ORM, and migration runner.
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
# PG_HOST_PORT=5433
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
VALKEY_URL=redis://localhost:6380
# Docker Compose host-port override for the Valkey container (default: 6380)
# VALKEY_HOST_PORT=6380
# ─── Gateway ─────────────────────────────────────────────────────────────────
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
GATEWAY_PORT=14242
# Comma-separated list of allowed CORS origins.
# Must include the web app origin in production.
GATEWAY_CORS_ORIGIN=http://localhost:3000
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
# REQUIRED — random secret used to sign sessions and tokens.
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
# Public base URL of the gateway (used by BetterAuth for callback URLs)
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
# Directory holding the built SPA bundle (vite build output). When set, the
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
# safe-default: unset in dev — SPA serving is an opt-in production concern
#WEB_DIST_DIR=apps/web/dist
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Service name shown in traces
OTEL_SERVICE_NAME=mosaic-gateway
# ─── AI Providers ────────────────────────────────────────────────────────────
# Ollama (local models — set OLLAMA_BASE_URL to enable)
# OLLAMA_BASE_URL=http://localhost:11434
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
# OLLAMA_HOST=http://localhost:11434
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
# OLLAMA_MODELS=llama3.2,codellama,mistral
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
# ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
# OPENAI_API_KEY=sk-...
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
# ZAI_API_KEY=...
# Custom providers — JSON array of provider configs
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
# MOSAIC_CUSTOM_PROVIDERS=
# ─── Embedding Service ───────────────────────────────────────────────────────
# OpenAI-compatible embeddings endpoint (default: OpenAI)
# EMBEDDING_API_URL=https://api.openai.com/v1
# EMBEDDING_MODEL=text-embedding-3-small
# ─── Log Summarization Service ───────────────────────────────────────────────
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
# SUMMARIZATION_API_URL=https://api.openai.com/v1
# SUMMARIZATION_MODEL=gpt-4o-mini
# Cron schedule for summarization job (default: every 6 hours)
# SUMMARIZATION_CRON=0 */6 * * *
# Cron schedule for log tier management (default: daily at 03:00)
# TIER_MANAGEMENT_CRON=0 3 * * *
# ─── Agent ───────────────────────────────────────────────────────────────────
# Filesystem sandbox root for agent file tools (default: process.cwd())
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
# Comma-separated list of tool names available to non-admin users.
# Leave unset to allow all tools for all authenticated users.
# AGENT_USER_TOOLS=read_file,list_directory,search_files
# System prompt injected into every agent session (optional)
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
# ─── MCP Servers ─────────────────────────────────────────────────────────────
# JSON array of MCP server configs — set to enable MCP tool integration.
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
# ─── Coordinator ─────────────────────────────────────────────────────────────
# Root directory used to scope coordinator (worktree/repo) operations.
# Defaults to the monorepo root auto-detected from process.cwd().
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
# DISCORD_BOT_TOKEN=
# DISCORD_GUILD_ID=
# DISCORD_GATEWAY_URL=http://localhost:14242
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_GATEWAY_URL=http://localhost:14242
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
# WORKOS_ISSUER=https://your-company.authkit.app
# WORKOS_CLIENT_ID=client_...
# WORKOS_CLIENT_SECRET=sk_live_...
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
# Legacy alternative if you prefer to compose the issuer from separate vars:
# KEYCLOAK_URL=https://auth.example.com
# KEYCLOAK_REALM=master
# KEYCLOAK_CLIENT_ID=mosaic
# KEYCLOAK_CLIENT_SECRET=
# The web login page discovers configured providers dynamically from
# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required.
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
# All three paths are required when that overlay is used. Use a dedicated
# next-based worktree, its canonical clone's .git directory, and the external
# home of the unprivileged code-dogfood-01 functional seat.
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/code-dogfood-01
+5 -3
View File
@@ -72,7 +72,8 @@ steps:
sanitization:
image: *node_image
commands:
- apk add --no-cache bash
# Real loopback TLS transport fixtures require curl and openssl.
- apk add --no-cache bash curl openssl
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
# Resident line-count ceiling over framework-owned resident files
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
@@ -205,7 +206,7 @@ steps:
# shared command; everything else in this step is PIPELINE-LEVEL
# prerequisite the canonical command expects its caller to provide (SDLC-D-034):
# the ci-postgres service + pg_isready wait + db:migrate (postgres path),
# `apk add openssl`, and the pinned pi install. None of those can move into
# `apk add openssl curl`, and the pinned pi install. None of those can move into
# the runner (it must also work locally on the PGlite path with no database).
test:
image: *node_image
@@ -220,7 +221,8 @@ steps:
# Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
# `apk add` guarantees openssl is present on PR pipelines too (and is a
# fast no-op once the rebuilt image already ships it).
- apk add --no-cache openssl
# Real loopback TLS transport fixtures also require curl.
- apk add --no-cache openssl curl
# Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
# installed `pi` binary at exactly this measured version — the test
# boots Pi's real tool registry to prove the read-only carve-out
+3 -2
View File
@@ -97,12 +97,13 @@ steps:
echo "[verify] commit identity confirmed: $CHECKOUT_SHA"
# (b) Canonical terminal verification. Caller-provided prerequisites the
# runner expects (see .woodpecker/ci.yml comments): bash/rsync for the
# guard stages, openssl + the pinned pi binary for the test stage. git is
# guard stages, openssl/curl for real loopback TLS transport tests, and
# the pinned pi binary for the test stage. git is
# baked into ci-base but re-asserted here so the identity check above can
# never silently depend on a stale baked image. DATABASE_URL is
# deliberately NOT set: the canonical command must hold on the PGlite
# path too and never sets or requires a database itself.
- apk add --no-cache bash rsync openssl git
- apk add --no-cache bash rsync openssl git curl
- npm install -g @earendil-works/[email protected]
- pnpm verify:release
depends_on:
+45
View File
@@ -208,6 +208,51 @@ mosaic telemetry upload # Dry-run unless opted in
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
## Standalone container deployment
The `stack` profile runs PostgreSQL, Valkey, the gateway, and the bundled webUI. Copy
`.env.example` to `.env`, generate `BETTER_AUTH_SECRET`, then start the profile:
```bash
cp .env.example .env
printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
docker compose --profile stack up -d
```
The optional dogfood overlay gives one dedicated in-stack agent a writable stack
worktree and its own read-only credential slot. It does not mount the fleet brain or
any other seat. Prepare a `next`-based worktree and an unprivileged
`code-dogfood-01` functional seat outside the container, then set these paths in
`.env`:
```dotenv
MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487
MOSAIC_DOGFOOD_COMMON_GIT_DIR=/path/to/mosaic-stack/.git
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/code-dogfood-01
```
The common Git directory must match the worktree's `.git` pointer. The seat home
must contain only that seat's credential at
`secrets/gitea-mosaicstack-code-dogfood-01.token`. Never place the token value in
`.env`. Start the overlay with:
```bash
docker compose \
-f docker-compose.yml \
-f docker-compose.dogfood.yml \
--profile stack up -d
```
The overlay removes the general shell tool for every session, including admins.
File tools stay inside the mounted checkout. Two dedicated delivery tools stage
explicit paths, run the CI queue guard, push through `git-credential-mosaic`, and
open PRs through `pr-create.sh`. They resolve only the `code-dogfood-01` slot and fail
if it is absent. The overlay enables Docker's init process so the R4 helper can
establish the gateway's seat lineage below PID 1.
This deployment route is separate from the local source-development restrictions
below.
## Development
### Prerequisites
@@ -4,14 +4,14 @@ import { AppModule } from '../app.module.js';
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
/**
* Hierarchy route-inventory baseline (contract 1 §6.3(a)).
* Hierarchy route inventory (contract 1 §6.3).
*
* M4-1b-i ships the audit event + outbox machinery with NO mutation routes:
* the hierarchy command family (controllers + DTOs) lands in M4-1b-ii once
* contract 2 merges. This witness enumerates every route the AppModule graph
* declares and pins that baseline, so a hierarchy route appearing before its
* command-family witnesses exist fails here first. When M4-1b-ii lands, this
* baseline is replaced by an exact inventory of the command family.
* The hierarchy command family is a CLOSED enumeration asserted here, not a
* prose claim: every hierarchy-flavored route the AppModule graph declares
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
* removing a hierarchy route without updating this inventory (and its
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
* baseline.
*/
interface RouteEntry {
@@ -77,7 +77,32 @@ function routesOf(controller: Type<unknown>): RouteEntry[] {
return routes;
}
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
/**
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
* mutation audited via the M4-1b-i path or one of the two ratified reads
* (granted companies, the §2.8 directory carve-out).
*/
const HIERARCHY_COMMAND_FAMILY = [
'POST /api/hierarchy/companies',
'GET /api/hierarchy/companies',
'GET /api/hierarchy/companies/directory',
'POST /api/hierarchy/companies/:id/rename',
'POST /api/hierarchy/companies/:id/visibility',
'DELETE /api/hierarchy/companies/:id',
'POST /api/hierarchy/estates',
'POST /api/hierarchy/estates/:id/rename',
'POST /api/hierarchy/estates/:id/transfer',
'DELETE /api/hierarchy/estates/:id',
'POST /api/hierarchy/platform-projects',
'POST /api/hierarchy/platform-projects/:id/rename',
'POST /api/hierarchy/platform-projects/:id/transfer',
'DELETE /api/hierarchy/platform-projects/:id',
'POST /api/hierarchy/grants',
'POST /api/hierarchy/grants/:id/change',
'DELETE /api/hierarchy/grants/:id',
] as const;
describe('hierarchy route inventory (§6.3)', () => {
const inventory = collectControllers(AppModule).flatMap(routesOf);
it('control: the enumeration sees the known route surface', () => {
@@ -88,19 +113,21 @@ describe('hierarchy route-inventory baseline (§6.3(a))', () => {
expect(inventory.length).toBeGreaterThan(20);
});
it('declares zero hierarchy mutation routes before M4-1b-ii', () => {
const hierarchyRoutes = inventory.filter((r) =>
/hierarch|compan|estate|platform[-_]?project/i.test(r.path),
);
expect(
hierarchyRoutes,
'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory',
).toEqual([]);
it('the hierarchy surface is exactly the declared command family', () => {
const hierarchyRoutes = inventory
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
it('HierarchyModule itself declares no controllers', () => {
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
const hierarchyControllers = collectControllers(HierarchyModule);
expect(hierarchyControllers).toEqual([]);
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
const controllers = collectControllers(HierarchyModule);
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
const declared = controllers
.flatMap(routesOf)
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
});
+4 -2
View File
@@ -27,10 +27,11 @@ import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
import { createBrainTools } from './tools/brain-tools.js';
import { createCoordTools } from './tools/coord-tools.js';
import { createDeliveryTools } from './tools/delivery-tools.js';
import { createMemoryTools } from './tools/memory-tools.js';
import { createFileTools } from './tools/file-tools.js';
import { createGitTools } from './tools/git-tools.js';
import { createShellTools } from './tools/shell-tools.js';
import { createShellToolsIfEnabled } from './tools/shell-tools.js';
import { createWebTools } from './tools/web-tools.js';
import { createSearchTools } from './tools/search-tools.js';
import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
@@ -167,7 +168,8 @@ export class AgentService implements OnModuleDestroy {
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
...createShellTools(sandboxDir),
...createShellToolsIfEnabled(sandboxDir),
...createDeliveryTools(sandboxDir),
...createWebTools(),
...createSearchTools(),
];
@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { createFileTools } from './file-tools.js';
import { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
import {
createDeliveryTools,
type DeliveryToolEnvironment,
type ProcessResult,
type ProcessRunner,
} from './delivery-tools.js';
const tempDirs: string[] = [];
function tempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function textOf(result: unknown): string {
const typed = result as { content: Array<{ text: string }> };
return typed.content.map((item) => item.text).join('\n');
}
async function execute(tool: ToolDefinition, params: Record<string, unknown>): Promise<unknown> {
return (
tool.execute as unknown as (id: string, input: Record<string, unknown>) => Promise<unknown>
)('test-call', params);
}
function ok(stdout = ''): ProcessResult {
return { exitCode: 0, stdout, stderr: '', timedOut: false };
}
function deliveryEnv(extra: Partial<DeliveryToolEnvironment> = {}): DeliveryToolEnvironment {
return {
AGENT_DELIVERY_ENABLED: 'true',
MOSAIC_GIT_TOOLS_DIR: '/opt/mosaic/tools/git',
MOSAIC_GIT_IDENTITY: 'code-dogfood-01',
MOSAIC_AGENT_NAME: 'code-dogfood-01',
MOSAIC_BRAIN_HOME: '/opt/mosaic/brain',
MOSAIC_INTEGRATION_TRUNK: 'next',
HOME: '/home/node',
PATH: '/usr/bin:/bin',
...extra,
};
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('dogfood execution boundary', () => {
it('removes shell_exec mechanically while its first-token bypass red control stays live', async () => {
const sandbox = tempDir('mosaic-shell-boundary-');
expect(createShellToolsIfEnabled(sandbox, { AGENT_SHELL_ENABLED: 'false' })).toEqual([]);
const redControl = createShellTools(sandbox)[0]!;
const result = await execute(redControl, { command: 'env printf FIRST_TOKEN_BYPASS' });
expect(textOf(result)).toContain('FIRST_TOKEN_BYPASS');
});
it('refuses an outside-sandbox token-shaped read and proves the path guard is the enforcement', async () => {
const root = tempDir('mosaic-file-boundary-');
const sandbox = path.join(root, 'workspace', 'stack');
const token = path.join(
root,
'brain',
'fleet',
'agents',
'code-dogfood-01',
'secrets',
'gitea-mosaicstack-code-dogfood-01.token',
);
fs.mkdirSync(sandbox, { recursive: true });
fs.mkdirSync(path.dirname(token), { recursive: true });
fs.writeFileSync(token, 'OUTSIDE_SANDBOX_SENTINEL');
const read = createFileTools(sandbox).find((tool) => tool.name === 'fs_read_file')!;
const refused = await execute(read, { path: token });
expect(textOf(refused)).toContain('Path escape attempt blocked');
expect(textOf(refused)).not.toContain('OUTSIDE_SANDBOX_SENTINEL');
fs.symlinkSync(token, path.join(sandbox, 'credential.token'));
const symlinkRefused = await execute(read, { path: 'credential.token' });
expect(textOf(symlinkRefused)).toContain('Path escape attempt blocked');
expect(textOf(symlinkRefused)).not.toContain('OUTSIDE_SANDBOX_SENTINEL');
const redRead = createFileTools(root).find((tool) => tool.name === 'fs_read_file')!;
const redControl = await execute(redRead, { path: token });
expect(textOf(redControl)).toContain('OUTSIDE_SANDBOX_SENTINEL');
});
});
describe('delivery tools', () => {
it('stay absent unless explicitly enabled and reject identity mismatch', () => {
const sandbox = tempDir('mosaic-delivery-disabled-');
expect(createDeliveryTools(sandbox, {})).toEqual([]);
expect(() =>
createDeliveryTools(sandbox, deliveryEnv({ MOSAIC_AGENT_NAME: 'another-seat' })),
).toThrow('matching safe MOSAIC agent and git identities');
});
it('publishes through execFile-only git and queue operations with a scrubbed environment', async () => {
const sandbox = tempDir('mosaic-delivery-publish-');
fs.writeFileSync(path.join(sandbox, 'change.md'), 'change');
const calls: Array<{ file: string; args: readonly string[]; env: NodeJS.ProcessEnv }> = [];
const runner: ProcessRunner = async (file, args, options) => {
calls.push({ file, args, env: options.env });
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
return ok();
};
const hostile = {
...deliveryEnv(),
BASH_ENV: '/tmp/injected',
'BASH_FUNC_read%%': '() { :; }',
GITEA_TOKEN: 'must-not-cross',
} as DeliveryToolEnvironment;
const publish = createDeliveryTools(sandbox, hostile, runner).find(
(tool) => tool.name === 'git_publish_branch',
)!;
const result = await execute(publish, {
issue: 1487,
paths: ['change.md'],
commitMessage: 'docs: dogfood proof (#1487)',
});
expect(textOf(result)).toBe('Published branch feat/1487-dogfood-proof as code-dogfood-01.');
expect(calls.map((call) => call.file)).toEqual([
'/usr/bin/git',
'/usr/bin/git',
'/usr/bin/git',
'/opt/mosaic/tools/git/ci-queue-wait.sh',
'/usr/bin/git',
]);
expect(calls[3]!.args).toEqual(['--purpose', 'push', '-B', 'feat/1487-dogfood-proof']);
expect(calls[4]!.args).toEqual(['push', '--set-upstream', 'origin', 'feat/1487-dogfood-proof']);
for (const call of calls) {
expect(call.file).not.toMatch(/(?:^|\/)sh$/);
expect(call.env).not.toHaveProperty('BASH_ENV');
expect(Object.keys(call.env).some((key) => key.startsWith('BASH_FUNC_'))).toBe(false);
expect(call.env).not.toHaveProperty('GITEA_TOKEN');
expect(call.env.MOSAIC_GIT_IDENTITY).toBe('code-dogfood-01');
}
});
it('opens PRs only through pr-create.sh against next', async () => {
const sandbox = tempDir('mosaic-delivery-pr-');
const calls: Array<{ file: string; args: readonly string[] }> = [];
const runner: ProcessRunner = async (file, args) => {
calls.push({ file, args });
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
return ok('https://git.mosaicstack.dev/mosaicstack/stack/pulls/999\n');
};
const openPr = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
(tool) => tool.name === 'git_open_pull_request',
)!;
const result = await execute(openPr, {
issue: 1487,
title: 'docs: dogfood proof',
body: 'Measured from the in-stack agent.',
});
expect(textOf(result)).toContain('/pulls/999');
expect(calls[1]!.file).toBe('/opt/mosaic/tools/git/pr-create.sh');
expect(calls[1]!.args).toEqual([
'-t',
'docs: dogfood proof',
'-b',
'Measured from the in-stack agent.',
'-B',
'next',
'-H',
'feat/1487-dogfood-proof',
'-i',
'1487',
]);
});
it('blocks publish paths outside the sandbox before staging', async () => {
const root = tempDir('mosaic-delivery-path-');
const sandbox = path.join(root, 'sandbox');
const outside = path.join(root, 'outside.md');
fs.mkdirSync(sandbox);
fs.writeFileSync(outside, 'OUTSIDE_DELIVERY_SENTINEL');
const calls: Array<{ file: string; args: readonly string[] }> = [];
const runner: ProcessRunner = async (file, args) => {
calls.push({ file, args });
return args[0] === 'branch' ? ok('feat/1487-dogfood-proof\n') : ok();
};
const publish = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
(tool) => tool.name === 'git_publish_branch',
)!;
const result = await execute(publish, {
issue: 1487,
paths: [outside],
commitMessage: 'docs: must not publish',
});
expect(textOf(result)).toContain('Path escape attempt blocked');
expect(textOf(result)).not.toContain('OUTSIDE_DELIVERY_SENTINEL');
expect(calls).toHaveLength(1);
});
});
@@ -0,0 +1,282 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { guardPath, SandboxEscapeError } from './path-guard.js';
const PROCESS_TIMEOUT_MS = 120_000;
const MAX_OUTPUT_BYTES = 100 * 1024;
const SAFE_IDENTITY = /^[a-z0-9][a-z0-9-]{0,62}$/;
const SAFE_BRANCH = /^(?:feat|fix|docs|test)\/[a-z0-9][a-z0-9._/-]*$/i;
export interface ProcessResult {
exitCode: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
}
export type ProcessRunner = (
file: string,
args: readonly string[],
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
) => Promise<ProcessResult>;
export interface DeliveryToolEnvironment {
AGENT_DELIVERY_ENABLED?: string;
MOSAIC_GIT_TOOLS_DIR?: string;
MOSAIC_GIT_IDENTITY?: string;
MOSAIC_AGENT_NAME?: string;
MOSAIC_BRAIN_HOME?: string;
MOSAIC_CREDENTIAL_SPOOL?: string;
MOSAIC_CREDENTIAL_LINEAGE_FENCE?: string;
MOSAIC_INTEGRATION_TRUNK?: string;
HOME?: string;
PATH?: string;
LANG?: string;
LC_ALL?: string;
}
function runProcess(
file: string,
args: readonly string[],
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
): Promise<ProcessResult> {
return new Promise((resolve) => {
const child = spawn(file, [...args], {
cwd: options.cwd,
env: options.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
let outputBytes = 0;
const append = (current: string, chunk: Buffer): string => {
const remaining = MAX_OUTPUT_BYTES - outputBytes;
if (remaining <= 0) return current;
outputBytes += chunk.length;
return current + chunk.subarray(0, remaining).toString();
};
child.stdout.on('data', (chunk: Buffer) => {
stdout = append(stdout, chunk);
});
child.stderr.on('data', (chunk: Buffer) => {
stderr = append(stderr, chunk);
});
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, options.timeoutMs);
child.on('error', (error) => {
clearTimeout(timer);
resolve({ exitCode: null, stdout, stderr: `${stderr}${String(error)}`, timedOut });
});
child.on('close', (exitCode) => {
clearTimeout(timer);
resolve({ exitCode, stdout, stderr, timedOut });
});
});
}
function cleanEnvironment(env: DeliveryToolEnvironment): NodeJS.ProcessEnv {
const clean: NodeJS.ProcessEnv = {
GIT_TERMINAL_PROMPT: '0',
};
for (const key of [
'HOME',
'PATH',
'LANG',
'LC_ALL',
'MOSAIC_GIT_IDENTITY',
'MOSAIC_AGENT_NAME',
'MOSAIC_BRAIN_HOME',
'MOSAIC_CREDENTIAL_SPOOL',
'MOSAIC_CREDENTIAL_LINEAGE_FENCE',
] as const) {
const value = env[key];
if (value !== undefined) clean[key] = value;
}
return clean;
}
function textResult(text: string): {
content: Array<{ type: 'text'; text: string }>;
details: undefined;
} {
return { content: [{ type: 'text', text }], details: undefined };
}
function describeFailure(label: string, result: ProcessResult): string {
if (result.timedOut) return `${label} timed out`;
const diagnostic = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
return `${label} failed (exit ${result.exitCode ?? 'null'}): ${diagnostic}`;
}
function currentBranchPattern(issue: number): RegExp {
return new RegExp(`^(?:feat|fix|docs|test)/${issue}(?:[-/].+)$`, 'i');
}
export function createDeliveryTools(
sandboxDir: string,
sourceEnv: DeliveryToolEnvironment = process.env,
runner: ProcessRunner = runProcess,
): ToolDefinition[] {
if (sourceEnv.AGENT_DELIVERY_ENABLED !== 'true') return [];
const identity = sourceEnv.MOSAIC_GIT_IDENTITY ?? '';
const agentName = sourceEnv.MOSAIC_AGENT_NAME ?? '';
const toolsDir = sourceEnv.MOSAIC_GIT_TOOLS_DIR ?? '';
const baseBranch = sourceEnv.MOSAIC_INTEGRATION_TRUNK ?? 'next';
if (!SAFE_IDENTITY.test(identity) || identity !== agentName) {
throw new Error('Delivery tools require matching safe MOSAIC agent and git identities');
}
if (!path.isAbsolute(toolsDir)) {
throw new Error('Delivery tools require an absolute MOSAIC_GIT_TOOLS_DIR');
}
if (!SAFE_BRANCH.test(`feat/${baseBranch}`) || baseBranch.includes('/')) {
throw new Error('Delivery tools require a safe integration branch name');
}
const env = cleanEnvironment(sourceEnv);
const queueGuard = path.join(toolsDir, 'ci-queue-wait.sh');
const prCreate = path.join(toolsDir, 'pr-create.sh');
const run = (file: string, args: readonly string[], timeoutMs = PROCESS_TIMEOUT_MS) =>
runner(file, args, { cwd: sandboxDir, env, timeoutMs });
const readBranch = async (): Promise<{ branch?: string; error?: string }> => {
const result = await run('/usr/bin/git', ['branch', '--show-current'], 15_000);
if (result.exitCode !== 0) return { error: describeFailure('git branch', result) };
const branch = result.stdout.trim();
if (!SAFE_BRANCH.test(branch))
return { error: `Unsafe delivery branch: ${branch || '<empty>'}` };
if (branch === baseBranch || branch === 'main') {
return { error: `Refusing delivery from protected branch ${branch}` };
}
return { branch };
};
const publish: ToolDefinition = {
name: 'git_publish_branch',
label: 'Publish Git Branch',
description:
'Stage explicit files in the current sandbox branch, commit them as the dedicated dogfood identity, run the CI queue guard, and push the branch. No shell or raw provider API is used.',
parameters: Type.Object({
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
paths: Type.Array(Type.String(), {
minItems: 1,
maxItems: 100,
description: 'Files to stage, relative to the sandbox root',
}),
commitMessage: Type.String({ minLength: 1, maxLength: 4000 }),
}),
async execute(_toolCallId, params) {
const { issue, paths, commitMessage } = params as {
issue: number;
paths: string[];
commitMessage: string;
};
const branchResult = await readBranch();
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
const branch = branchResult.branch;
if (!currentBranchPattern(issue).test(branch)) {
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
}
const relativePaths: string[] = [];
try {
const sandboxRoot = guardPath('.', sandboxDir);
for (const candidate of paths) {
const resolved = guardPath(candidate, sandboxDir);
const relative = path.relative(sandboxRoot, resolved);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new SandboxEscapeError(candidate, sandboxDir, resolved);
}
relativePaths.push(relative);
}
} catch (error) {
return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
}
const add = await run('/usr/bin/git', ['add', '--', ...relativePaths], 30_000);
if (add.exitCode !== 0) return textResult(`Error: ${describeFailure('git add', add)}`);
const commit = await run(
'/usr/bin/git',
[
'-c',
`user.name=${identity}`,
'-c',
`user.email=${identity}@mosaic.invalid`,
'commit',
'-m',
commitMessage,
'--',
...relativePaths,
],
60_000,
);
if (commit.exitCode !== 0)
return textResult(`Error: ${describeFailure('git commit', commit)}`);
const queue = await run(queueGuard, ['--purpose', 'push', '-B', branch]);
if (queue.exitCode !== 0) {
return textResult(`Error: ${describeFailure('CI queue guard', queue)}`);
}
const push = await run(
'/usr/bin/git',
['push', '--set-upstream', 'origin', branch],
PROCESS_TIMEOUT_MS,
);
if (push.exitCode !== 0) return textResult(`Error: ${describeFailure('git push', push)}`);
return textResult(`Published branch ${branch} as ${identity}.`);
},
};
const openPr: ToolDefinition = {
name: 'git_open_pull_request',
label: 'Open Pull Request',
description:
'Open a pull request from the current sandbox branch through the Mosaic pr-create wrapper. The wrapper targets the configured integration branch and links the tracking issue.',
parameters: Type.Object({
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
title: Type.String({ minLength: 1, maxLength: 240 }),
body: Type.String({ maxLength: 20_000 }),
}),
async execute(_toolCallId, params) {
const { issue, title, body } = params as { issue: number; title: string; body: string };
const branchResult = await readBranch();
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
const branch = branchResult.branch;
if (!currentBranchPattern(issue).test(branch)) {
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
}
const result = await run(prCreate, [
'-t',
title,
'-b',
body,
'-B',
baseBranch,
'-H',
branch,
'-i',
String(issue),
]);
if (result.exitCode !== 0) {
return textResult(`Error: ${describeFailure('pr-create wrapper', result)}`);
}
return textResult(result.stdout.trim() || `Pull request opened from ${branch}.`);
},
};
return [publish, openPr];
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
import { guardPath, guardWritePath, SandboxEscapeError } from './path-guard.js';
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
@@ -92,7 +92,7 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
};
let safePath: string;
try {
safePath = guardPathUnsafe(path, baseDir);
safePath = guardWritePath(path, baseDir);
} catch (err) {
if (err instanceof SandboxEscapeError) {
return {
+2 -1
View File
@@ -1,8 +1,9 @@
export { createBrainTools } from './brain-tools.js';
export { createCoordTools } from './coord-tools.js';
export { createDeliveryTools } from './delivery-tools.js';
export { createFileTools } from './file-tools.js';
export { createGitTools } from './git-tools.js';
export { createSearchTools } from './search-tools.js';
export { createShellTools } from './shell-tools.js';
export { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
export { createWebTools } from './web-tools.js';
export { createSkillTools } from './skill-tools.js';
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
import { guardPath, guardPathUnsafe, guardWritePath, SandboxEscapeError } from './path-guard.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
@@ -101,4 +101,55 @@ describe('guardPath', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('rejects a symlink inside the sandbox that resolves outside it', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-outside-'));
try {
const target = path.join(outside, 'credential.token');
fs.writeFileSync(target, 'OUTSIDE_SYMLINK_SENTINEL');
fs.symlinkSync(target, path.join(tmpDir, 'credential.token'));
expect(() => guardPath('credential.token', tmpDir)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
describe('guardWritePath', () => {
it('allows a new file under an existing real sandbox directory', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
try {
expect(guardWritePath('new.txt', sandbox)).toBe(path.join(sandbox, 'new.txt'));
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
it('rejects writes through a file symlink that resolves outside the sandbox', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
try {
const target = path.join(outside, 'credential.token');
fs.writeFileSync(target, 'OUTSIDE_WRITE_SENTINEL');
fs.symlinkSync(target, path.join(sandbox, 'credential.token'));
expect(() => guardWritePath('credential.token', sandbox)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it('rejects new files under a directory symlink that leaves the sandbox', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
try {
fs.symlinkSync(outside, path.join(sandbox, 'outside'));
expect(() => guardWritePath('outside/new.txt', sandbox)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
+48 -32
View File
@@ -1,47 +1,63 @@
import path from 'node:path';
import fs from 'node:fs';
/**
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
*
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
* within the user path — so symlink escape attempts are caught too.
*
* @param userPath - The path provided by the agent (may be relative or absolute)
* @param sandboxDir - The allowed root directory (already validated on session creation)
* @returns The resolved absolute path, guaranteed to be within sandboxDir
*/
export function guardPath(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath);
const sandboxResolved = fs.realpathSync.native(sandboxDir);
function isContained(candidate: string, root: string): boolean {
return candidate === root || candidate.startsWith(root + path.sep);
}
// Normalize both paths to resolve any symlinks in the sandbox root itself.
// For the user path, we check containment BEFORE resolving symlinks in the path
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
function assertLexicalContainment(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath);
const sandboxAbsolute = path.resolve(sandboxDir);
if (!isContained(resolved, sandboxAbsolute)) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
}
return resolved;
}
/**
* Validates a path without resolving symlinks in the user-provided portion.
* Use for paths that may not exist yet (creates, writes).
*
* Performs a lexical containment check only using path.resolve.
* Resolve an existing path and verify both its lexical path and real symlink
* target remain inside the sandbox.
*/
export function guardPath(userPath: string, sandboxDir: string): string {
const resolved = assertLexicalContainment(userPath, sandboxDir);
const sandboxReal = fs.realpathSync.native(sandboxDir);
const resolvedReal = fs.realpathSync.native(resolved);
if (!isContained(resolvedReal, sandboxReal)) {
throw new SandboxEscapeError(userPath, sandboxDir, resolvedReal);
}
return resolvedReal;
}
/**
* Resolve a writable file path whose parent already exists. Existing targets
* are resolved fully. New targets use the real parent directory, which blocks
* writes through a parent symlink that leaves the sandbox.
*/
export function guardWritePath(userPath: string, sandboxDir: string): string {
const resolved = assertLexicalContainment(userPath, sandboxDir);
const sandboxReal = fs.realpathSync.native(sandboxDir);
let writableReal: string;
try {
writableReal = fs.realpathSync.native(resolved);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw error;
const parentReal = fs.realpathSync.native(path.dirname(resolved));
writableReal = path.join(parentReal, path.basename(resolved));
}
if (!isContained(writableReal, sandboxReal)) {
throw new SandboxEscapeError(userPath, sandboxDir, writableReal);
}
return writableReal;
}
/**
* Lexical-only validation for non-filesystem pathspecs such as `git diff --`
* targets, where the path may name a deleted file and Git does not dereference
* a tracked symlink.
*/
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath);
const sandboxAbs = path.resolve(sandboxDir);
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
}
return resolved;
return assertLexicalContainment(userPath, sandboxDir);
}
export class SandboxEscapeError extends Error {
@@ -128,6 +128,14 @@ function runCommand(
});
}
export function createShellToolsIfEnabled(
sandboxDir: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): ToolDefinition[] {
if (env['AGENT_SHELL_ENABLED'] === 'false') return [];
return createShellTools(sandboxDir);
}
export function createShellTools(sandboxDir?: string): ToolDefinition[] {
const defaultCwd = sandboxDir ?? process.cwd();
+2
View File
@@ -25,6 +25,7 @@ import { HarnessModule } from './harness/harness.module.js';
import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js';
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
import { EnrollmentModule } from './enrollment/enrollment.module.js';
import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@@ -67,6 +68,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
ReloadModule,
WorkspaceModule,
HierarchyModule,
EnrollmentModule,
...(federationEnabled ? [FederationModule] : []),
],
controllers: [HealthController],
@@ -61,6 +61,33 @@ describe('CommandAuthorizationService', () => {
).toBe(false);
});
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
const service = createService('admin');
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
.allowed,
).toBe(false);
}
});
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
const service = createService('member');
for (const [scope, allowed] of [
['core', true],
['agent', true],
['skill', false],
['plugin', false],
] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
.allowed,
).toBe(allowed);
}
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
@@ -154,8 +154,15 @@ export class CommandAuthorizationService {
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
/**
* Contract 2 §1.1: platform admin confers instance administration only —
* the former admin-passes-every-scope short-circuit is retired. Admin
* reaches exactly the admin scope; core/agent scopes belong to the member
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
* them (§3.1 deny-by-default).
*/
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (role === 'admin') return true;
if (scope === 'admin') return role === 'admin';
return role === 'member' && (scope === 'core' || scope === 'agent');
}
@@ -0,0 +1,508 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { Logger, ValidationPipe, type ExecutionContext } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import supertest from 'supertest';
import { unseal } from '@mosaicstack/auth';
import {
agentAuditEvents,
agentIdempotencyFence,
agentOutbox,
agents,
and,
createPgliteDb,
eq,
providerCredentials,
runPgliteMigrations,
sql,
users,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
import { FakeHarnessAdapter } from '../harness/testing/fake-harness.adapter.js';
import { EnrollmentController } from './enrollment.controller.js';
import {
EnrollmentRepository,
type EnrollAgentInput,
type EnrollmentResult,
type EnrolledAgentView,
} from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* Command-level witnesses for the agent enrollment family (M4-4b) — design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §5 items 1–9 and
* 11 (item 10, CLI parity, lives in packages/mosaic). Schema-level
* constraints are witnessed in packages/db/src/agent-enrollment.witness.test.ts.
*
* The suite runs the REAL repository/service/controller graph over PGlite,
* with only AuthGuard overridden (a session store is out of scope; the
* override binds request.user exactly as the real guard does). The §6.3
* static companions — no `any`-typed boundary pass-through, a single audit
* emitter (EnrollmentRepository.appendEvent) — are code-surface properties
* reviewed on the PR, not runtime probes.
*/
describe('enrollment commands integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let app: NestFastifyApplication;
let http: ReturnType<typeof supertest>;
let repo: EnrollmentRepository;
let previousAuthSecret: string | undefined;
const OWNER = 'enr-owner';
const ADMIN = 'enr-admin';
const STRANGER = 'enr-stranger';
const HARNESS = 'fake-harness';
/** Never-echo probe value (§5.1). Unique enough that any leak is unambiguous. */
const SECRET = `enr-secret-value-${randomUUID()}`;
/** The HTTP-leg acting user; the overridden guard binds it per request. */
let currentUserId = OWNER;
const enrollInput = (overrides: Partial<EnrollAgentInput> = {}): EnrollAgentInput => ({
actorId: OWNER,
harness: HARNESS,
name: `Agent ${randomUUID().slice(0, 8)}`,
persona: null,
model: 'anthropic/claude-test',
provider: `prov-${randomUUID().slice(0, 8)}`,
credential: { mode: 'intake', type: 'api_key', value: SECRET },
idempotencyKey: randomUUID(),
...overrides,
});
function expectOk<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}
function expectFail<T>(
result: EnrollmentResult<T>,
error: string,
): { ok: false; error: string; message: string; correlationId: string } {
if (result.ok) throw new Error(`expected ${error}, got ok`);
expect(result.error).toBe(error);
return result;
}
const fenceForKey = (key: string) =>
handle.db
.select()
.from(agentIdempotencyFence)
.where(eq(agentIdempotencyFence.idempotencyKey, key));
const eventsForAgent = (agentId: string) =>
handle.db.select().from(agentAuditEvents).where(eq(agentAuditEvents.agentId, agentId));
const agentsNamed = (name: string) =>
handle.db.select().from(agents).where(eq(agents.name, name));
const credentialsFor = (userId: string, provider: string) =>
handle.db
.select()
.from(providerCredentials)
.where(
and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)),
);
const allOutbox = () => handle.db.select().from(agentOutbox);
beforeAll(async (): Promise<void> => {
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = 'enrollment-witness-sealing-key';
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-enrollment-commands-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: HARNESS }));
moduleRef = await Test.createTestingModule({
controllers: [EnrollmentController],
providers: [
EnrollmentRepository,
EnrollmentService,
{ provide: DB, useValue: handle.db },
{ provide: HARNESS_REGISTRY, useValue: registry },
],
})
.overrideGuard(AuthGuard)
.useValue({
canActivate: (ctx: ExecutionContext): boolean => {
const request = ctx.switchToHttp().getRequest<{ user?: unknown }>();
request.user = { id: currentUserId };
return true;
},
})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
// Mirror main.ts exactly — the closure witnesses depend on these options.
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
http = supertest(app.getHttpServer());
repo = moduleRef.get(EnrollmentRepository);
await handle.db.insert(users).values([
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
]);
});
afterAll(async (): Promise<void> => {
await app?.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
});
// ── §5.7 wizard-facing zero-mutation (runs FIRST: no call → zero rows) ────
it('zero-mutation: with no enrollment invocation the family tables hold zero rows', async () => {
expect(await handle.db.select().from(agents)).toHaveLength(0);
expect(await handle.db.select().from(agentAuditEvents)).toHaveLength(0);
expect(await handle.db.select().from(agentOutbox)).toHaveLength(0);
expect(await handle.db.select().from(agentIdempotencyFence)).toHaveLength(0);
});
// ── §5.1 never-echo + §5.2 sealed single-copy ─────────────────────────────
it('never echoes the intake credential value: HTTP result, audit, outbox, fence, and logs are clean', async () => {
const logSink: string[] = [];
const logSpies = (['log', 'error', 'warn', 'debug', 'verbose'] as const).map((method) =>
vi.spyOn(Logger.prototype, method).mockImplementation((...args: unknown[]) => {
logSink.push(args.map(String).join(' '));
}),
);
try {
currentUserId = OWNER;
const provider = `prov-echo-${randomUUID().slice(0, 8)}`;
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'Echo Probe',
persona: 'a persona',
model: 'anthropic/claude-test',
provider,
credential: { mode: 'intake', type: 'api_key', value: SECRET },
idempotencyKey: randomUUID(),
});
expect(res.status).toBe(201);
expect(res.text).not.toContain(SECRET);
const agentId = (res.body as { agent: EnrolledAgentView }).agent.id;
const events = await eventsForAgent(agentId);
expect(events).toHaveLength(1);
expect(JSON.stringify(events)).not.toContain(SECRET);
expect(JSON.stringify(await allOutbox())).not.toContain(SECRET);
const fences = await handle.db
.select()
.from(agentIdempotencyFence)
.where(eq(agentIdempotencyFence.outcomeAgentId, agentId));
expect(fences).toHaveLength(1);
expect(JSON.stringify(fences)).not.toContain(SECRET);
expect(logSink.join('\n')).not.toContain(SECRET);
// §5.2 sealed single-copy: exactly one provider_credentials row, sealed
// at rest, and it round-trips through unseal — no plaintext column.
const creds = await credentialsFor(OWNER, provider);
expect(creds).toHaveLength(1);
expect(creds[0]?.encryptedValue).not.toBe(SECRET);
expect(creds[0]?.encryptedValue).not.toContain(SECRET);
expect(unseal(creds[0]?.encryptedValue as string)).toBe(SECRET);
} finally {
logSpies.forEach((spy) => spy.mockRestore());
}
});
it('the agents table itself has no credential-bearing column (§5.2)', async () => {
const result = (await handle.db.execute(
sql`select column_name from information_schema.columns where table_name = 'agents'`,
)) as unknown as { rows?: Array<{ column_name: string }> } & Array<{ column_name: string }>;
const names = (result.rows ?? result).map((row) => row.column_name);
expect(names.length).toBeGreaterThan(0);
for (const name of names) {
expect(name).not.toMatch(/credential|secret|token|api_key/i);
}
});
// ── §5.3 reference resolution ─────────────────────────────────────────────
it('refuses an unresolvable credential reference with precondition_failed and creates nothing', async () => {
const input = enrollInput({ credential: { mode: 'reference' } });
const result = await repo.enroll(input);
expectFail(result, 'precondition_failed');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
});
it('resolves a reference credential stored earlier for (actor, provider)', async () => {
const provider = `prov-ref-${randomUUID().slice(0, 8)}`;
const seeded = expectOk(await repo.enroll(enrollInput({ provider })));
const result = expectOk(
await repo.enroll(enrollInput({ provider, credential: { mode: 'reference' } })),
);
expect(result.agent.id).not.toBe(seeded.agent.id);
expect(await credentialsFor(OWNER, provider)).toHaveLength(1);
});
// ── §5.4 harness refusals, both codes ────────────────────────────────────
it('refuses a syntactically invalid harness as validation_failed and a registry miss as precondition_failed', async () => {
const blank = await repo.enroll(enrollInput({ harness: ' ' }));
expectFail(blank, 'validation_failed');
const miss = await repo.enroll(enrollInput({ harness: 'well-formed-but-unregistered' }));
expectFail(miss, 'precondition_failed');
currentUserId = OWNER;
const httpBlank = await http.post('/api/enrollment/agents').send({
harness: '',
name: 'H',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: randomUUID(),
});
expect(httpBlank.status).toBe(400);
});
// ── §5.5 idempotency set (contract 3 §4.3) ───────────────────────────────
it('actor-bound replay returns the recorded outcome and executes nothing new', async () => {
const input = enrollInput();
const first = expectOk(await repo.enroll(input));
const replay = expectOk(await repo.enroll({ ...input, correlationId: randomUUID() }));
expect(replay.agent.id).toBe(first.agent.id);
expect(await agentsNamed(input.name)).toHaveLength(1);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
const events = await eventsForAgent(first.agent.id);
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
// A passing replay appends exactly the non-mutation access event.
const replayed = events.filter((e) => e.eventType === 'agent.enrollment.replayed');
expect(replayed).toHaveLength(1);
expect((replayed[0]?.payload as { fenceId?: string }).fenceId).toBeDefined();
});
it('payload-digest mismatch on a recorded key refuses with the single bounded conflict shape', async () => {
const input = enrollInput();
expectOk(await repo.enroll(input));
const mismatch = await repo.enroll({ ...input, name: `${input.name} CHANGED` });
const failure = expectFail(mismatch, 'conflict');
expect(failure.message).toBe('idempotency conflict');
});
it('replay-mode and scope mismatches on the recorded fence each refuse as the same constant conflict', async () => {
const modeInput = enrollInput();
expectOk(await repo.enroll(modeInput));
await handle.db
.update(agentIdempotencyFence)
.set({ replayMode: 'shared' })
.where(eq(agentIdempotencyFence.idempotencyKey, modeInput.idempotencyKey));
const modeFailure = expectFail(await repo.enroll(modeInput), 'conflict');
const scopeInput = enrollInput();
expectOk(await repo.enroll(scopeInput));
await handle.db
.update(agentIdempotencyFence)
.set({ authorizationScope: 'some-other-scope' })
.where(eq(agentIdempotencyFence.idempotencyKey, scopeInput.idempotencyKey));
const scopeFailure = expectFail(await repo.enroll(scopeInput), 'conflict');
expect(modeFailure.message).toBe(scopeFailure.message);
});
it('a different actor replaying an actor-bound key is refused conflict, learning nothing', async () => {
const input = enrollInput();
expectOk(await repo.enroll(input));
const failure = expectFail(await repo.enroll({ ...input, actorId: STRANGER }), 'conflict');
expect(failure.message).toBe('idempotency conflict');
});
it('a replay is re-authorized fresh: revoked target authority refuses instead of replaying', async () => {
const input = enrollInput();
const first = expectOk(await repo.enroll(input));
// Simulate the legacy CRUD DELETE path removing the outcome agent: the
// submitter no longer holds read authority on the referenced row.
await handle.db.delete(agents).where(eq(agents.id, first.agent.id));
expectFail(await repo.enroll(input), 'conflict');
});
it('a shared replay-mode declaration is refused validation_failed with nothing executed and no fence row', async () => {
const input = enrollInput({ replayMode: 'shared' });
expectFail(await repo.enroll(input), 'validation_failed');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
currentUserId = OWNER;
const key = randomUUID();
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'Shared Probe',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: key,
replayMode: 'shared',
});
expect(res.status).toBe(400);
expect(await fenceForKey(key)).toHaveLength(0);
});
it('two concurrent same-key submissions produce exactly one mutation, the loser resolving as a replay', async () => {
const input = enrollInput();
const [a, b] = await Promise.all([
repo.enroll(input),
repo.enroll({ ...input, correlationId: randomUUID() }),
]);
const okA = expectOk(a);
const okB = expectOk(b);
expect(okA.agent.id).toBe(okB.agent.id);
expect(await agentsNamed(input.name)).toHaveLength(1);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
const events = await eventsForAgent(okA.agent.id);
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
expect(events.filter((e) => e.eventType === 'agent.enrollment.replayed')).toHaveLength(1);
});
// ── §5.6 same-tx atomicity fault injection ───────────────────────────────
it('rolls everything back on failure at each write point — no orphan credential survives', async () => {
const injectionPoints = [
'writeSealedCredential',
'insertAgentRow',
'insertFenceRow',
'appendEvent',
'insertOutboxRow',
] as const;
for (const point of injectionPoints) {
const input = enrollInput();
const spy = vi.spyOn(repo, point).mockImplementationOnce(() => {
throw new Error(`injected ${point} fault`);
});
try {
const result = await repo.enroll(input);
expectFail(result, 'internal_fault');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
// Injection at fence/audit/outbox fires AFTER the sealed credential
// write's statement ran — the rollback must leave no orphan row.
expect(await credentialsFor(OWNER, input.provider)).toHaveLength(0);
} finally {
spy.mockRestore();
}
}
});
// ── §5.8 is_system closure ───────────────────────────────────────────────
it('rejects an is_system injection attempt at the DTO boundary', async () => {
currentUserId = OWNER;
const key = randomUUID();
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'System Probe',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: key,
isSystem: true,
});
expect(res.status).toBe(400);
expect(await fenceForKey(key)).toHaveLength(0);
});
// ── §5.9 correlation + no-existence-oracle ───────────────────────────────
it('carries a submitted correlation id into the result, the audit event, and the outbox record', async () => {
const correlationId = randomUUID();
const input = enrollInput({ correlationId });
const result = expectOk(await repo.enroll(input));
expect(result.correlationId).toBe(correlationId);
const events = await eventsForAgent(result.agent.id);
expect(events).toHaveLength(1);
expect(events[0]?.correlationId).toBe(correlationId);
const outboxRows = await handle.db
.select()
.from(agentOutbox)
.where(eq(agentOutbox.eventId, events[0]?.id as string));
expect(outboxRows).toHaveLength(1);
expect(outboxRows[0]?.correlationId).toBe(correlationId);
// Refusals carry the correlation envelope too (contract 5 §4.3).
const refusal = expectFail(
await repo.enroll({ ...input, name: 'changed name', correlationId }),
'conflict',
);
expect(refusal.correlationId).toBe(correlationId);
});
it('agent.enrollment.get returns owner and admin reads with the correlation envelope, no idempotency key', async () => {
const enrolled = expectOk(await repo.enroll(enrollInput()));
const correlationId = randomUUID();
const asOwner = expectOk(await repo.getEnrollment(OWNER, enrolled.agent.id, correlationId));
expect(asOwner.correlationId).toBe(correlationId);
expect(asOwner.agent.id).toBe(enrolled.agent.id);
const asAdmin = expectOk(await repo.getEnrollment(ADMIN, enrolled.agent.id));
expect(asAdmin.correlationId).toMatch(/^[0-9a-f-]{36}$/);
currentUserId = OWNER;
const wire = randomUUID();
const res = await http.get(`/api/enrollment/agents/${enrolled.agent.id}?correlationId=${wire}`);
expect(res.status).toBe(200);
expect((res.body as { correlationId: string }).correlationId).toBe(wire);
});
it('no existence oracle: unauthorized get of a real agent and get of a missing id are indistinguishable', async () => {
const enrolled = expectOk(await repo.enroll(enrollInput()));
currentUserId = STRANGER;
const unauthorized = await http.get(`/api/enrollment/agents/${enrolled.agent.id}`);
const missing = await http.get(`/api/enrollment/agents/${randomUUID()}`);
expect(unauthorized.status).toBe(404);
expect(missing.status).toBe(404);
const strip = (body: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'correlationId'));
expect(strip(unauthorized.body as Record<string, unknown>)).toEqual(
strip(missing.body as Record<string, unknown>),
);
});
// ── §5.11 fail-closed ────────────────────────────────────────────────────
it('fails closed as internal_fault when the store is unreachable, with no fallback write', async () => {
const before = (await handle.db.select().from(agents)).length;
const txSpy = vi.spyOn(handle.db, 'transaction').mockImplementationOnce(() => {
throw new Error('injected store outage');
});
try {
expectFail(await repo.enroll(enrollInput()), 'internal_fault');
} finally {
txSpy.mockRestore();
}
const selectSpy = vi.spyOn(handle.db, 'select').mockImplementationOnce(() => {
throw new Error('injected store outage');
});
try {
expectFail(await repo.getEnrollment(OWNER, randomUUID()), 'internal_fault');
} finally {
selectSpy.mockRestore();
}
expect((await handle.db.select().from(agents)).length).toBe(before);
});
});
@@ -0,0 +1,61 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { EnrollAgentDto, GetEnrollmentQueryDto } from './enrollment.dto.js';
import { EnrollmentRepository } from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* The agent enrollment command family's closed HTTP surface (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3): one command,
* one query. Authentication failures are the guard's (401); everything else
* is the repository's closed enum mapped by EnrollmentService.
*/
@Controller('api/enrollment')
@UseGuards(AuthGuard)
export class EnrollmentController {
constructor(
private readonly repository: EnrollmentRepository,
private readonly service: EnrollmentService,
) {}
/** agent.enroll (§3.1). */
@Post('agents')
async enroll(@CurrentUser() user: { id: string }, @Body() dto: EnrollAgentDto) {
return this.service.unwrap(
await this.repository.enroll({
actorId: user.id,
harness: dto.harness,
name: dto.name,
persona: dto.persona ?? null,
model: dto.model,
provider: dto.provider,
credential: dto.credential,
idempotencyKey: dto.idempotencyKey,
correlationId: dto.correlationId,
replayMode: dto.replayMode,
}),
);
}
/** agent.enrollment.get (§3.2): owner-or-admin; unauthorized and missing fold to one not_found. */
@Get('agents/:id')
async getEnrollment(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Query() query: GetEnrollmentQueryDto,
) {
return this.service.unwrap(
await this.repository.getEnrollment(user.id, id, query.correlationId),
);
}
}
@@ -0,0 +1,107 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
/**
* Agent enrollment command DTOs (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.1/§3.2,
* contract 5 §4.1 typed boundary).
*
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so
* closure is contract surface here exactly as in the hierarchy DTOs:
* - EnrollAgentDto declares NO isSystem field — `is_system` is never
* settable through this command (design §3.1 rule 4); the pipe refuses it.
* - replayMode admits ONLY 'actor-bound': `shared` is seed-only (contract 3
* §4.3), so a shared declaration is refused `validation_failed` at the
* boundary, executes nothing, and records no fence row (design §3.1).
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
* assertion proves the pipe sees the decorators.
*/
/**
* Credential input, discriminated on `mode` (design §3.1):
* - `{ mode: 'reference' }` — a stored credential for (actor, provider)
* must already exist; `type`/`value` must be ABSENT (the repository
* refuses a reference that smuggles a value).
* - `{ mode: 'intake', type: 'api_key', value }` — the value is sealed
* into the credential store inside the enrollment transaction and is
* never echoed anywhere (§3.1 rule 1).
*/
export class EnrollCredentialDto {
@IsIn(['reference', 'intake'])
mode!: 'reference' | 'intake';
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
@IsIn(['api_key'])
type?: 'api_key';
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
@IsString()
@MinLength(1)
@MaxLength(4096)
value?: string;
}
export class EnrollAgentDto {
/** Registered harness name; a well-formed name missing from the registry is `precondition_failed`. */
@IsString()
@MinLength(1)
@MaxLength(200)
harness!: string;
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
/** Stored as the agent's system prompt; null/absent leaves it unset. */
@IsOptional()
@IsString()
@MaxLength(20000)
persona?: string | null;
/** Provider-qualified model id. */
@IsString()
@MinLength(1)
@MaxLength(200)
model!: string;
/** Names the credential's provider. */
@IsString()
@MinLength(1)
@MaxLength(200)
provider!: string;
@ValidateNested()
@Type(() => EnrollCredentialDto)
credential!: EnrollCredentialDto;
/** REQUIRED — contract 3 §4.3, ratified into contract 5 §4 via §7 item 4. */
@IsUUID()
idempotencyKey!: string;
/** Optional; generated when absent (contract 5 §4.3). */
@IsOptional()
@IsUUID()
correlationId?: string;
/** Only 'actor-bound' is admissible on this family — see module doc. */
@IsOptional()
@IsIn(['actor-bound'])
replayMode?: 'actor-bound';
}
/** Query envelope for agent.enrollment.get (design §3.2): correlation only, no idempotency key. */
export class GetEnrollmentQueryDto {
@IsOptional()
@IsUUID()
correlationId?: string;
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { HarnessModule } from '../harness/harness.module.js';
import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentRepository } from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* Agent enrollment command family (M4-4b; design
* docs/plans/2026-08-29-agent-enrollment-command-design.md). Imports
* HarnessModule for the live harness registry — the validation source for
* the `harness` field (a well-formed name the registry does not know is a
* precondition failure). EnrollmentRepository is the family's sole writer;
* every mutation runs fence-check → mutate → audit + outbox in one
* transaction.
*/
@Module({
imports: [HarnessModule],
controllers: [EnrollmentController],
providers: [EnrollmentRepository, EnrollmentService],
exports: [EnrollmentRepository],
})
export class EnrollmentModule {}
@@ -0,0 +1,538 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { seal } from '@mosaicstack/auth';
import {
agentAuditEvents,
agentIdempotencyFence,
agentOutbox,
agents,
and,
eq,
providerCredentials,
users,
type Db,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import type { HarnessRegistry } from '../harness/harness.registry.js';
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
/**
* Agent enrollment command repository (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3; contract 5 §4
* envelope; contract 3 §4.3 idempotency fence, ratified via §7 item 4).
*
* The ONLY writer of the enrollment family's tables (`agent_audit_events`,
* `agent_outbox`, `agent_idempotency_fence`) and the only path that sets
* `agents.harness`/`agents.enrolled_at`. Every enroll runs one transaction:
* fence check → (replay | credential handling → agent insert → fence insert →
* audit event + outbox), so state, fence, event, and outbox commit or roll
* back together (§3.1 rule 6).
*
* Authorization (v1, §3.1 rule 4) is the AuthGuard-authenticated actor — no
* hierarchy grant is consulted because v1 enrollment binds no hierarchy node.
* The recorded fence authorization scope is therefore the constant
* platform-user identity domain (§3.1 rule 5).
*
* Never-echo (§3.1 rule 1): the credential value reaches exactly one sink —
* the sealed store write — and appears in no result, audit payload, outbox
* row, or log line. Log lines here carry correlation ids and error names
* only, never request fields.
*
* The single-write helper methods (writeSealedCredential, insertAgentRow,
* insertFenceRow, appendEvent, insertOutboxRow) are ordinary decomposition;
* the atomicity witnesses (§5.6) spy on them to inject faults at each write
* point without any test-only production switch.
*/
export const ENROLLMENT_OPERATION = 'agent.enroll';
/** §3.1 rule 5: v1 authorization is grant-free, so the scope is the authenticated-user identity domain. */
const AUTHORIZATION_SCOPE = 'platform-user';
/** The single bounded collision shape (§3.1 rule 5): constant, identifying no record. */
const CONFLICT_MESSAGE = 'idempotency conflict';
/** One fixed message for every not_found cause — missing and unauthorized are indistinguishable (§3.2). */
const NOT_FOUND_MESSAGE = 'agent not found';
/** Closed per-family error enum (§3.3). 401 is produced by AuthGuard; 403 folds to not_found (§3.2). */
export type EnrollmentErrorCode =
| 'validation_failed'
| 'authentication_failed'
| 'authorization_refused'
| 'not_found'
| 'conflict'
| 'precondition_failed'
| 'internal_fault';
export interface EnrollmentFailure {
readonly ok: false;
readonly error: EnrollmentErrorCode;
readonly message: string;
/** Refusals carry the correlation id too (contract 5 §4.3 end-to-end traceability). */
readonly correlationId: string;
}
export type EnrollmentResult<T> =
| ({ readonly ok: true; readonly correlationId: string } & T)
| EnrollmentFailure;
/** The persisted agent row; the table stores no credential material (§3.1 rule 1). */
export interface EnrolledAgentView {
readonly id: string;
readonly name: string;
readonly provider: string;
readonly model: string;
readonly status: string;
readonly harness: string | null;
readonly persona: string | null;
readonly ownerId: string | null;
readonly enrolledAt: string | null;
readonly createdAt: string;
}
export interface EnrollCredentialInput {
readonly mode: 'reference' | 'intake';
readonly type?: 'api_key';
readonly value?: string;
}
export interface EnrollAgentInput {
readonly actorId: string;
readonly harness: string;
readonly name: string;
readonly persona?: string | null;
readonly model: string;
readonly provider: string;
readonly credential: EnrollCredentialInput;
readonly idempotencyKey: string;
readonly correlationId?: string;
/** Defense in depth below the DTO: anything but 'actor-bound' is refused (seed-only rule). */
readonly replayMode?: string;
}
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
type AgentRow = typeof agents.$inferSelect;
type FenceRow = typeof agentIdempotencyFence.$inferSelect;
/** Raised inside the transaction when the fence insert lost a same-key race (§3.1 rule 5 concurrency). */
class ConcurrentEnrollmentError extends Error {
constructor() {
super('concurrent enrollment lost the fence race');
this.name = 'ConcurrentEnrollmentError';
}
}
function agentView(row: AgentRow): EnrolledAgentView {
return {
id: row.id,
name: row.name,
provider: row.provider,
model: row.model,
status: row.status,
harness: row.harness,
persona: row.systemPrompt,
ownerId: row.ownerId,
enrolledAt: row.enrolledAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
/** Key-order-independent serialization (jsonb precedent in hierarchy-audit). */
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>;
const body = Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',');
return `{${body}}`;
}
return JSON.stringify(value);
}
interface NormalizedEnrollment {
readonly actorId: string;
readonly harness: string;
readonly name: string;
readonly persona: string | null;
readonly model: string;
readonly provider: string;
readonly credential: EnrollCredentialInput;
readonly idempotencyKey: string;
readonly correlationId: string;
readonly digest: string;
}
/**
* Canonicalized-payload digest (§3.1 rule 5). The input EXCLUDES the
* credential value by construction: it covers mode and declared type only —
* plaintext never reaches the hash.
*/
function digestOf(
input: Omit<NormalizedEnrollment, 'actorId' | 'idempotencyKey' | 'correlationId' | 'digest'>,
): string {
const canonical = canonicalJson({
harness: input.harness,
name: input.name,
persona: input.persona,
model: input.model,
provider: input.provider,
credential: { mode: input.credential.mode, type: input.credential.type ?? null },
});
return createHash('sha256').update(canonical).digest('hex');
}
@Injectable()
export class EnrollmentRepository {
private readonly logger = new Logger(EnrollmentRepository.name);
constructor(
@Inject(DB) private readonly db: Db,
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
) {}
async enroll(input: EnrollAgentInput): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const correlationId = input.correlationId ?? randomUUID();
const fail = (error: EnrollmentErrorCode, message: string): EnrollmentFailure => ({
ok: false,
error,
message,
correlationId,
});
const harness = input.harness.trim();
const name = input.name.trim();
if (harness.length === 0) return fail('validation_failed', 'harness must be non-empty');
if (name.length === 0 || name.length > 200) {
return fail('validation_failed', 'name must be non-empty and at most 200 characters');
}
if (input.replayMode !== undefined && input.replayMode !== 'actor-bound') {
// Seed-only rule (contract 3 §4.3): refused with nothing executed and no fence row.
return fail('validation_failed', 'replayMode must be actor-bound');
}
if (input.credential.mode === 'reference') {
if (input.credential.type !== undefined || input.credential.value !== undefined) {
return fail('validation_failed', 'a reference credential carries no type or value');
}
} else if (
input.credential.type !== 'api_key' ||
typeof input.credential.value !== 'string' ||
input.credential.value.length === 0
) {
return fail('validation_failed', 'an intake credential requires type api_key and a value');
}
// Syntactic validity ends above; a well-formed name the live registry
// does not know is a precondition failure (§3.1 table).
if (!this.registry.has(harness)) {
return fail('precondition_failed', 'harness is not registered');
}
const normalized: NormalizedEnrollment = {
actorId: input.actorId,
harness,
name,
persona: input.persona ?? null,
model: input.model,
provider: input.provider,
credential: input.credential,
idempotencyKey: input.idempotencyKey,
correlationId,
digest: digestOf({
harness,
name,
persona: input.persona ?? null,
model: input.model,
provider: input.provider,
credential: input.credential,
}),
};
// Two attempts: a fence-race loser's transaction rolls back and the retry
// resolves through the replay path against the winner's committed row —
// or executes afresh if the winner aborted (§3.1 rule 5 concurrency). A
// unique-violation race never surfaces as an unhandled internal fault.
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
return await this.db.transaction(async (tx) => this.enrollTx(tx, normalized));
} catch (error) {
if (error instanceof ConcurrentEnrollmentError && attempt === 0) continue;
if (error instanceof ConcurrentEnrollmentError) {
return fail('conflict', CONFLICT_MESSAGE);
}
// §4.4 fail-closed: whatever broke, the transaction rolled back and
// the refusal is the internal-fault class — no fallback write or read.
this.logger.error(
`agent.enroll failed closed (correlation=${correlationId}): ${
error instanceof Error ? error.name : 'unknown error'
}`,
);
return fail('internal_fault', 'internal fault');
}
}
return fail('internal_fault', 'internal fault');
}
private async enrollTx(
tx: Tx,
input: NormalizedEnrollment,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const fence = await this.fenceFor(tx, input.idempotencyKey);
if (fence) return this.replay(tx, fence, input);
if (input.credential.mode === 'reference') {
// §3.1 rule 3: the reference must resolve for (actor, provider).
const existing = await tx
.select({ id: providerCredentials.id })
.from(providerCredentials)
.where(
and(
eq(providerCredentials.userId, input.actorId),
eq(providerCredentials.provider, input.provider),
),
)
.limit(1);
if (existing.length === 0) {
return {
ok: false,
error: 'precondition_failed',
message: 'credential reference does not resolve',
correlationId: input.correlationId,
};
}
} else {
// §3.1 rule 2: sealed-store write inside THIS transaction — a later
// failure rolls it back, leaving no orphan credential.
await this.writeSealedCredential(
tx,
input.actorId,
input.provider,
input.credential.value as string,
);
}
const agentRow = await this.insertAgentRow(tx, input);
const fenceRow = await this.insertFenceRow(tx, input, agentRow.id);
if (!fenceRow) {
// A same-(operation, key) winner committed first; abandon our writes.
throw new ConcurrentEnrollmentError();
}
await this.appendEvent(tx, {
eventType: 'agent.enrolled',
actorId: input.actorId,
agentId: agentRow.id,
correlationId: input.correlationId,
// §3.1 rule 6 payload: harness, provider, name, credentialMode — no credential material.
payload: {
harness: input.harness,
provider: input.provider,
name: input.name,
credentialMode: input.credential.mode,
},
});
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
}
/**
* Replay path (§3.1 rule 5): a fresh submission of a recorded
* (operation, key). The actor is re-authorized exactly as a fresh
* submission (v1: authenticated actor — the guard already ran); then mode,
* scope, digest, and recorded-actor equality; then target-result read
* authority (owner or admin) on the referenced agent. ANY failure refuses
* with the single bounded conflict shape — constant, identifying no record.
* A passing replay executes nothing and appends only the non-mutation
* access event (with its outbox record — one outbox row per event).
*/
private async replay(
tx: Tx,
fence: FenceRow,
input: NormalizedEnrollment,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const collision: EnrollmentFailure = {
ok: false,
error: 'conflict',
message: CONFLICT_MESSAGE,
correlationId: input.correlationId,
};
if (fence.replayMode !== 'actor-bound') return collision;
if (fence.authorizationScope !== AUTHORIZATION_SCOPE) return collision;
if (fence.payloadDigest !== input.digest) return collision;
if (fence.actorId !== input.actorId) return collision;
const rows = await tx.select().from(agents).where(eq(agents.id, fence.outcomeAgentId)).limit(1);
const agentRow = rows[0];
if (!agentRow) return collision;
const authorized =
agentRow.ownerId === input.actorId || (await this.isPlatformAdmin(tx, input.actorId));
if (!authorized) return collision;
await this.appendEvent(tx, {
eventType: 'agent.enrollment.replayed',
actorId: input.actorId,
agentId: agentRow.id,
correlationId: input.correlationId,
payload: { fenceId: fence.id },
});
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
}
/**
* agent.enrollment.get (§3.2): owner-or-admin read. Unauthorized and
* missing fold to the same not_found wire shape (no existence oracle).
*/
async getEnrollment(
actorId: string,
agentId: string,
correlationId?: string,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const resolvedCorrelation = correlationId ?? randomUUID();
try {
const rows = await this.db.select().from(agents).where(eq(agents.id, agentId)).limit(1);
const row = rows[0];
if (row) {
const authorized =
row.ownerId === actorId || (await this.isPlatformAdmin(this.db, actorId));
if (authorized) {
return { ok: true, correlationId: resolvedCorrelation, agent: agentView(row) };
}
}
return {
ok: false,
error: 'not_found',
message: NOT_FOUND_MESSAGE,
correlationId: resolvedCorrelation,
};
} catch (error) {
this.logger.error(
`agent.enrollment.get failed closed (correlation=${resolvedCorrelation}): ${
error instanceof Error ? error.name : 'unknown error'
}`,
);
return {
ok: false,
error: 'internal_fault',
message: 'internal fault',
correlationId: resolvedCorrelation,
};
}
}
private async fenceFor(tx: Tx, idempotencyKey: string): Promise<FenceRow | null> {
const rows = await tx
.select()
.from(agentIdempotencyFence)
.where(
and(
eq(agentIdempotencyFence.operation, ENROLLMENT_OPERATION),
eq(agentIdempotencyFence.idempotencyKey, idempotencyKey),
),
)
.limit(1);
return rows[0] ?? null;
}
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
const rows = await tx
.select({ role: users.role })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
return rows[0]?.role === 'admin';
}
/**
* Sealed intake write, mirroring ProviderCredentialsService.store semantics
* (seal-at-rest, one row per (userId, provider)) but on the enrollment
* transaction (§3.1 rule 2). The plaintext exists only in this frame.
*/
async writeSealedCredential(
tx: Tx,
userId: string,
provider: string,
value: string,
): Promise<void> {
const encryptedValue = seal(value);
await tx
.insert(providerCredentials)
.values({ userId, provider, credentialType: 'api_key', encryptedValue, metadata: null })
.onConflictDoUpdate({
target: [providerCredentials.userId, providerCredentials.provider],
set: {
credentialType: 'api_key',
encryptedValue,
metadata: null,
updatedAt: new Date(),
},
});
}
async insertAgentRow(tx: Tx, input: NormalizedEnrollment): Promise<AgentRow> {
const rows = await tx
.insert(agents)
.values({
name: input.name,
provider: input.provider,
model: input.model,
harness: input.harness,
systemPrompt: input.persona,
// §3.1 rule 4: owner is the authenticated actor; is_system stays default false.
ownerId: input.actorId,
enrolledAt: new Date(),
})
.returning();
const row = rows[0];
if (!row) throw new Error('agent insert returned no row');
return row;
}
async insertFenceRow(
tx: Tx,
input: NormalizedEnrollment,
outcomeAgentId: string,
): Promise<FenceRow | null> {
const rows = await tx
.insert(agentIdempotencyFence)
.values({
operation: ENROLLMENT_OPERATION,
idempotencyKey: input.idempotencyKey,
actorId: input.actorId,
authorizationScope: AUTHORIZATION_SCOPE,
payloadDigest: input.digest,
replayMode: 'actor-bound',
outcomeAgentId,
})
.onConflictDoNothing()
.returning();
return rows[0] ?? null;
}
/** Append one audit event and its outbox record on the caller's transaction (one outbox row per event). */
async appendEvent(
tx: Tx,
input: {
eventType: 'agent.enrolled' | 'agent.enrollment.replayed';
actorId: string;
agentId: string;
correlationId: string;
payload: Record<string, unknown>;
causationId?: string;
},
): Promise<void> {
const inserted = await tx
.insert(agentAuditEvents)
.values({
eventType: input.eventType,
actorId: input.actorId,
agentId: input.agentId,
correlationId: input.correlationId,
causationId: input.causationId ?? null,
payload: input.payload,
})
.returning();
const event = inserted[0];
if (!event) throw new Error('agent audit event insert returned no row');
await this.insertOutboxRow(tx, event.id, input.correlationId);
}
async insertOutboxRow(tx: Tx, eventId: string, correlationId: string): Promise<void> {
await tx.insert(agentOutbox).values({ eventId, correlationId });
}
}
@@ -0,0 +1,45 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import type {
EnrollmentErrorCode,
EnrollmentFailure,
EnrollmentResult,
} from './enrollment.repository.js';
/**
* Maps enrollment result unions onto the closed HTTP status set (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.3, contract 5
* §4.2). Every refusal body carries the correlation id (contract 5 §4.3
* end-to-end traceability) alongside the enum code. `not_found` carries one
* fixed message for every cause — missing agent and unauthorized caller are
* indistinguishable on the wire (§3.2).
*/
const HTTP_STATUS: Record<EnrollmentErrorCode, HttpStatus> = {
validation_failed: HttpStatus.BAD_REQUEST,
authentication_failed: HttpStatus.UNAUTHORIZED,
authorization_refused: HttpStatus.FORBIDDEN,
not_found: HttpStatus.NOT_FOUND,
conflict: HttpStatus.CONFLICT,
precondition_failed: HttpStatus.UNPROCESSABLE_ENTITY,
internal_fault: HttpStatus.INTERNAL_SERVER_ERROR,
};
@Injectable()
export class EnrollmentService {
unwrap<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
if (result.ok) return result;
throw this.toException(result);
}
private toException(failure: EnrollmentFailure): HttpException {
const status = HTTP_STATUS[failure.error];
return new HttpException(
{
statusCode: status,
error: failure.error,
message: failure.message,
correlationId: failure.correlationId,
},
status,
);
}
}
@@ -28,8 +28,8 @@ import { DB } from '../database/database.module.js';
*
* This is NOT a class-table writer: it touches only the audit/outbox
* tables, so it does not appear on the writer-coverage allowlist. The
* hierarchy command repositories (M4-1b-ii) are the allowlisted writers and
* call into this on their own transactions.
* hierarchy command repository (HierarchyRepository) is the allowlisted
* writer and calls into this on its own transactions.
*/
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
@@ -0,0 +1,965 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
companies,
createPgliteDb,
eq,
estates,
hierarchyAuditEvents,
hierarchyGrants,
hierarchyOutbox,
runPgliteMigrations,
teams,
users,
workspaces,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
/**
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
* ancestor-chain inheritance, max-role, live revocation, suspended team
* subjects). Schema-level constraints are witnessed in
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
* own atomicity in hierarchy-audit.integration.test.ts.
*
* The rollback legs pre-seed an audit event under the command's idempotency
* key with different content: the command's append then throws inside the
* command transaction, so the whole mutation must roll back — the command
* returns `conflict` and leaves no state change, no second event, and no
* second outbox record.
*/
describe('hierarchy commands integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let repo: HierarchyRepository;
let evaluation: HierarchyGrantEvaluationService;
const OWNER = 'hier-cmd-owner';
const ADMIN = 'hier-cmd-admin';
const STRANGER = 'hier-cmd-stranger';
const SUBJECT = 'hier-cmd-subject';
/** Base fixture: OWNER's company (created through the command surface). */
let companyId: string;
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}
const eventsForKey = (key: string) =>
handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
const outboxForKey = (key: string) =>
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
const seedConflictingKey = async (key: string): Promise<void> => {
await handle.db.transaction(async (tx) =>
appendHierarchyEvent(tx, {
actorId: 'seed-actor',
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { seeded: true },
correlationId: 'seed-correlation',
idempotencyKey: key,
}),
);
};
/**
* §6.4 rollback leg: the command must return `conflict` and leave exactly
* the seeded event/outbox pair under the key — nothing it wrote survives.
*/
const expectRolledBack = async <T>(
key: string,
command: () => Promise<HierarchyResult<T>>,
assertUnchanged: () => Promise<void>,
): Promise<void> => {
await seedConflictingKey(key);
const result = await command();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('conflict');
expect(await eventsForKey(key)).toHaveLength(1);
expect(await outboxForKey(key)).toHaveLength(1);
await assertUnchanged();
};
beforeAll(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [
HierarchyRepository,
HierarchyGrantEvaluationService,
{ provide: DB, useValue: handle.db },
],
}).compile();
repo = moduleRef.get(HierarchyRepository);
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
await handle.db.insert(users).values([
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
]);
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
);
companyId = created.company.id;
});
afterAll(async (): Promise<void> => {
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
// ── §6.4 commit legs ───────────────────────────────────────────────────────
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
const key = `key-${randomUUID()}`;
const result = expectOk(
await repo.createCompany({
actorId: OWNER,
name: 'Atomic Co',
slug: slug('atomic'),
idempotencyKey: key,
}),
);
expect(result.company.visibility).toBe('private');
expect(result.grant.role).toBe('hierarchy:owner');
expect(result.grant.userId).toBe(OWNER);
expect(result.grant.grantedBy).toBe(OWNER);
const [createEvents, grantEvents] = await Promise.all([
eventsForKey(key),
eventsForKey(`${key}:grant`),
]);
expect(createEvents).toHaveLength(1);
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
expect(grantEvents).toHaveLength(1);
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
// The grant event is caused by the create event, same correlation (§4.3).
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
expect(await outboxForKey(key)).toHaveLength(1);
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, result.company.id));
expect(rows).toHaveLength(1);
});
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
);
const extraGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: created.company.id,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.deleteCompany({
actorId: OWNER,
companyId: created.company.id,
idempotencyKey: key,
}),
);
const deleteEvents = await eventsForKey(key);
expect(deleteEvents).toHaveLength(1);
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
expect(revokeEvents).toHaveLength(1);
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
}
expect(
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
).toHaveLength(0);
});
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
);
const key = `key-${randomUUID()}`;
const renamed = expectOk(
await repo.renameCompany({
actorId: OWNER,
companyId: created.company.id,
name: 'New Name Co',
idempotencyKey: key,
}),
);
expect(renamed.company.name).toBe('New Name Co');
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
// §6.4: the audited rename carries the old and new names.
expect(events[0]!.targetSnapshot).toMatchObject({
name: 'New Name Co',
previousName: 'Old Name Co',
});
expect(await outboxForKey(key)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, created.company.id));
expect(rows[0]!.name).toBe('New Name Co');
});
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
const revoked = expectOk(
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
);
expect(revoked.revokedId).toBe(grant.grant.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
expect(await outboxForKey(key)).toHaveLength(1);
// §6 revocation = row deletion: the grant row is gone.
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id));
expect(rows).toHaveLength(0);
});
it('rolls back a create: no estate row survives the aborted transaction', async () => {
const estateSlug = slug('rb-create');
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createEstate({
actorId: OWNER,
companyId,
name: 'Doomed Estate',
slug: estateSlug,
idempotencyKey: key,
}),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
).toHaveLength(0);
},
);
});
it('rolls back a rename: the company keeps its name', async () => {
const before = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.name).toBe(before.name);
},
);
});
it('rolls back a visibility change: the company stays private', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.changeCompanyVisibility({
actorId: ADMIN,
companyId,
visibility: 'directory',
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.visibility).toBe('private');
},
);
});
it('rolls back a transfer: the estate keeps its parent', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
);
const other = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: other.company.id,
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(after.companyId).toBe(companyId);
},
);
});
it('rolls back a delete: the estate row survives', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
).toHaveLength(1);
},
);
});
it('rolls back a grant create: no grant row survives', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createGrant({
actorId: OWNER,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
idempotencyKey: key,
}),
async () => {
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, STRANGER));
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
},
);
});
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const changeKey = `key-${randomUUID()}`;
await expectRolledBack(
changeKey,
() =>
repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
idempotencyKey: changeKey,
}),
async () => {
const row = (
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id))
)[0]!;
expect(row.role).toBe('viewer');
},
);
const revokeKey = `key-${randomUUID()}`;
await expectRolledBack(
revokeKey,
() =>
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
async () => {
expect(
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id)),
).toHaveLength(1);
},
);
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('replays a completed command idempotently through the audit machinery', async () => {
const key = `key-${randomUUID()}`;
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
// The retry's insert no-ops on the slug conflict — the command surfaces
// `conflict`, and crucially appends no second event under the key.
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
expect(retry.ok).toBe(false);
expect(await eventsForKey(key)).toHaveLength(1);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
});
// ── §6.5 authorization ─────────────────────────────────────────────────────
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
ok: false,
error: 'not_found',
});
});
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'member',
}),
);
expect(
await repo.createGrant({
actorId: SUBJECT,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
ok: false,
error: 'not_found',
});
// Member also cannot create children (owner-only, §4.1/§4.3).
expect(
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
).toEqual({ ok: false, error: 'not_found' });
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createGrant({
actorId: ADMIN,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
});
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
const owned = await repo.changeCompanyVisibility({
actorId: OWNER,
companyId,
visibility: 'directory',
});
expect(owned).toEqual({
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
});
const changed = expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
);
expect(changed.company.visibility).toBe('directory');
// Restore for later witnesses.
expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
);
});
// ── §6.9 visibility ────────────────────────────────────────────────────────
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
);
const unlisted = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
idempotencyKey: key,
}),
);
const directory = await repo.listDirectory();
const ids = directory.map((entry) => entry.id);
expect(ids).toContain(listed.company.id);
expect(ids).not.toContain(unlisted.company.id);
expect(ids).not.toContain(companyId);
// Closed-field: existence, name, slug — nothing else (no visibility, no
// timestamps, no grant or membership data).
for (const entry of directory) {
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
}
// §5.5: the audited event carries old and new values.
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
expect(events[0]!.targetSnapshot).toMatchObject({
previousVisibility: 'private',
visibility: 'directory',
});
});
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
);
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
}),
);
// The company is directory-listed for the whole probe window...
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
// ...but the non-granted reader's granted-read surface still excludes it:
// directory disclosure adds existence/name/slug only, never content access.
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
// A stranger mutation of the listed company is refused exactly like a
// missing node — the §6.7 carve-out covers the listing, not commands.
const realProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: listed.company.id,
name: 'x',
});
const missingProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realProbe).toEqual(missingProbe);
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
ok: false,
error: 'not_found',
});
});
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
});
// ── §6.7 no existence oracle ───────────────────────────────────────────────
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
const missingCompany = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realCompany).toEqual(missingCompany);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId,
name: 'Oracle E',
slug: slug('oracle'),
}),
);
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
expect(realEstate).toEqual(missingEstate);
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'viewer',
}),
);
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
expect(realGrant).toEqual(missingGrant);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
);
const project = expectOk(
await repo.createPlatformProject({
actorId: OWNER,
estateId: estate.estate.id,
name: 'Chain P',
slug: slug('chain-p'),
}),
);
// Workspaces are evaluable but not hierarchy commands; seed one directly.
const workspaceId = randomUUID();
await handle.db.insert(workspaces).values({
id: workspaceId,
name: 'Chain W',
slug: slug('chain-w'),
platformProjectId: project.platformProject.id,
});
for (const [kind, id] of [
['company', companyId],
['estate', estate.estate.id],
['platform_project', project.platformProject.id],
['workspace', workspaceId],
] as const) {
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
}
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
// and below the estate, viewer at the company.
const viewerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const ownerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'owner',
}),
);
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
// Revocation is row deletion and denies the very next evaluation (§6).
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
expectOk(
await repo.deletePlatformProject({
actorId: OWNER,
platformProjectId: project.platformProject.id,
}),
);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
const teamId = randomUUID();
await handle.db.insert(teams).values({
id: teamId,
name: slug('team'),
slug: slug('team'),
ownerId: SUBJECT,
managerId: SUBJECT,
});
// Out-of-band team row (the command surface cannot create one).
const inserted = await handle.db
.insert(hierarchyGrants)
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
.returning();
const teamGrantId = inserted[0]!.id;
// The team's own owner gains no effective role from it.
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
// changeGrant refuses the row.
expect(
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
).toEqual({
ok: false,
error: 'conflict',
message: 'team grant subjects are suspended',
});
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
await handle.db.delete(teams).where(eq(teams.id, teamId));
});
// ── command conflict semantics ─────────────────────────────────────────────
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
const source = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
);
const destination = expectOk(
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
);
const estateSlug = slug('mv');
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: source.company.id,
name: 'Mv E',
slug: estateSlug,
}),
);
// OWNER owns the source but not the destination → not_found (§6.7-safe).
expect(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
}),
).toEqual({ ok: false, error: 'not_found' });
// Same-parent transfer is refused.
const samePlace = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: source.company.id,
});
expect(samePlace.ok).toBe(false);
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
// Grant OWNER the destination; a slug collision there is refused.
expectOk(
await repo.createGrant({
actorId: SUBJECT,
userId: OWNER,
targetKind: 'company',
targetId: destination.company.id,
role: 'owner',
}),
);
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: destination.company.id,
name: 'Collide',
slug: estateSlug,
}),
);
const collision = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
});
expect(collision.ok).toBe(false);
if (!collision.ok) expect(collision.error).toBe('conflict');
});
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
const from = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
);
const to = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: from.company.id,
name: 'Moved E',
slug: slug('moved'),
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: to.company.id,
idempotencyKey: key,
}),
);
const moved = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(moved.companyId).toBe(to.company.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
// The post-transfer snapshot's parent chain names the destination.
expect(events[0]!.targetSnapshot).toMatchObject({
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
});
});
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
const co = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
);
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
expect(dupSlug.ok).toBe(false);
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: co.company.id,
name: 'Child',
slug: slug('child'),
}),
);
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
expect(withChildren.ok).toBe(false);
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
// Grant to a nonexistent subject is refused (the caller already holds
// owner, so the refusal discloses nothing new).
const ghost = await repo.createGrant({
actorId: OWNER,
userId: `missing-${randomUUID()}`,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(ghost.ok).toBe(false);
if (!ghost.ok) expect(ghost.error).toBe('conflict');
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
}),
);
const duplicate = await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(duplicate.ok).toBe(false);
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
const sameRole = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'viewer',
});
expect(sameRole.ok).toBe(false);
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
// A second grant with another role exists → changing the first onto that
// role would collide with the unique constraint; refused ahead of it.
const second = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'member',
}),
);
const collide = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
});
expect(collide.ok).toBe(false);
if (!collide.ok) expect(collide.error).toBe('conflict');
// A clean change succeeds and records the previous role, namespaced (§4.5).
const changeKey = `key-${randomUUID()}`;
const changed = expectOk(
await repo.changeGrant({
actorId: OWNER,
grantId: second.grant.id,
role: 'owner',
idempotencyKey: changeKey,
}),
);
expect(changed.grant.role).toBe('hierarchy:owner');
const events = await eventsForKey(changeKey);
expect(events).toHaveLength(1);
expect(events[0]!.targetSnapshot).toMatchObject({
role: 'hierarchy:owner',
previousRole: 'hierarchy:member',
});
});
});
@@ -0,0 +1,242 @@
import { Inject, Injectable } from '@nestjs/common';
import {
companies,
eq,
estates,
hierarchyGrants,
inArray,
or,
platformProjects,
workspaces,
and,
type Db,
HIERARCHY_GRANT_ROLES,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
/**
* Hierarchy grant evaluation (contract 2 §3).
*
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
* the effective role on a node is the maximum role over grants targeting the
* node itself or any of its ancestors, maximum per the total order
* viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no
* caching — so revocation (row deletion, §6) denies the next decision
* inherently. A missing node evaluates to null, indistinguishable from
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
*
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
* create them and this evaluator considers user-subject grants only, so a
* team row could not confer access even if one existed.
*
* Read-only module: it selects from the class tables but never writes them,
* so it does not appear on the writer-coverage allowlist.
*/
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
type Tx = Pick<Db, 'select'>;
/** Ancestor chain of a node, self included at its own level; ids only. */
export interface AncestorChain {
readonly companyId: string;
readonly estateId?: string;
readonly platformProjectId?: string;
readonly workspaceId?: string;
}
export function roleStrength(role: HierarchyGrantRole): number {
return HIERARCHY_GRANT_ROLES.indexOf(role);
}
export function roleAtLeast(
role: HierarchyGrantRole | null,
required: HierarchyGrantRole,
): boolean {
return role !== null && roleStrength(role) >= roleStrength(required);
}
/**
* Serialized role strings are namespaced (§4.5): audit events and API
* responses carry `hierarchy:owner`, never a bare `owner`.
*/
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
return `hierarchy:${role}`;
}
/**
* Resolve a node's ancestor chain (self included). Returns null when the
* node does not exist — callers treat that exactly like no-grant (§3.1,
* oracle-safe).
*/
export async function resolveAncestorChain(
tx: Tx,
kind: EvaluableNodeKind,
id: string,
): Promise<AncestorChain | null> {
if (kind === 'company') {
const rows = await tx
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.id } : null;
}
if (kind === 'estate') {
const rows = await tx
.select({ id: estates.id, companyId: estates.companyId })
.from(estates)
.where(eq(estates.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.companyId, estateId: row.id } : null;
}
if (kind === 'platform_project') {
const rows = await tx
.select({
id: platformProjects.id,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(platformProjects)
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(platformProjects.id, id))
.limit(1);
const row = rows[0];
return row
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
: null;
}
const rows = await tx
.select({
id: workspaces.id,
platformProjectId: workspaces.platformProjectId,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(workspaces)
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(workspaces.id, id))
.limit(1);
const row = rows[0];
return row
? {
companyId: row.companyId,
estateId: row.estateId,
platformProjectId: row.platformProjectId,
workspaceId: row.id,
}
: null;
}
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
let best: HierarchyGrantRole | null = null;
for (const candidate of roles) {
// Fail-closed: a value outside the vocabulary confers nothing.
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
const role = candidate as HierarchyGrantRole;
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
}
return best;
}
/**
* Effective role of a user on a node: maximum over the user's grants whose
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
* node → null.
*/
export async function evaluateEffectiveRole(
tx: Tx,
userId: string,
kind: EvaluableNodeKind,
id: string,
): Promise<HierarchyGrantRole | null> {
const chain = await resolveAncestorChain(tx, kind, id);
if (!chain) return null;
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
if (chain.platformProjectId) {
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
}
const rows = await tx
.select({ role: hierarchyGrants.role })
.from(hierarchyGrants)
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
return maxRole(rows.map((r) => r.role));
}
/**
* All companies on which the user holds any effective role, i.e. companies
* with a grant on the company itself or on any descendant (contract 1 §2.8:
* a grant anywhere in the subtree discloses the company's chain upward).
*/
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
const grants = await tx
.select({
companyId: hierarchyGrants.companyId,
estateId: hierarchyGrants.estateId,
platformProjectId: hierarchyGrants.platformProjectId,
})
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, userId));
const companyIds = new Set<string>();
const estateIds = new Set<string>();
const platformProjectIds = new Set<string>();
for (const grant of grants) {
if (grant.companyId) companyIds.add(grant.companyId);
else if (grant.estateId) estateIds.add(grant.estateId);
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
}
if (platformProjectIds.size > 0) {
const rows = await tx
.select({ estateId: platformProjects.estateId })
.from(platformProjects)
.where(inArray(platformProjects.id, [...platformProjectIds]));
for (const row of rows) estateIds.add(row.estateId);
}
if (estateIds.size > 0) {
const rows = await tx
.select({ companyId: estates.companyId })
.from(estates)
.where(inArray(estates.id, [...estateIds]));
for (const row of rows) companyIds.add(row.companyId);
}
return [...companyIds];
}
@Injectable()
export class HierarchyGrantEvaluationService {
constructor(@Inject(DB) private readonly db: Db) {}
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
effectiveRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
tx?: Tx,
): Promise<HierarchyGrantRole | null> {
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
}
async hasRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
required: HierarchyGrantRole,
tx?: Tx,
): Promise<boolean> {
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
}
}
@@ -0,0 +1,297 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy.dto.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
* closed HTTP surface over the hierarchy class tables: the route-inventory
* witness asserts these routes and no others exist. Delete commands take an
* optional body (idempotency key) via POST-style DTOs; every mutation is
* audited on its own transaction by the repository.
*/
@Controller('api/hierarchy')
@UseGuards(AuthGuard)
export class HierarchyController {
constructor(
private readonly repository: HierarchyRepository,
private readonly service: HierarchyService,
) {}
// ── companies ────────────────────────────────────────────────────────────
@Post('companies')
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
return this.service.unwrap(
await this.repository.createCompany({
actorId: user.id,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
/** Companies the caller holds a grant on (directly or via a descendant). */
@Get('companies')
listGrantedCompanies(@CurrentUser() user: { id: string }) {
return this.repository.listGrantedCompanies(user.id);
}
/** Directory-class companies, closed-field (§2.8). */
@Get('companies/directory')
listDirectory() {
return this.repository.listDirectory();
}
@Post('companies/:id/rename')
@HttpCode(200)
async renameCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameCompany({
actorId: user.id,
companyId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('companies/:id/visibility')
@HttpCode(200)
async changeCompanyVisibility(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeCompanyVisibilityDto,
) {
return this.service.unwrap(
await this.repository.changeCompanyVisibility({
actorId: user.id,
companyId: id,
visibility: dto.visibility,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('companies/:id')
async deleteCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteCompany({
actorId: user.id,
companyId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── estates ──────────────────────────────────────────────────────────────
@Post('estates')
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
return this.service.unwrap(
await this.repository.createEstate({
actorId: user.id,
companyId: dto.companyId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/rename')
@HttpCode(200)
async renameEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameEstate({
actorId: user.id,
estateId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/transfer')
@HttpCode(200)
async transferEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferEstateDto,
) {
return this.service.unwrap(
await this.repository.transferEstate({
actorId: user.id,
estateId: id,
destinationCompanyId: dto.destinationCompanyId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('estates/:id')
async deleteEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteEstate({
actorId: user.id,
estateId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── platform projects ────────────────────────────────────────────────────
@Post('platform-projects')
async createPlatformProject(
@CurrentUser() user: { id: string },
@Body() dto: CreatePlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.createPlatformProject({
actorId: user.id,
estateId: dto.estateId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/rename')
@HttpCode(200)
async renamePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renamePlatformProject({
actorId: user.id,
platformProjectId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/transfer')
@HttpCode(200)
async transferPlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferPlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.transferPlatformProject({
actorId: user.id,
platformProjectId: id,
destinationEstateId: dto.destinationEstateId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('platform-projects/:id')
async deletePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deletePlatformProject({
actorId: user.id,
platformProjectId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── grants ───────────────────────────────────────────────────────────────
@Post('grants')
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
return this.service.unwrap(
await this.repository.createGrant({
actorId: user.id,
userId: dto.userId,
targetKind: dto.targetKind,
targetId: dto.targetId,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('grants/:id/change')
@HttpCode(200)
async changeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeGrantDto,
) {
return this.service.unwrap(
await this.repository.changeGrant({
actorId: user.id,
grantId: id,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('grants/:id')
async revokeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.revokeGrant({
actorId: user.id,
grantId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
/**
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
*
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
* payload field absent from these classes is a 400. That closure is itself
* contract surface:
* - CreateCompanyDto declares NO visibility field — creation is always
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
* - CreateGrantDto declares NO teamId field — team grant subjects are
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
* assertion proves the pipe sees the decorators.
*/
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
export class CreateCompanyDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class RenameNodeDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeCompanyVisibilityDto {
@IsIn(COMPANY_VISIBILITY)
visibility!: (typeof COMPANY_VISIBILITY)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class DeleteNodeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateEstateDto {
@IsUUID()
companyId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreatePlatformProjectDto {
@IsUUID()
estateId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferEstateDto {
@IsUUID()
destinationCompanyId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferPlatformProjectDto {
@IsUUID()
destinationEstateId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateGrantDto {
/** Subject user (better-auth text id). No teamId field — see module doc. */
@IsString()
@MinLength(1)
@MaxLength(255)
userId!: string;
@IsIn(['company', 'estate', 'platform_project'])
targetKind!: 'company' | 'estate' | 'platform_project';
@IsUUID()
targetId!: string;
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeGrantDto {
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
+18 -7
View File
@@ -1,17 +1,28 @@
import { Module } from '@nestjs/common';
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyController } from './hierarchy.controller.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* Hierarchy (tenancy/authorization structure) feature module.
*
* M4-1b-i ships the audit event + outbox machinery only (contract 1 §5.2).
* The hierarchy command family — controllers, DTOs, and the allowlisted
* class-table repositories — lands in M4-1b-ii once contract 2 (RBAC grant
* model) merges; until then this module exposes no routes, which the
* route-inventory witness asserts.
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
* M4-1b-ii adds the command family — the closed route surface asserted by
* the route-inventory witness — plus grant evaluation (contract 2 §3).
* HierarchyRepository is the sole class-table writer (writer-coverage
* allowlist); every mutation runs authorize → mutate → audit in one
* transaction.
*/
@Module({
providers: [HierarchyAuditRepository],
exports: [HierarchyAuditRepository],
controllers: [HierarchyController],
providers: [
HierarchyAuditRepository,
HierarchyGrantEvaluationService,
HierarchyRepository,
HierarchyService,
],
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
})
export class HierarchyModule {}
@@ -0,0 +1,935 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
companies,
eq,
estates,
hierarchyGrants,
inArray,
platformProjects,
users,
workspaces,
type Db,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import {
appendHierarchyEvent,
buildNodeSnapshot,
HierarchyAuditIdempotencyConflictError,
} from './hierarchy-audit.repository.js';
import {
evaluateEffectiveRole,
grantedCompanyIds,
namespacedHierarchyRole,
roleAtLeast,
type GrantTargetKind,
type HierarchyGrantRole,
} from './hierarchy-grant-evaluation.js';
/**
* Hierarchy command repository (contract 1 §5, contract 2 §4).
*
* The ONLY writer of the hierarchy class tables (companies, estates,
* platform_projects, hierarchy_grants) — it is the writer-coverage
* allowlist's sole entry. Every command runs one transaction that
* authorizes (live grant evaluation inside the same transaction), mutates,
* and appends the semantic audit event + outbox record via the M4-1b-i
* machinery, so state, event, and outbox commit or roll back together
* (REQ-AUD-001).
*
* Authorization failure and target-not-found both return `not_found`
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
* nothing a stranger would not). `forbidden` appears only where the caller
* already knows the surface exists independent of any node: the admin-only
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
*/
export type HierarchyCommandFailure =
| { readonly ok: false; readonly error: 'not_found' }
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
export interface CompanyView {
readonly id: string;
readonly name: string;
readonly slug: string;
readonly visibility: string;
}
export interface NodeView {
readonly id: string;
readonly name: string;
readonly slug: string;
}
export interface GrantView {
readonly id: string;
readonly userId: string;
readonly targetKind: GrantTargetKind;
readonly targetId: string;
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
readonly role: string;
readonly grantedBy: string;
}
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
export interface DirectoryEntry {
readonly id: string;
readonly name: string;
readonly slug: string;
}
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
type GrantRow = typeof hierarchyGrants.$inferSelect;
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
function conflict(message: string): HierarchyCommandFailure {
return { ok: false, error: 'conflict', message };
}
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
if (row.companyId) return { kind: 'company', id: row.companyId };
if (row.estateId) return { kind: 'estate', id: row.estateId };
return { kind: 'platform_project', id: row.platformProjectId as string };
}
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
function grantSnapshot(row: GrantRow): Record<string, unknown> {
const target = grantTarget(row);
return {
id: row.id,
subject: { userId: row.userId },
target: { kind: target.kind, id: target.id },
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function grantView(row: GrantRow): GrantView {
const target = grantTarget(row);
return {
id: row.id,
userId: row.userId as string,
targetKind: target.kind,
targetId: target.id,
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function companyView(row: typeof companies.$inferSelect): CompanyView {
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
}
interface CommandContext {
readonly actorId: string;
readonly idempotencyKey: string;
readonly correlationId: string;
}
@Injectable()
export class HierarchyRepository {
constructor(@Inject(DB) private readonly db: Db) {}
private async run<T>(
idempotencyKey: string | undefined,
actorId: string,
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
): Promise<HierarchyResult<T>> {
const ctx: CommandContext = {
actorId,
idempotencyKey: idempotencyKey ?? randomUUID(),
correlationId: randomUUID(),
};
try {
return await this.db.transaction(async (tx) => body(tx, ctx));
} catch (error) {
// A key replayed with different content aborts the whole command —
// the transaction (state change included) has rolled back (§6.4).
if (error instanceof HierarchyAuditIdempotencyConflictError) {
return conflict(error.message);
}
throw error;
}
}
private async requireOwner(
tx: Tx,
actorId: string,
kind: GrantTargetKind,
id: string,
): Promise<boolean> {
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
}
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
const rows = await tx
.select({ role: users.role })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
return rows[0]?.role === 'admin';
}
// ── companies ────────────────────────────────────────────────────────────
/**
* Any authenticated user may create a company; the same audited operation
* writes the creator's initial owner grant (§4.3), causation-linked to the
* create event. Visibility is always 'private' — the command takes no
* visibility input (§5.5).
*/
createCompany(input: {
actorId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const inserted = await tx
.insert(companies)
.values({ name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const company = inserted[0];
if (!company) return conflict('company slug already exists');
const grantRows = await tx
.insert(hierarchyGrants)
.values({
userId: ctx.actorId,
companyId: company.id,
role: 'owner',
grantedBy: ctx.actorId,
})
.returning();
const grant = grantRows[0] as GrantRow;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
const created = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: created.event.id,
idempotencyKey: `${ctx.idempotencyKey}:grant`,
});
return { ok: true, company: companyView(company), grant: grantView(grant) };
});
}
renameCompany(input: {
actorId: string;
companyId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ name: input.name, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
/**
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
* hierarchy mutation a platform admin performs without a grant. A
* non-admin caller (owner included) gets `forbidden` before any company
* read: the refusal reveals nothing about the target's existence.
*/
changeCompanyVisibility(input: {
actorId: string;
companyId: string;
visibility: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
return {
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
};
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'visibility_change',
targetKind: 'company',
targetId: company.id,
// Old and new values are event content (§5.5).
targetSnapshot: {
...snapshot,
previousVisibility: previous.visibility,
visibility: company.visibility,
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
deleteCompany(input: {
actorId: string;
companyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: estates.id })
.from(estates)
.where(eq(estates.companyId, input.companyId))
.limit(1);
if (children.length > 0) return conflict('company still has estates');
// Snapshot and grants are read before the delete; target FKs cascade
// the grant rows, and each cascaded deletion is audited (§5.2).
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.companyId, input.companyId));
await tx.delete(companies).where(eq(companies.id, input.companyId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'company',
targetId: input.companyId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.companyId };
});
}
// ── estates ──────────────────────────────────────────────────────────────
/** Child creation requires owner on the parent and confers no grant (§4.3). */
createEstate(input: {
actorId: string;
companyId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(estates)
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const estate = inserted[0];
if (!estate) return conflict('estate slug already exists in company');
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
renameEstate(input: {
actorId: string;
estateId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(estates)
.set({ name: input.name })
.where(eq(estates.id, input.estateId))
.returning();
const estate = updated[0] as typeof estates.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
transferEstate(input: {
actorId: string;
estateId: string;
destinationCompanyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const estate = rows[0];
if (!estate) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
return NOT_FOUND;
}
if (estate.companyId === input.destinationCompanyId) {
return conflict('estate already belongs to the destination company');
}
const collision = await tx
.select({ id: estates.id })
.from(estates)
.where(
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
)
.limit(1);
if (collision.length > 0) return conflict('destination company already has that estate slug');
const parents = await tx
.select({ id: companies.id, slug: companies.slug })
.from(companies)
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
const source = parents.find((p) => p.id === estate.companyId);
const destination = parents.find((p) => p.id === input.destinationCompanyId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(estates)
.set({ companyId: input.destinationCompanyId })
.where(eq(estates.id, input.estateId));
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
deleteEstate(input: {
actorId: string;
estateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(eq(platformProjects.estateId, input.estateId))
.limit(1);
if (children.length > 0) return conflict('estate still has platform projects');
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.estateId, input.estateId));
await tx.delete(estates).where(eq(estates.id, input.estateId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.estateId };
});
}
// ── platform projects ────────────────────────────────────────────────────
createPlatformProject(input: {
actorId: string;
estateId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(platformProjects)
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const project = inserted[0];
if (!project) return conflict('platform project slug already exists in estate');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
renamePlatformProject(input: {
actorId: string;
platformProjectId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(platformProjects)
.set({ name: input.name })
.where(eq(platformProjects.id, input.platformProjectId))
.returning();
const project = updated[0] as typeof platformProjects.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
transferPlatformProject(input: {
actorId: string;
platformProjectId: string;
destinationEstateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const project = rows[0];
if (!project) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
return NOT_FOUND;
}
if (project.estateId === input.destinationEstateId) {
return conflict('platform project already belongs to the destination estate');
}
const collision = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(
and(
eq(platformProjects.estateId, input.destinationEstateId),
eq(platformProjects.slug, project.slug),
),
)
.limit(1);
if (collision.length > 0) {
return conflict('destination estate already has that platform project slug');
}
const parents = await tx
.select({ id: estates.id, slug: estates.slug })
.from(estates)
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
const source = parents.find((p) => p.id === project.estateId);
const destination = parents.find((p) => p.id === input.destinationEstateId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(platformProjects)
.set({ estateId: input.destinationEstateId })
.where(eq(platformProjects.id, input.platformProjectId));
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
deletePlatformProject(input: {
actorId: string;
platformProjectId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const children = await tx
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.platformProjectId, input.platformProjectId))
.limit(1);
if (children.length > 0) return conflict('platform project still has workspaces');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.platformProjectId };
});
}
// ── grants ───────────────────────────────────────────────────────────────
/** Grant management requires effective owner on the target (§4.1). */
createGrant(input: {
actorId: string;
userId: string;
targetKind: GrantTargetKind;
targetId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
return NOT_FOUND;
}
const subject = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
if (subject.length === 0) return conflict('subject user does not exist');
const inserted = await tx
.insert(hierarchyGrants)
.values({
userId: input.userId,
companyId: input.targetKind === 'company' ? input.targetId : null,
estateId: input.targetKind === 'estate' ? input.targetId : null,
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
role: input.role,
grantedBy: ctx.actorId,
})
.onConflictDoNothing()
.returning();
const grant = inserted[0];
if (!grant) return conflict('grant already exists');
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
changeGrant(input: {
actorId: string;
grantId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
// Team subjects are suspended (§1.4); the command surface never
// creates them, so this only fires on out-of-band rows.
if (!existing.userId) return conflict('team grant subjects are suspended');
if (existing.role === input.role) return conflict('grant already holds that role');
const duplicate = await tx
.select({ id: hierarchyGrants.id })
.from(hierarchyGrants)
.where(
and(
eq(hierarchyGrants.userId, existing.userId),
target.kind === 'company'
? eq(hierarchyGrants.companyId, target.id)
: target.kind === 'estate'
? eq(hierarchyGrants.estateId, target.id)
: eq(hierarchyGrants.platformProjectId, target.id),
eq(hierarchyGrants.role, input.role),
),
)
.limit(1);
if (duplicate.length > 0) {
return conflict('subject already holds that role on the target');
}
const updated = await tx
.update(hierarchyGrants)
.set({ role: input.role })
.where(eq(hierarchyGrants.id, input.grantId))
.returning();
const grant = updated[0] as GrantRow;
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_change',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: {
...grantSnapshot(grant),
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
revokeGrant(input: {
actorId: string;
grantId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ revokedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: existing.id,
targetSnapshot: grantSnapshot(existing),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, revokedId: existing.id };
});
}
// ── reads ────────────────────────────────────────────────────────────────
/**
* The directory: every directory-class company, closed-field (§2.8). The
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
*/
async listDirectory(): Promise<DirectoryEntry[]> {
return this.db
.select({ id: companies.id, name: companies.name, slug: companies.slug })
.from(companies)
.where(eq(companies.visibility, 'directory'))
.orderBy(asc(companies.name));
}
/** Companies the user holds any grant on (company or descendant, §2.8). */
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
const ids = await grantedCompanyIds(this.db, userId);
if (ids.length === 0) return [];
const rows = await this.db
.select()
.from(companies)
.where(inArray(companies.id, ids))
.orderBy(asc(companies.name));
return rows.map(companyView);
}
}
@@ -0,0 +1,31 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
/**
* Maps repository result unions onto HTTP exceptions. `not_found` carries
* one fixed message for every cause — missing node and unauthorized caller
* are indistinguishable on the wire (contract 1 §6.7).
*/
@Injectable()
export class HierarchyService {
unwrap<T>(result: HierarchyResult<T>): T {
if (result.ok) return result;
throw this.toException(result);
}
private toException(failure: HierarchyCommandFailure): Error {
switch (failure.error) {
case 'not_found':
return new NotFoundException('hierarchy node not found');
case 'forbidden':
return new ForbiddenException(failure.message);
case 'conflict':
return new ConflictException(failure.message);
}
}
}
+89 -24
View File
@@ -176,7 +176,18 @@ describe('MCP actor identity and tool scope enforcement', () => {
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(true);
).toBe(false);
});
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
const scopes = deriveMcpToolScopesForUser({ role });
expect([...scopes].sort()).toEqual([...memberScopes].sort());
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
}
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
@@ -311,14 +322,20 @@ describe('MCP actor identity and tool scope enforcement', () => {
]);
});
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
// ownership sees exactly what an unprivileged member with the same
// ownership would see — here, only the one project they own, and nothing
// tenant-wide or platform-wide.
const fixtures = {
projects: [
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant',
name: 'same tenant, unowned',
},
{
id: 'project-tenant-b',
@@ -328,39 +345,50 @@ describe('MCP actor identity and tool scope enforcement', () => {
},
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
};
service.registerTools(server, actor);
const actors = [
makeAdminActor('role-bearing-user', 'tenant-a'),
makePlatformAdminActor('role-bearing-user'),
];
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
for (const actor of actors) {
const { service } = makeService(fixtures);
const { server, tools } = makeCapturingServer();
service.registerTools(server, actor);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
}
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
// §1.1 retirement: content visibility comes from ownership, not the
// tenant-admin role — the acting user owns the tenant-a project.
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
@@ -373,7 +401,29 @@ describe('MCP actor identity and tool scope enforcement', () => {
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
// Platform role no longer derives task-write scopes (§1.1 retirement):
// a role-derived admin actor is scope-denied before any tenant logic.
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, roleDerivedAdmin);
await expect(
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
).rejects.toThrow('MCP tool scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
// The tenant-scoping checks below sit behind the scope gate; exercise
// them with explicitly granted task-write scopes (how grant-mapped
// scopes will arrive), not with a platform role.
tools.clear();
const actor = createMcpActorContext({
userId: 'tenant-admin-user',
tenantId: 'tenant-a',
role: 'member',
scopes: [
...deriveMcpToolScopesForUser({ role: 'member' }),
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
],
});
service.registerTools(server, actor);
@@ -416,7 +466,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
);
});
it('keeps admin-only coordination tools on server-derived paths', async () => {
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
@@ -433,10 +483,25 @@ describe('MCP actor identity and tool scope enforcement', () => {
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
// §1.1 retirement: platform-admin no longer derives coord scopes either.
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
await expect(platformAdminTool.handler({})).rejects.toThrow(
'MCP tool scope denied: coord:read',
);
// An explicitly granted coord:read scope reaches the server-derived
// path (caller-supplied projectPath is stripped by the schema).
tools.clear();
const grantedActor = createMcpActorContext({
userId: 'granted-user',
role: 'member',
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
});
service.registerTools(server, grantedActor);
const grantedTool = getTool(tools, 'coord_list_tasks');
await grantedTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
+19 -57
View File
@@ -63,20 +63,6 @@ interface SessionEntry {
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
@@ -89,15 +75,17 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.memory_save_insight,
]);
export function deriveMcpToolScopesForUser(input: {
/**
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
* former tenant-admin/global-admin scope sets keyed on users.role are
* retired. Every authenticated user receives the base member set; task
* writes and coordination scopes attach to explicit hierarchy grants when
* the MCP grant mapping lands, never to a platform role. The role
* parameter is kept for caller compatibility and deliberately ignored.
*/
export function deriveMcpToolScopesForUser(_input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
@@ -168,41 +156,22 @@ type TaskLike = TenantScopedLike & {
userId?: string | null;
};
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
/**
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
* global-admin/tenant-admin filter short-circuits keyed on the platform role
* are retired along with the role-derived scope sets. Content reaches an MCP
* actor through ownership only; widened access arrives as explicit hierarchy
* grants when the MCP grant mapping lands.
*/
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
return projects.filter((project) => project.ownerId === actor.userId);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
return missions.filter((mission) => mission.userId === actor.userId);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
@@ -293,7 +262,6 @@ export class McpService implements OnModuleDestroy {
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
@@ -302,8 +270,6 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
@@ -317,7 +283,6 @@ export class McpService implements OnModuleDestroy {
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
@@ -339,7 +304,7 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
if (!refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
@@ -349,8 +314,6 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
@@ -367,7 +330,6 @@ export class McpService implements OnModuleDestroy {
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);
@@ -108,11 +108,13 @@ export class MissionsController {
) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
// dto.status is deliberately not forwarded: mission_tasks.status is
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
// the repo strips it as well.
return this.brain.missionTasks.create({
missionId,
taskId: dto.taskId,
userId: user.id,
status: dto.status,
description: dto.description,
notes: dto.notes,
pr: dto.pr,
+12
View File
@@ -77,6 +77,12 @@ export class CreateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@@ -102,6 +108,12 @@ export class UpdateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
+92
View File
@@ -1,6 +1,23 @@
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy/hierarchy.dto.js';
import {
EnrollAgentDto,
EnrollCredentialDto,
GetEnrollmentQueryDto,
} from './enrollment/enrollment.dto.js';
/**
* Boot-time self-check: the global ValidationPipe must be able to SEE the
@@ -43,6 +60,81 @@ export const PIPE_GUARDED_DTOS: Array<{
target: BootstrapSetupDto,
properties: ['name', 'email', 'password'],
},
{
name: 'CreateCompanyDto',
target: CreateCompanyDto,
properties: ['name', 'slug', 'idempotencyKey'],
},
{
name: 'RenameNodeDto',
target: RenameNodeDto,
properties: ['name', 'idempotencyKey'],
},
{
name: 'ChangeCompanyVisibilityDto',
target: ChangeCompanyVisibilityDto,
properties: ['visibility', 'idempotencyKey'],
},
{
name: 'DeleteNodeDto',
target: DeleteNodeDto,
properties: ['idempotencyKey'],
},
{
name: 'CreateEstateDto',
target: CreateEstateDto,
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'CreatePlatformProjectDto',
target: CreatePlatformProjectDto,
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'TransferEstateDto',
target: TransferEstateDto,
properties: ['destinationCompanyId', 'idempotencyKey'],
},
{
name: 'TransferPlatformProjectDto',
target: TransferPlatformProjectDto,
properties: ['destinationEstateId', 'idempotencyKey'],
},
{
name: 'CreateGrantDto',
target: CreateGrantDto,
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
},
{
name: 'ChangeGrantDto',
target: ChangeGrantDto,
properties: ['role', 'idempotencyKey'],
},
{
name: 'EnrollAgentDto',
target: EnrollAgentDto,
properties: [
'harness',
'name',
'persona',
'model',
'provider',
'credential',
'idempotencyKey',
'correlationId',
'replayMode',
],
},
{
name: 'EnrollCredentialDto',
target: EnrollCredentialDto,
properties: ['mode', 'type', 'value'],
},
{
name: 'GetEnrollmentQueryDto',
target: GetEnrollmentQueryDto,
properties: ['correlationId'],
},
];
export class PipeMetatypeCheckError extends Error {
+37
View File
@@ -0,0 +1,37 @@
# Explicit, single-seat dogfood mode for stack-containerization B2.
# Use with docker-compose.yml. The base stack remains credential-free.
services:
gateway:
# The R4 credential helper establishes ownership from process ancestry and
# intentionally does not trust PID 1. Keep gateway Node below Docker's init.
init: true
environment:
# Identity and credential layout match a fleet seat. This fixed name prevents
# an operator from mounting one seat while attributing actions to another.
MOSAIC_AGENT_NAME: code-dogfood-01
MOSAIC_GIT_IDENTITY: code-dogfood-01
MOSAIC_BRAIN_HOME: /opt/mosaic/brain
AGENT_FILE_SANDBOX_DIR: /workspace/stack
# Disable the general shell before admin/user allowlist resolution. Delivery
# uses execFile-only tools bound to the queue and PR wrappers below.
AGENT_SHELL_ENABLED: 'false'
AGENT_DELIVERY_ENABLED: 'true'
MOSAIC_GIT_TOOLS_DIR: /opt/mosaic/tools/git
MOSAIC_INTEGRATION_TRUNK: next
AGENT_USER_TOOLS: fs_read_file,fs_write_file,fs_list_directory,fs_edit_file,git_status,git_log,git_diff,git_publish_branch,git_open_pull_request
volumes:
# Mount a dedicated worktree, never the canonical clone or divergent local main.
- type: bind
source: ${MOSAIC_DOGFOOD_WORKTREE:?set to a dedicated next-based stack worktree}
target: /workspace/stack
# A Git worktree's .git file points into the canonical clone's common Git
# directory. Mount that directory at its original absolute path so Git can
# resolve the pointer. File tools cannot traverse outside /workspace/stack.
- type: bind
source: ${MOSAIC_DOGFOOD_COMMON_GIT_DIR:?set to the canonical stack clone .git directory}
target: ${MOSAIC_DOGFOOD_COMMON_GIT_DIR:?set to the canonical stack clone .git directory}
# Only this seat home enters the container. Other fleet credentials stay outside.
- type: bind
source: ${MOSAIC_DOGFOOD_SEAT_HOME:?set to the external code-dogfood-01 seat directory}
target: /opt/mosaic/brain/fleet/agents/code-dogfood-01
read_only: true
+38
View File
@@ -47,6 +47,44 @@ services:
environment:
COLLECTOR_OTLP_ENABLED: 'true'
gateway:
# Standalone-tier application service (compose `stack` profile).
# Default image = local build of docker/gateway.Dockerfile (works with
# no registry auth); override GATEWAY_IMAGE to a CI-published sha tag
# for registry deploys (git.mosaicstack.dev/mosaicstack/stack/gateway:sha-XXXXXXX).
profiles: [stack]
image: ${GATEWAY_IMAGE:-mosaic-gateway:dev}
build:
context: .
dockerfile: docker/gateway.Dockerfile
ports:
- '${GATEWAY_HOST_PORT:-14242}:14242'
environment:
GATEWAY_PORT: '14242'
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
VALKEY_URL: valkey://valkey:6379
# The compose IS the standalone tier by declaration (mode contract:
# mode chosen at install); pinning skips cross-container probe races.
MOSAIC_STORAGE_TIER: standalone
# Standalone-tier secrets: generated at install (see .env.example).
# Enterprise tier replaces these with Vault/Openbao plumbing.
BETTER_AUTH_SECRET: '${BETTER_AUTH_SECRET:?set in .env — openssl rand -hex 32}'
volumes:
- gateway_workspaces:/opt/mosaic/.workspaces
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:14242/health || exit 1']
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
gateway_workspaces:
pg_data:
valkey_data:
+20 -2
View File
@@ -29,11 +29,29 @@ ENV NODE_ENV=production
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
# git was declined so routine base-image security updates remain maintainable.
RUN apk add --no-cache git \
# packages was declined so routine base-image security updates remain maintainable.
# bash/curl/python3 are runtime dependencies of the provider-neutral Mosaic git
# wrappers. jq supports wrapper discovery for non-canonical Gitea hosts.
RUN apk add --no-cache bash curl git jq python3 \
&& mkdir -p /opt/mosaic/.workspaces \
&& chown -R node:node /opt/mosaic /app
ENV MOSAIC_ROOT=/opt/mosaic
# Dogfood agents use the same fail-closed credential helper, queue guard, and
# PR-create wrapper as fleet seats. Copy only those operations and their shared
# dependencies. Merge and infrastructure tools stay out of the image.
COPY --from=builder /app/packages/mosaic/framework/tools/git/pr-create.sh /opt/mosaic/tools/git/pr-create.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/ci-queue-wait.sh /opt/mosaic/tools/git/ci-queue-wait.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/detect-platform.sh /opt/mosaic/tools/git/detect-platform.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/repo-decl.sh /opt/mosaic/tools/git/repo-decl.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic /opt/mosaic/tools/git/git-credential-mosaic
# R4 hardening (P0-SEC, brain 15f6979a): the credential helper is a pair.
# python entrypoint (allowlist envp, execve boundary) + the bash implementation
# it execs. The entrypoint derives the .impl path from its own directory, so the
# pair sits side by side; system gitconfig keeps pointing at the entrypoint.
COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic.impl /opt/mosaic/tools/git/git-credential-mosaic.impl
COPY --from=builder /app/packages/mosaic/framework/tools/_lib/credentials.sh /opt/mosaic/tools/_lib/credentials.sh
COPY --from=builder /app/packages/mosaic/framework/tools/structure/validate-repo-json.sh /opt/mosaic/tools/structure/validate-repo-json.sh
RUN git config --system credential.helper /opt/mosaic/tools/git/git-credential-mosaic
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
+34 -17
View File
@@ -197,24 +197,41 @@ conflict must amend one of them explicitly, never fork a third document
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
### 12. Decision registry
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
| ID | Decision (short form) |
| --- | ------------------------------------------------------------------------------------------------------------------ |
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
| D8 | webUI sits over official framework tooling; CLI primary |
| D9 | Not a hosted business; company = organizational separation for one operator |
| D10 | better-auth is the account system of record; external IdPs via OIDC |
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
The stack ships a tiered deployment target, additive to the architecture
gate (D8): (1) Standalone tier — docker compose is the canonical
single-host deployment: postgres, valkey, openbao, gateway, appservice
and the served webUI in one composition, with migrations, health checks,
and a documented install/upgrade path; the registry (CI-published
images) is the only deployment source. (2) Enterprise tier — Kubernetes
manifests for the same service set, phase-gated on the standalone tier
holding its acceptance bar. The v1 acceptance bar for the standalone
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
open a PR to this repo; CI validates it; the running deployment adopts
the merged change (pull + restart). Federation (D3 clause) remains
deferred and unforeclosed. Implementation plan:
docs/plans/2026-08-30_containerization.md.
## 12. Decision registry
| ID | Decision (short form) |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
| D8 | webUI sits over official framework tooling; CLI primary |
| D9 | Not a hosted business; company = organizational separation for one operator |
| D10 | better-auth is the account system of record; external IdPs via OIDC |
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
| D15 | Tiered containerized deployment: compose standalone tier (five-point v1 bar) + phase-gated k8s enterprise tier; registry-only image source | 2026-08-30 containerization lane; plan docs/plans/2026-08-30_containerization.md |
The full decision texts are recorded in the operator decision log (USC estate
brain, webui-audit lane, `GRILL.md`).
+1
View File
@@ -14,6 +14,7 @@
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
@@ -0,0 +1,117 @@
---
kind: verification
status: active
---
# P0 Field-Map Currency Verification — 2026-08-29
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
integration trunk `next`. This document re-verifies the map's currency at the
lane-opening head and records the measured pre-expand writer inventory. It amends
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
wins.
## 1. Currency verification (measured)
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
2026-08-29).
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
**300 insertions, 0 deletions** — no existing declaration changed.
- The additions: the new declarations `logicalAgentConnectorLeases`,
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
column on the unmapped BetterAuth `accounts` table (shipped as
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
source.
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
therefore current at `origin/main` itself, and at the trunk head beyond it.
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
`agents`, fleet `backlog` — is byte-identical to the declaration the map
inspected. The field map is current as written. No row changes.
## 2. Refinements available since the pin (context, not map changes)
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
layer; its "bootstrap workspace" backfill step now has a shipped target:
`workspaces` (uuid PK, chained under platform projects per
`docs/requirements/hierarchy-schema.md`; hierarchy core in
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
`0019_volatile_killraven.sql`, visibility in
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
task lane's `task_events`/`task_outbox` mirror the pattern but are
workspace-scoped with the composite `(workspace_id, id)` key required by
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
shared store for task events.
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
the trunk head pinned above, so no reinterpretation of the acceptance text
is needed.
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
ownership to the mission seat `coder2`. Seat identity is operational fleet
state, not resolvable from this repository, and is outside this document's
scope. The invariant §6 protects binds regardless of seat and is restated
here as binding on the M4-3 schema lane: exactly one lane generates
migrations at a time; expand is additive; no drop/rename/narrow; constraints
validate before NOT NULL.
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
`mission_tasks.status` as a write source, plus a writer inventory, before any
expand DDL.
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
The same module also exposes `remove`/`removeByMission` DELETE paths —
immaterial to `status` writes, listed for inventory completeness.
- **Storage-layer surfaces that touch the column without authoring it**
(added 2026-08-29 after independent review of the phase-1 patch):
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
between storage tiers and must preserve the stored `status` verbatim — row
transport, exempt from the write prohibition (stripping there would corrupt
data inside the N-1 window). The generic table-keyed storage adapters
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
their table maps but have no caller that targets it: measured at this head,
every runtime adapter caller passes a fixed collection constant
(preferences/insights). Neither surface authors a new `status` value.
- **Read-only consumers of `mission_tasks`:** federation verb services
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
`brain_update_task` write the separately mapped `tasks` table, a legitimate
N-1 writer through the compatibility window.
- The ratified contract 5 decision
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
the legacy endpoints — including MCP `brain_*` task mutations — for new
consumers, while existing consumers keep working until each surface's owning
contract retires it. It does not stop existing writes.
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
on `mission_tasks` create/update) MUST land before the expand DDL of migration
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
status is ignored; the column stays declared and readable through the whole
N-1 window; retirement only after no readers).
## 4. Lane opening
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
map (§5.4), and required migration tests (§5.5) bind as written. External
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
@@ -0,0 +1,315 @@
---
kind: spec
status: active
audience: developer
---
# Agent Enrollment Command Family — v1 Design (M4-4-0)
Status: design note (implementation-facing; amends no contract).
Authority chain: tool-gateway-mapping.md §3.1 rank-4 row + §4 envelope
(ruled 2026-08-27), onboarding-wizard.md §3.5 (D11 minimal enrollment),
custody-schema.md §5.2 at revision 13 (agent-grantee FK bound to the
live `agents` table — a binding introduced at rev 4 and standing
verbatim), PRD §9 D11. Where this note and a ratified contract disagree,
the contract wins.
## 1. What the contracts bind (and what they leave open)
There is no standalone enrollment contract. The rank-4 family is defined
by composition:
1. **Contract 5 §3.1 rank 4:** "Enroll one agent: harness, credential
reference/API-key intake (values never echoed), name/persona,
assignment scope (contract 3 §3.5)."
2. **Contract 5 §4 — all five sub-clauses:** §4.1 typed request/result
DTOs validated at the Gateway boundary (expected-version only where
an owning contract defines one); §4.2 closed per-family error enum
(validation, authentication, authorization, not-found, conflict,
precondition, internal) with HTTP mappings; §4.3 audit linkage — the
envelope contributes correlation: every request accepts/generates a
correlation id, carried into the audit events **and returned in the
result**, with no second audit stream; §4.4 fail-closed — an
operation that cannot evaluate its authorization or reach its owning
tool refuses, never degrading to a fallback read or direct data
access; §4.5 CLI parity — the family MUST be invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts (a Gateway command without CLI
exposure is a tracked conformance gap).
**Idempotency keys are NOT contract 5 §4.3:** the idempotency-key
envelope is contract 3 §4.3, ratified as a drafting addition to
contract 5 §4's command envelope via contract 3 §7 item 4. Its fence
and replay rules bind as written there; §3.1 rule 5 below designs to
them.
3. **Contract 3 §3.5:** the wizard's enrollment step is minimal (one
harness, API-key login, agent name and persona — D11), uses ONLY this
family, and is skippable. Wizard witness §6.10: a run that skips the
step produces zero enrollment-family mutations.
4. **Custody-schema §5.2 (rev 13; binding introduced at rev 4):**
contract 7's agent-grantee FK references the live `agents` table
(`agents.id`, uuid); an enrollment surface with its own table would
force a contract-7 amendment.
**Assignment scope (open point, pinned here):** the rank-4 row cites
contract 3 §3.5, which defines no assignment semantics; the PRD's full
enrollment vision (Part I, Standalone flow) includes "account
assignment", but the D11 v1 slice is exactly "one harness, API key,
name/persona". v1 therefore scopes assignment to the two bindings the
minimal slice already implies — the enrolling user becomes the agent's
owner (`agents.owner_id`), and the credential reference names which of
that user's stored provider credentials the agent uses. Richer
assignment (multi-account, comms auto-enroll, workspace placement) is
deferred with the rest of the PRD's full flow (D11); when a contract
defines it, this family extends by ordinary amendment of the design.
The deferral rests on contract 3 §3.5's explicit delegation of
enrollment specifics to this family — not on reading the D11 list as
exhaustive (it is not: the §3.1 `model`/`provider` fields are required
by the live table's NOT NULL columns, though D11 does not name them).
## 2. Current state (measured 2026-08-29 at `origin/next` = `94d626df`)
- `agents` table (packages/db `schema.ts`): id uuid PK, name, provider,
model, status enum, project_id (legacy `projects`, ON DELETE SET
NULL), owner_id → users, system_prompt, allowed_tools, skills,
is_system, config jsonb, timestamps. No harness column (provider and
model describe the LLM backend, not the harness), no audit coupling.
- Sole write path: `packages/brain/src/agents.ts` repository (the only
module issuing `insert(agents)`), with three write consumers: the
legacy `/api/agents` CRUD controller
(`apps/gateway/src/agent/agent-configs.controller.ts`), the `/agent
new` chat command (`apps/gateway/src/commands/command-executor.service.ts`
→ `brain.agents.create`), and workspace bootstrap
(`apps/gateway/src/workspace/project-bootstrap.service.ts`). All
three keep serving existing consumers; none is touched by M4-4.
- Sealed credential store exists: `ProviderCredentialsService`
(apps/gateway/src/agent/) — one row per (userId, provider), values
sealed at rest, decrypt server-side only, summaries never carry
values.
- Harness registry exists (`apps/gateway/src/harness/`), the validation
source for the harness field.
- Implementation pattern: the merged hierarchy module (M4-1) —
transaction-scoped command context, in-tx authorization, discriminated
result unions, same-transaction semantic audit event + transactional
outbox, no-oracle not_found folding.
**F1 — contract-5 mapping note (disposition, not an amendment):**
`/api/agents` appears nowhere in contract 5 — neither as a P0 row nor in
the §3.2 legacy non-substitutes list (the ruled §3.2 freeze names
specific endpoints, and `/api/agents` is not among them). The operative
constraints are §3.3's amendment-only rule for new mapping rows and §5's
closure rule: this design adds no new consumer to `/api/agents` and
builds the rank-4 family as the P1 path for enrollment. Adding the
missing P0 row is a contract amendment for a future S2 pass; nothing in
M4-4 depends on it.
## 3. Command family surface (v1)
One command, one query. Module: `apps/gateway/src/enrollment/`
(`enrollment.module.ts`), mirroring the hierarchy module's shape.
### 3.1 `agent.enroll` (mutation)
Request DTO (shared types package, class-validator at the boundary):
| Field | Type | Rule |
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness` | string | syntactically invalid (empty/malformed) → `validation_failed`; well-formed but not in the harness registry → `precondition_failed` |
| `correlationId` | string (uuid) | optional; generated when absent (contract 5 §4.3); carried into audit events and returned in the result |
| `replayMode` | 'actor-bound' | optional, default `actor-bound`. `shared` is seed-only (contract 3 §4.3 binds it to the §3.4 canonical seed key set and "no other operation can carry a shared declaration"; §7 item 4 closes it); a `shared` declaration here is refused `validation_failed`, executes nothing, and records no fence row |
| `name` | string | non-empty, trimmed, ≤ 200 chars |
| `persona` | string \| null | optional; stored as the agent's system prompt |
| `model` | string | non-empty (provider-qualified model id) |
| `provider` | string | non-empty; names the credential's provider |
| `credential` | discriminated union | `{ mode: 'reference' }` — a credential for (actor, provider) MUST already exist; `{ mode: 'intake', type: 'api_key', value: string }` — value is sealed into the credential store in the same flow |
| `idempotencyKey` | string (uuid) | required (contract 3 §4.3, ratified into contract 5 §4 via contract 3 §7 item 4) |
Rules:
1. **Never echoed.** The credential value appears in no result DTO, no
audit event, no outbox payload, and no log line. The result carries
only `{ provider, credentialMode }`.
2. **Intake = the existing sealed store, inside the transaction.**
`intake` writes through the sealed-store path
(`ProviderCredentialsService.store` semantics: seal-at-rest, upsert
per (userId, provider)) **in the same transaction** as the agent
insert — a failure after the credential write rolls everything back,
leaving no orphan credential. Enrollment persists no second copy and
no plaintext.
3. **Reference must resolve.** `reference` with no stored credential for
(actor, provider) refuses with `precondition_failed` (nothing is
created).
4. **Ownership.** `owner_id` = the authenticated actor. v1 authorization
is AuthGuard-authenticated user; no hierarchy grant is required
because v1 enrollment binds no hierarchy node (§1 assignment-scope
pin). `is_system` is never settable through this command.
5. **Idempotency fence (contract 3 §4.3, in full).** The command layer
records, in a uniqueness-constrained fence table in the same
transaction as the mutation and its audit event: the key, the
operation identifier (`agent.enroll`), the acting principal, the
authorization scope, a digest of the canonicalized request payload
(the digest input EXCLUDES the credential value — it covers
provider + credentialMode, never plaintext), the declared replay
mode (always `actor-bound` for this family — the `shared` refusal
in the table above means no shared fence row can exist here; the
column is kept for envelope-shape fidelity and mode-mismatch
collision checks), and a reference to the committed outcome (the
agent id). The recorded **authorization scope** for this family is
pinned to the acting principal's platform-user scope (v1
authorization is grant-free per rule 4, so the scope is the
authenticated-user identity domain — recorded so the §4.3
scope-equality check has a defined value). Fence uniqueness is the
pair (operation identifier, key). **Replay:** a submission whose
(operation, key) is recorded is first authorized exactly as a fresh
submission; then replay-mode, scope, and digest equality are
checked (a mismatch on any — including scope — is a collision);
then **target-result authorization** — the submitter must hold, at
replay time, read authority on the referenced agent row under
§3.2's rule (owner or admin) — plus recorded-actor equality
(`actor-bound`). A passing replay executes nothing, returns the
recorded outcome, and appends a replay access event (non-mutation
audit class: accessing principal, current correlation id,
fence-row reference). Any equality or authorization failure refuses
with the single bounded `conflict` shape — constant, identifying no
record — preserving the no-existence-oracle rule. **Concurrency
(contract 3 §4.3's rule, ratified via §7 item 4):** two submissions
with the same (operation, key) serialize on the fence's unique
constraint — exactly one executes; the loser waits for the winner's
transaction, and is then handled as a replay if it committed
(through the full replay path above) or executes afresh if it
aborted. A unique-violation race never surfaces as an unhandled
internal fault.
6. **Audit + outbox, same transaction.** Insert into `agents` +
sealed credential write (intake mode) + fence row + semantic audit
event (`agent.enrolled`: actor, agent id, harness, provider, name,
credentialMode — no credential material) + outbox row commit
atomically, hierarchy-pattern style. Audit rows reference the agent
by **snapshot id, not FK** — mirroring the hierarchy audit tables'
deliberate FK-free linkage so audit history survives agent deletion
through the legacy CRUD DELETE path.
Result union: `enrolled { agent, correlationId }` | refusal from the
§3.3 enum (refusals also carry the correlation id, per contract 5
§4.3's end-to-end traceability). `agent` in the result is the persisted
row minus nothing sensitive (the table stores no credential material).
### 3.2 `agent.enrollment.get` (query)
By agent id; actor must be the owner (or admin). Unauthorized and
missing fold to the same `not_found` wire shape (contract 2
no-existence-oracle rule, applied family-wide for uniformity).
The query carries the same non-state envelope as the mutation
(contract 5 §4.3; contract 3's envelope reconciliation confirms closed
query responses carry it): typed request DTO with an optional
`correlationId` (generated when absent) and a typed result —
`found { agent, correlationId }` | `not_found` (the folded shape,
also carrying the correlation id). Queries take no idempotency key
(the fence binds mutations).
### 3.3 Error enum (closed, §4.2)
`validation_failed` 400 · `authentication_failed` 401 ·
`authorization_refused` 403 (owner-only paths; folded to `not_found`
where §3.2 applies) · `not_found` 404 · `conflict` 409 (the single
bounded idempotency refusal shape of §3.1 rule 5) · `precondition_failed`
422 (unresolvable credential reference; well-formed harness not in the
registry — syntactic invalidity is `validation_failed` per the §3.1
table) · `internal_fault` 500 (also the §4.4 fail-closed class when the
owning tool is unreachable; unauthorized-fallback behavior is
prohibited).
## 4. Schema delta (migration 0021, additive-only)
Extend `agents` — no new agent table, preserving custody-schema §5.2's
FK binding without amendment:
- `harness` text NULL — registered harness name; NULL for pre-existing
rows (legacy rows predate the concept).
- `enrolled_at` timestamptz NULL — set by `agent.enroll`; NULL marks a
legacy (non-enrolled) row. No backfill: enrollment is a fact this
command creates, not one to invent for existing rows.
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
separate store): `agent_audit_events` (append-only: id, event_type,
actor id, agent id — snapshot value, no FK, per §3.1 rule 6 —
correlation id, causation id, payload jsonb, created_at; per-agent
ordering index), `agent_outbox` (hierarchy-outbox shape), and
`agent_idempotency_fence` (contract 3 §4.3 shape: operation identifier,
key, acting principal, authorization scope, canonicalized-payload
digest, replay mode, committed-outcome reference (agent id), created_at;
UNIQUE (operation identifier, key)). Persona reuses the existing
`system_prompt` column; no version column (no ratified expected-version
rule names `agents` — §4.1 binds only where the owning contract defines
one).
Witnesses (real PostgreSQL, lane standard): append-only enforcement,
same-tx atomicity (agent row + credential write + fence row + audit +
outbox all-or-nothing under injected failure at multiple points,
including after the credential write), fence uniqueness on
(operation, key).
Sequencing: additive DDL via the same migration path as 0018–0020
(hierarchy). The docs/native-kanban-sot/SHARED-CONTRACT.md §5.3 DDL
gate binds the kanban lane's audit/proposal DDL, not this lane; if a
pending operator ruling on migration sequencing changes mechanics
lane-wide, re-check before generating 0021.
## 5. Witnesses the implementation slice must ship
1. Never-echo: enroll via `intake`, assert the value string is absent
from the HTTP result, the audit row, the outbox payload, and captured
logs.
2. Sealed-store single-copy: after intake, the credential exists only in
`provider_credentials` (sealed), and `agents` has no credential
column at all.
3. Reference-resolution refusal (`precondition_failed`, no row created).
4. Harness refusals, both codes: syntactically invalid →
`validation_failed`; well-formed registry miss →
`precondition_failed` (against the live registry).
5. Idempotency (contract 3 §4.3 set): actor-bound replay returns the
recorded outcome and executes nothing (no new agent/audit/outbox
mutation rows; a replay access event is appended); payload-digest
mismatch, replay-mode mismatch, scope mismatch, and different-actor
actor-bound replay each refuse with the single bounded `conflict`
shape; a replay is re-authorized fresh (a submitter whose
authorization was revoked since the original is refused, not
replayed); a `shared` declaration on `agent.enroll` is refused
`validation_failed` with nothing executed and no fence row
recorded (seed-only rule); two concurrent same-(operation, key)
submissions produce exactly one mutation, the loser resolving
through the replay path (no unhandled unique-violation fault).
6. Same-tx atomicity fault injection (agent / credential write / fence
/ audit / outbox), including a failure injected after the intake
credential write commits its statement — everything rolls back, no
orphan credential.
7. Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
call → zero rows in `agents`/`agent_audit_events`/`agent_outbox`/
`agent_idempotency_fence` attributable to the family.
8. `is_system` injection attempt is rejected by DTO validation.
9. Correlation-id witness (contract 5 §6.3): a correlation id submitted
on `agent.enroll` appears in its audit event(s) and in the result;
the same holds for `agent.enrollment.get`'s result; the §6.3 static
companions (no `any`-typed boundary pass-through; single audit
emitter) apply. §6.3's no-existence-oracle probe: an unauthorized
`agent.enrollment.get` of an existing agent and a get of a
nonexistent id return indistinguishable results.
10. CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
`agent.enroll` and `agent.enrollment.get` against the Gateway
succeeds with the same typed results the web client receives. The
implementation slice therefore SHIPS CLI exposure for both
operations (contract 5 §4.5 — a Gateway command without CLI
exposure is a tracked conformance gap; this design refuses to open
one).
11. Fail-closed witness (contract 5 §6.5): with the owning tool or
grant state unreachable (fault injection), the operation returns
the internal-fault or authorization-refusal class and performs no
fallback read/write.
## 6. Out of scope
Wizard orchestration (M4-6); any UI (D8/D12); un-enroll/update lifecycle
(no contract requires it in v1 — the legacy write surfaces named in §2
keep serving existing consumers); OAuth login, multi-account, comms
auto-enroll, model recommendation (PRD full flow, deferred by D11);
contract amendments (F1 recorded above for a future S2 pass). CLI
exposure is explicitly IN scope (witness 10 — contract 5 §4.5 binds it).
+99
View File
@@ -0,0 +1,99 @@
# Plan — Stack Containerization (tiered deployment)
Status: DRAFT for review. Charter: fleet/lanes/stack-containerization
(brain) NORTH-STAR.md; PRD amendment in the same PR adds D15.
Supersedes nothing; sequences the absorbed M4 remainder per its lane.
## Measured baseline (origin/next @ 143ba0f5, 2026-08-30)
- `docker-compose.yml`: dev infrastructure only — postgres (pgvector),
valkey, otel-collector, jaeger. No application services.
- `docker-compose.federated.yml`: standalone overlay for the FEDERATED
storage tier (own postgres/valkey; port-conflicts the base stack by
design). Not an app deployment.
- `docker/gateway.Dockerfile`, `docker/appservice.Dockerfile`:
multi-stage production builds (node:22-alpine) EXIST; the gateway image
includes the web SPA bundle (#1444).
- CI (`publish.yml`) builds and publishes these images (next-channel
prereleases + main stable), and runs `verify:release` fail-closed.
- Gap: no stack-level composition wires gateway+appservice+data plane
into one deployable unit; no blessed install/upgrade path; no
in-container agent-runtime story for the dogfood loop.
## Target (PRD D15 amendment)
Tiered deployment, additive to the existing architecture:
1. **Standalone tier (v1 bar)**: `docker compose up` on one host brings
postgres, valkey, openbao, gateway, appservice (and the webUI the
gateway serves) to healthy; migrations apply; the webUI hosts agent
chat; an in-stack agent can read this repo and open a PR; CI
validates; the deployment adopts merged images (pull + restart).
2. **Enterprise tier (post-v1)**: Kubernetes manifests (or Helm) for the
same service set, phase-gated on the standalone bar holding.
## Phases
### Phase A — blessed standalone compose
- A1 Compose service definitions for gateway + appservice joining the
existing infra compose (profiles: `dev` keeps today's behavior;
`stack` adds the app tier), with health checks and dependency order.
- A2 Migrations on boot (or an explicit migrate step) with idempotency
and version pinning; init-db.sql folded into pg-init.
- A3 Openbao in the compose set (secret plumbing for the app tier).
- A4 `.env.example` + `mosaic.config.json` defaults documented for the
standalone mode; mode recorded per the mode-conversion contract.
- A5 Smoke: `docker compose --profile stack up` green on a scratch host;
webUI served; agent chat reachable; failures catalogued and fixed.
- Acceptance: the five-point NORTH-STAR bar measured live.
### Phase B — component completion
- Interface assumption (velma verdict A1, P5-RM-005/006): in-stack
dogfood agents inherit SEAT-GRADE identity — credential-slot
isolation, wrapper-first enforcement, no privileged coordination
identity, evidence by references that resolve outside the container
lifetime.
- Decompose JIT from A5's catalogue. Known candidates: agent runtime
bits (brain/tool access paths in-container), repo credentials for the
dogfood agent, watch/comms surfaces inside the deployment.
### Phase C — CI/CD parity
- Publish pipeline is the only image source (already true); add the
deployment-side pull/upgrade path (compose pull + migrate + restart =
next iteration); document the promotion flow next -> registry ->
deployment.
### Phase D — coordinator integration (GATED)
- Gate (velma verdict C2): blocked until the checkpoint-and-lease child
of the guides-proposed control-plane refactor — core + WU-P1-CHECKPOINT
(schema, freshness, incarnation, clean-replacement resume; D57-D60
lineage) — carries an independent target-bound PASS. Wiring restarts
against the core alone re-creates the stale-incarnation failure class
D57-D60 closed. Transitive: inherits the T108 gates (P0 exit + Jason
P1 authorization).
- Scope (velma verdict C1): lifecycle actions (start/stop/restart/
health/recovery) executed by the SHIPPED coord client over the one
typed coordination contract (request id, actor identity, epoch,
revision, lease, correlation; typed stale rejection; worker role
boundary). No second coordination interface gets designed here —
containerization consumes the coordination contract, never defines it.
### Phase E — enterprise tier
- k8s manifests/Helm for the same set; phase-gated on Phase A holding.
### Absorbed M4 remainder
- M4-3 pivot: KBN-101 foundation first (per ruling R6), then expand DDL.
- M4-5: lands inside Phase B/C where natural.
- M4-6 (composes M4-1+M4-4): last, as designed.
## Non-goals (v1)
- No Kubernetes in v1; no multi-host federation; no replacement of the
fleet's brain-based seats (the stack is an additional operator
surface); no on-host image builds for deployment (registry only).
+4
View File
@@ -13,6 +13,10 @@ status: active
- [Documentation structure README implementation](2026-08-10-docs-structure-readme.md) — completed implementation plan for the documentation contract and atlas.
- [Documentation catalog and truth audit](2026-08-10-docs-catalog-audit.md) — audit method, evidence statuses, deliverables, and acceptance criteria.
## Feature design plans
- [Agent enrollment command design](2026-08-29-agent-enrollment-command-design.md) — v1 rank-4 enrollment command family: contract composition, command surface, schema delta, witnesses (M4-4-0).
After a plan is delivered, update the canonical guide, contract, decision, or index. Do not cite a plan as proof that intended behavior shipped.
## Related
@@ -0,0 +1,12 @@
{
"summary": "No important actionable issues found. Helper failures remain terminal, and the previously reported token-export regression is fixed. Bash syntax, package JSON, and caller-token export checks passed. The full regression suite was not run because the sandbox is read-only.",
"verdict": "approve",
"confidence": 0.88,
"findings": [],
"stats": {
"files_reviewed": 6,
"blockers": 0,
"should_fix": 0,
"suggestions": 0
}
}
@@ -0,0 +1,13 @@
{
"summary": "No confident security findings in the six supplied changed files. Seat lookups use the production helper, helper failures remain terminal, and returned credentials are validated. Bash syntax checks passed. Runtime regression tests were not run because the sandbox is read-only.",
"risk_level": "none",
"confidence": 0.87,
"findings": [],
"stats": {
"files_reviewed": 6,
"critical": 0,
"high": 0,
"medium": 0,
"low": 0
}
}
+279
View File
@@ -0,0 +1,279 @@
# Deployment Mode and Conversion Contract (D3)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
Standalone and Enterprise, with the mode table (brains, user-data
isolation, secrets, conversion); Standalone → Enterprise conversion is
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
— the per-user brain split is optional in Standalone and keeping it is
the recommended default because it preserves forward-compatibility with
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
flow only; Enterprise conversion is explicitly deferred. PRD D3
federation clause — federation is intentionally not fully designed,
deferred, and nothing in v1 may foreclose it.
Revision 2 (luna review F1–F7): the identity precondition restated in
identity-contract terms with a conversion-local acknowledgment record
this contract owns (F1); a durable, keyed preparation state with a
Standalone-safe representation rule, an in-transaction re-check fence,
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
directly without the contradictory non-exhaustiveness clause (F3); the
D14 boundary bound here with a stable column-allowlist witness instead
of delegated to an unratified layout (F4); the conversion witness
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
writer coverage imported concretely from contract 1 §6.3 with a named
schema, closed writer set, crafted-write probe, and mode-resolution
assertion (F6); the mode read command flagged as a §12.1 drafting
addition rather than a D8 mandate (F7). Ownership language aligned
with contract 3 revision 2: mode is recorded at bootstrap and read by
the wizard as input.
This contract binds the mode as a canonical platform property (§2), the
per-mode obligations and which contract owns each (§3), the conversion
transition (§4), the v1 non-foreclosure obligations (§5), and their
witnesses (§6). Domain semantics stay with their owning contracts:
wizard branching (contract 3 §2), identity/SSO
(`identity-lifecycle.md`), custody and per-user brain mechanics
(contract 7, `custody-schema.md`), tool mapping
(`tool-gateway-mapping.md`).
## 1. Definitions
1. **Mode**: the platform-wide deployment mode, exactly one of
`standalone` or `enterprise`. The vocabulary is closed in v1;
extension (e.g. a federation mode) is by amendment to this contract,
never ad hoc.
2. **Conversion**: the one-way transition `standalone → enterprise`.
No other mode transition exists.
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
must all hold before the mode record may change.
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
the migration of one secret to the Vault backend, or the partition
of one user's brain content (§4.3).
## 2. Mode is a canonical recorded property
1. Mode is recorded canonically in the platform database at bootstrap
as the operator's install-time choice (D3: modes are "chosen at
install time"). The record is a single-row keyed record
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
reference); this contract owns it, the bootstrap writer performs the
one v1 write (§6.2), and the wizard reads it as input (contract 3
§2.3). Mode is never derived from feature state (presence of Vault,
count of brains, count of users), and no component may infer a
different mode than the record states.
2. The record is readable by any authenticated user through a Gateway
command with CLI exposure. This read command is a **drafting
addition** ratified with this contract (PRD §12.1), not a D8
mandate: D8 binds only that any surface exposing the value goes
through official tooling. When a webUI surface consumes the read, a
mapping row is added to `tool-gateway-mapping.md` by amendment —
the same route §4.4 already binds for the conversion command.
Components branch on the read value only.
3. The record is immutable except by the §4 conversion transition.
Editing it by direct database access, config file, environment
variable, or wizard re-run is non-conformant (contract 3 §2.3:
changing mode later is conversion, not a wizard re-run).
## 3. Per-mode obligations (owner map)
The PRD mode table binds four rows; this contract assigns each an
owning contract so no obligation is unowned and none is bound twice:
| Obligation | Standalone | Enterprise | Owner |
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
The Standalone brains row states the default layout, not the only
valid one: the D14 per-user split is a MAY in Standalone with keeping
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
secrets are equally valid Standalone configuration. Both prepared
states are therefore themselves valid Standalone states — the fact
§4.3 relies on.
In Enterprise steady state, a flat-file secrets backend is
non-conformant; the platform refuses to start Enterprise-mode
components against a flat-file secrets configuration (fail-closed, not
warn-and-run).
## 4. Conversion transition
1. **Direction and terminality.** The only transition is
`standalone → enterprise`. `enterprise → standalone` does not exist:
there is no command, no admin override, and no support path. An
attempt is refused with the precondition/state error class of the
command envelope (`tool-gateway-mapping.md` §4.2).
2. **Preconditions (all verified before the record changes):**
- Secrets: OpenBao/Vault is configured and reachable, and every
required secret is served from the Vault backend — none from a
flat-file backend. Secret migration completes before conversion;
this contract does not define the migration tooling, only the
gate.
- Brains: the per-user brain split required by the Enterprise row of
§3 is established for **every** existing user (or the deployment
already kept the split, the D14 recommended default). Brain
partitioning mechanics are contract 7; this contract binds only
that the split is complete before the mode flips.
- Identity: at least one platform administrator account exists that
is active in identity-contract terms — authenticated capability,
not banned, not deactivated (identity §2, §5). And the conversion
request carries a **configuration acknowledgment**: the current
canonical values of registration mode and per-provider JIT
enablement (identity §2.2, §4.1), echoed back in the request. A
mismatch between the echoed values and the canonical values at
verification refuses the conversion. This acknowledgment record
is conversion-local, owned by this contract, and stored with the
§4.4 audit event as the precondition evidence; it adds no
identity-contract obligation and no mode-specific identity
default — identity's own defaults remain valid states.
3. **Preparation state and the flip boundary.** Preparatory work is
tracked durably: each preparation unit (§1.4) records its
completion in a preparation table keyed by (bootstrap epoch, unit
identity — the secret's path, the user's id), written in the same
transaction as the unit's own effect where the unit's backend
allows it, and reconciled from the backend's actual state where it
does not (a secret already served by Vault, a brain already split,
is complete regardless of the table). Units are at-most-once per
key and re-runnable across attempts. **Standalone-safe
representation:** every preparation unit moves the deployment into
a state that is itself valid Standalone configuration (§3 note), so
an interrupted preparation leaves a fully operational Standalone
deployment reading its state through the ordinary contracts — no
rollback, fencing, or special Standalone read path is needed, and
no component behavior may key on "preparation in progress".
**The flip:** one transaction that (a) locks the mode record, (b)
re-verifies every §4.2 precondition after acquiring the lock, and
(c) writes the mode record and the §4.4 audit event. Any re-check
failure aborts with no write. External state that changes after the
re-check but before commit is bounded by the transaction window;
an external backend (Vault) failing after conversion is an
Enterprise runtime fault handled by §3's fail-closed steady-state
rule, not a conversion defect. An interrupted or failed conversion
leaves the record `standalone` and the platform fully operational;
there is no intermediate mode and no half-converted state
observable through the record.
4. **Authority and audit.** Conversion is a platform-administrator
command carrying an explicit irreversibility acknowledgment in its
request (distinct from the §4.2 configuration acknowledgment). It
is an official Gateway/CLI command (D8): when built, it is added to
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
§3.3). The transition emits an audit event (actor, prior mode, new
mode, precondition evidence reference including the configuration
acknowledgment) in the same transaction as the record change; the
event survives indefinitely. A refused attempt emits a refusal
event naming the failed precondition class and actor, with no
mode-change event.
## 5. v1 obligations (non-foreclosure)
v1 ships Standalone only (D11); the conversion command is deferred
work. v1 still MUST:
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
refused value for bootstrap — v1 bootstrap accepts `standalone`
only. The wizard reads the record (contract 3 §2.3); nothing in v1
writes it after bootstrap.
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
record.
3. Not foreclose conversion: the v1 platform database holds no
sensitive user content — sensitive categories live in the owning
user's brain, and postgres holds structure, consent records, and
pointers only (the D14 boundary, PRD §7). Custody mechanics are
contract 7's; this contract binds the boundary itself here so v1
cannot ship a layout that makes the §4.2 brain precondition
unsatisfiable, and §6.3 gives it a stable witness that does not
depend on contract 7's internals. Conversion implementation
additionally requires contract 7 ratified.
4. Not foreclose federation: v1 components accept exactly the two §1.1
values wherever a mode value is parsed and refuse any other value
**before side effects** — a refused configuration, not undefined
behavior and not a crash mid-operation. Forward compatibility lives
in storage and architecture, not in parser speculation: the mode
record's storage is not structurally locked to two values (no
database-level two-value enum), and any future value (e.g. a
federation mode) is defined by a versioned amendment to this
contract before any component accepts it. The PRD defers
federation's shape entirely; this contract does not presume it
arrives as a third mode value.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Mode-record witness (v1):** after bootstrap the mode is readable
via the Gateway command and CLI and equals the bootstrap-recorded
choice; bootstrap with mode `enterprise` is refused; bootstrap with
any unknown mode value is refused before side effects (§5.4).
2. **Writer-coverage witness (v1):** the mode record's writer set is
closed by the same three-prong static assertion contract 1 §6.3(b)
defines — symbol, class-table literal, and raw-execution prongs
with its allowlist composition rules — scoped to the
`platform_mode` table, with a writer allowlist containing exactly
the bootstrap writer in v1 (and exactly plus the conversion command
at the conversion milestone). Companions: a crafted direct write
attempted in a test fails and leaves the record unchanged; a
mode-resolution assertion that no shipped component derives mode
from feature state (mode reads occur only through the §2.2 read
surface — static assertion over Gateway, CLI, bootstrap, and
repository sources).
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
style of contract 1 §6.2 that the platform database schema contains
no sensitive-content column — the §5.3 boundary — stable regardless
of contract 7's internals (contract 7 §7 carries the full custody
witnesses).
4. **No-downgrade witness (conversion milestone):** with mode
`enterprise`, a conversion request to `standalone` (and any crafted
mode-write) is refused with the precondition/state error class and
no record change.
5. **Precondition witnesses (conversion milestone),** each refused
with no record change and no partial mode effect, parameterized
over both OpenBao and Vault where secrets are involved:
(a) secrets backend unreachable; (b) one required secret still
flat-file backed (migration incomplete); (c) one unpartitioned user
brain in a **multi-user** deployment where every other user is
partitioned; (d) no active platform administrator (the only admin
banned or deactivated); (e) configuration acknowledgment missing or
mismatching the canonical registration/JIT values; (f) actor not a
platform administrator (authorization refusal); (g) irreversibility
acknowledgment absent. And the steady-state rule: an
Enterprise-mode component started against a flat-file secrets
configuration refuses to start (§3).
6. **Interruption and fence witnesses (conversion milestone):** fault
injection aborting conversion after each preparation unit and
between preparation and flip leaves the record `standalone` and the
platform operational in Standalone semantics (§4.3
Standalone-safety), and a re-attempt completes without duplicating
prepared state (at-most-once keys); a precondition invalidated
after preparation but before the flip (a secret reverted to
flat-file) is caught by the in-transaction re-check and refused.
7. **Audit witnesses (conversion milestone):** a completed conversion
has exactly one mode-change audit event, same-transaction with the
record change (transaction linkage asserted), carrying actor, prior
mode, new mode, and the precondition evidence reference including
the configuration acknowledgment; a failed attempt has a refusal
event naming the failed precondition class and no mode-change
event; the mode-change event remains queryable after subsequent
unrelated audit activity (retention probe).
8. **Mapping witness (conversion milestone):** the conversion command
and the mode read command each have their
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
before the commands ship.
## Ruling request
Ratify sections 1–6 as written, with one decision embedded:
- Decision (§5): v1 implements the **mode record and its immutability
only** — bootstrap records `standalone`, the `enterprise` value is
reserved and refused, and the conversion command itself is deferred
to the Enterprise milestone, consistent with D11's deferred list.
v1 carries three obligations beyond the record: the closed writer
assertion, the D14 column boundary, and the unknown-value refusal
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
conversion work. Alternative if rejected: build the conversion
command inside v1 — rejected because D11 scopes v1 to the Standalone
slice and conversion depends on contract 7 custody mechanics that
are themselves not in the v1 slice.
+44
View File
@@ -514,3 +514,47 @@ itself permits, so that contract does not stretch A1 by interpretation.
default-open disclosure.
5. Every other constraint of A1 — §8.1.2's remaining bullets, §8.2 in full,
and §8.3's other acceptance criteria — is untouched.
## 10. Amendment A3 — capability-holder existence disclosure
**Status:** amendment to Amendment A2, added by reviewed PR together with
contract 2 Amendment 1 (`rbac-grant-model.md` §8, this PR), under that
amendment's ruling request (decision owner Jason). It binds if and only if
contract 2 Amendment 1 ratifies; until then §9.1.2's sole-disclosure rule
stands unmodified — which is consistent, because until ratification the
company-CRUD capability class is empty and the carve-out below has no
holders. Everything in §§1–9 remains binding verbatim, with exactly the one
express modification below. The detailed contract text lives in
`rbac-grant-model.md` §8.1; this amendment changes only what A2 itself
permits, so that contract does not stretch A2 by interpretation.
### 10.1 What A3 modifies in A2
1. **Capability-holder disclosure (narrows §9.1.2's sole-disclosure rule
by one carve-out).** §9.1.2 makes the directory the sole permitted
existence disclosure and keeps private companies undisclosed to
non-granted subjects everywhere. A3 admits exactly one further
disclosure channel: a subject holding the company-CRUD capability
(contract 2 §8), when exercising the hierarchy schema §5.5 visibility
command, learns the target company's existence and its old/new
visibility values through the command's redacted actor receipt —
success for an existing target (private or directory alike) versus
`not_found` for a nonexistent id — bounded exactly as contract 2 §8.1
states: no name, slug, structure, content, grant, or membership
information, and no read command of any kind. To every other
non-granted subject, private companies remain undisclosed everywhere,
including the directory; the directory remains the sole
existence-disclosure _listing_.
### 10.2 What A3 explicitly does not change
1. The directory itself is unchanged: read-only, directory-class companies
only, existence/name/slug only (§9.1.2's enumeration is narrowed for
capability holders' receipts, widened for nothing).
2. No join-request surface, no curation listing, no read command of any
family is authorized (§9.2.2 unchanged; a curation listing is a further
amendment per contract 2 §8.1).
3. The canonical audit event for visibility mutations is untouched — it
keeps hierarchy schema §5.2's full immutable target snapshot; the
capability confers no audit read (contract 2 §8.5).
4. Every other constraint of A1 and A2 is untouched.
+219
View File
@@ -24,6 +24,26 @@ fail-closed-fault, and existence-oracle observables added (§7);
role-string namespacing rule added (§4.5); ruling request now names the
interpretive resolution of PRD "admins".
Amendment 1 (company-CRUD capability): defines the capability class that
contract 1 Amendment 1 (Ruling 4b, 2026-08-28) and hierarchy schema §5.5
anticipate. §8 defines the capability as a platform-scoped, admin-assigned,
audited delegation of exactly the hierarchy schema §5.5 company visibility
command — no read command, no other company operation; the mutation's
inherent existence disclosure is ratified as a bounded carve-out to
hierarchy schema §6.7/§2.8 and to kanban SOT Amendment A2's
sole-disclosure rule — SOT Amendment A3 (native-kanban-sot.md §10, this
PR) expressly extends A2 by exactly this carve-out (§8.1). The holder
sees only a redacted actor receipt; the canonical audit event keeps
hierarchy schema §5.2's full immutable snapshot. The hierarchy role
vocabulary (§2), every evaluation rule (§3), and grant management (§4) are
untouched: the capability is not a `hierarchy_grants.role` value and
evaluates outside the chain; capability-row deletion joins §6.1's
revocation enumeration (§8.4). Until this amendment ratifies, the capability
class is empty and
the visibility command remains admin-only (hierarchy schema §5.5 states
this fallback; the shipped gate at
`apps/gateway/src/hierarchy/hierarchy.repository.ts` implements it).
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
each hierarchy level confers, how grants evaluate down the chain, how
revocation propagates, and who may manage grants. Out of scope: the hierarchy
@@ -242,6 +262,191 @@ contract 1 §6):
8. Transfer: both-sides `owner` accepted, each single-side case refused
(completing contract 1 §6.5).
## 8. Company-CRUD capability (Amendment 1)
Hierarchy schema §5.5 authorizes the company visibility mutation for
exactly two actor classes: platform admins and "subjects holding the
company-CRUD capability that a follow-up amendment to contract 2 will
define". This section is that definition. The name is historical — coined
in contract 1 Amendment 1 before the capability's content was fixed — and
confers nothing by connotation: the ratified content is exactly §8.1.
Company _creation_ is already ruled open to active users and always
private (contract 3 §5.2, Ruling 4); rename, delete, and transfer of
companies remain hierarchy `owner` operations (§2.3, §5); none of those is
part of this capability, and widening it to any other operation is a
further amendment, not an implementation decision.
1. **Content: exactly one command, no read command, disclosure stated.**
Holding the capability authorizes executing the hierarchy schema §5.5
visibility command (`companies.visibility`, both directions:
`private → directory` and `directory → private`) on any company in the
deployment, and no other command of any family. It confers **no read
command**: no company enumeration, no curation listing, no structure
read. The practical flow this implies is deliberate: to publish a
private company, the holder is given the target identifier by the
requesting company `owner` out of band; to unpublish, the target is
already directory-listed. A curation listing for capability holders,
if ever wanted, is a further amendment with its own disclosure
analysis under hierarchy schema §6.7.
**Existence disclosure carve-out, stated rather than pretended away:**
exercising a mutation inherently discloses its target's existence.
The command's result distinguishes an existing company (success, for
private and directory targets alike) from a nonexistent id
(`not_found`), so a holder presenting candidate ids learns existence —
exactly as a platform admin already does through the same command.
This amendment ratifies that disclosure as part of the §5.5 curation
authority, bounded as follows. The holder-visible surface is the
command's **actor receipt** — the mutation result payload, carrying
exactly the target id, old visibility, and new visibility, and
**nothing else**: no name, slug, structure, content, grant, or
membership information. The actor receipt is a redacted projection
distinct from the **canonical audit event**, which is unchanged by
this amendment: it keeps hierarchy schema §5.2's deletion-safe
immutable target snapshot (id, slug, and parent chain at event time)
in full. The two never converge on the holder: the capability confers
no audit read (§8.5), so the canonical event — and with it the slug
and parent chain — is reachable only by subjects independently
authorized to read audit data, never through this capability. A
successful publish additionally makes the target directory-listed to
every authenticated user; that is the command's ratified purpose
(hierarchy schema §5.5), not a leak. Hierarchy schema §6.7's
existence-oracle rule and §2.8's directory-only disclosure are amended
by exactly this carve-out for capability holders, kanban SOT Amendment
A3 (native-kanban-sot.md §10, this PR) expressly extends A2's
sole-disclosure enumeration by the same carve-out, and all three are
otherwise untouched. Witnessed in §8.6.3.
2. **Holding: platform-scoped assignment, user subjects only.** The
capability is not a hierarchy grant: it attaches to no node, has no
role, and never enters §3 chain evaluation. It is held via a
`platform_capabilities` table whose column set is exactly (nothing
else, per the contract 1 §2.7 exhaustiveness discipline):
- `id` — uuid, primary key;
- `user_id` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
- `capability` — text, NOT NULL, constraint-checked against exactly
`company_crud`;
- `granted_by` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
- `created_at` — timestamptz, NOT NULL;
- UNIQUE (`user_id`, `capability`).
The user FKs are **text**, not uuid, because `users.id` is a BetterAuth
text key (`packages/db/src/schema.ts`; custody schema records the same)
— PostgreSQL cannot reference a text primary key with a uuid column.
This matches the shipped `hierarchy_grants` shape exactly: uuid
surrogate `id`, text FKs to `users`.
Both user FKs are RESTRICT for the same reason contract 1 §3.3 pins
RESTRICT on principal FKs: the identity contract (§7.3) gates user
deletion, and a cascade here could silently destroy a capability
without its §8.3 revocation audit event. Revocation is row deletion
through the §8.3 command — there is no other removal path, no expiry
column, and no tombstone. A deactivated holder confers nothing while
deactivated: identity contract §7.1 denies all authorization to
deactivated accounts, and the §8.4 predicate evaluates on the
authenticated live user. No team subjects (§1.4's suspension reasoning
applies with more force here — a workspace-bound team holding
deployment-wide curation authority has no ratified meaning).
3. **Assignment is instance administration on the normal admin surface.**
Only platform admins (`users.role = 'admin'`) may assign or revoke the
capability, through an ordinary admin command (the same command class
`AdminGuard` governs, §1.1) — not through direct table writes.
Assignment delegates a slice of instance administration and is itself
an instance-administration act under §1.1. A capability holder as such
may NOT assign or revoke it (no self-propagation). Every assignment
and revocation is a semantic audit event carrying actor, verb, subject
user, and capability; serialized capability strings are namespaced per
§4.5 (`platform-capability:company-crud` — a bare `company_crud` in
any serialized artifact is non-conformant).
4. **Evaluation and revocation follow this contract's existing rules.**
The hierarchy schema §5.5 command's authorization predicate is:
`users.role = 'admin'` OR a live `platform_capabilities` row
(`user_id`, `company_crud`). Both disjuncts are evaluated live and
fail closed per §3.5 — **independently**: with capability state
unreadable (fault), the capability disjunct denies, but a platform
admin whose `users.role` is readable remains authorized through the
admin disjunct; with role state unreadable, the admin disjunct denies
likewise. A decision that can read neither denies. Capability-row
deletion is hereby added to §6.1's enumerated revocation paths:
it propagates identically, under §6.2's bound, on every transport —
no new HTTP/MCP command authorized by the deleted row after the
revoking transaction commits, and any cached authorization is
invalidated in the revoking transaction (§3.5).
5. **What it does not confer**, stated so implementing PRs cannot drift:
no hierarchy grant or effective role at any node; no workspace
authorization or membership; no content, structure, or roll-up read;
no grant management (§4.1 unchanged); no MCP scope; no other instance
administration (user management, system settings, provider
configuration remain platform-admin-only); no company create, rename,
delete, or transfer. Hierarchy schema §5.5's rule that a company
`owner` as such may NOT change visibility is unchanged — `owner` and
this capability are disjoint authorities that combine only by a
subject holding both.
6. **Verification requirements** (extends §7, binding on implementing
PRs):
1. Schema witnesses (real PostgreSQL, `ci-postgres` service in the
`test` CI step): the `capability` CHECK constraint rejects any
value outside `company_crud`; NOT NULL enforced on every declared
NOT NULL column; UNIQUE (`user_id`, `capability`) rejects a
duplicate; both user FKs reject a dangling reference AND deleting a
referenced user is refused (RESTRICT witnessed in both directions);
the table's column set is exactly the §8.2 declared set (contract 1
§6.2 discipline).
2. Capability-only command matrix — the witness that proves "exactly
one command", not merely "at least one": a non-admin holder with no
other grants succeeds on the visibility command in **both**
directions with contract 1 §5.2's audit event (old and new values
as semantic content), and the **same** actor is refused, case by
enumerated case: every hierarchy mutation family (company/child
create under another's node, rename, delete, transfer); grant
create/change/revoke; the workspace read and write command
families; roll-up reads; structure reads — including the
not-found-indistinguishable refusal on a structure read of the very
company they just mutated (hierarchy schema §6.7); every
instance-administration surface other than the visibility command
(user management, system settings, provider configuration, and
capability assign/revoke itself); and MCP scope derivation yields
nothing — the §7.4 deny-by-default matrix gains this row. Company
creation compares against an eligible-user baseline: the holder's
create behaves exactly as any active user's — always `private`,
and a creation request carrying a visibility argument is refused
for holder and baseline alike (contract 3 §5.2).
3. Disclosure bound (§8.1 carve-out witnessed, receipt and canonical
event separately): the mutation result for a private-valid target,
a directory-valid target, and a nonexistent id is exactly {success,
success, `not_found`}; the actor receipt for a success carries
exactly {target id, old visibility, new visibility} and no result
or error payload carries name, slug, structure, content, grant, or
membership data; the canonical audit event for the same mutation —
asserted directly against the hierarchy outbox, not through any
holder-facing surface — carries hierarchy schema §5.2's full
immutable snapshot (id, slug, parent chain); and the holder's
attempt to read audit data is refused (no audit read conferred,
§8.5), proving the receipt/event separation reaches the holder as
a redaction, not a weakened event.
4. Assignment path, both polarities: a platform admin assigns and
revokes through the normal admin command (positive witnesses —
assign then observe the §8.6.2 allow, revoke then observe deny); a
non-admin — including a current capability holder — is refused
assign and revoke; every assign/revoke produces its audit event
with the namespaced string (§8.3); a direct-write path that skips
the command surface is non-conformant (the §8.3 command is the only
writer of `platform_capabilities`).
5. Revocation joins the §7.6 matrix: assignment is decision-time-live
(capability assigned → the holder's next visibility command allows,
no re-login); after row deletion, the ex-holder's next visibility
command is refused **on every exposed transport**, measured with
the revocation and the decision on distinct physical connections; a
cached-authorization implementation proves transactional
invalidation (§3.5). Fail-closed fault witnesses, both disjuncts
(§8.4): with `platform_capabilities` unreadable, a non-admin holder
is denied while a platform admin remains authorized; with role
state unreadable, the admin disjunct denies.
6. Owner-as-such refusal re-witnessed: hierarchy schema §6.9's
owner-cannot-publish witness re-asserted with the
`platform_capabilities` table present and empty for that owner.
## Ruling request
Ratify sections 1–7 as written, with one decision embedded and one
@@ -257,3 +462,17 @@ interpretive resolution named:
platform admins. A1 §8.1.3 does not attribute grant declaration to
platform admins, and the §1.1 decision above is what makes this reading
binding.
## Ruling request (Amendment 1)
Ratify §8, the Amendment 1 header note, and kanban SOT Amendment A3
(native-kanban-sot.md §10 — the express A2 carve-out extension, which
binds only with this ratification) as written, with one decision
embedded:
- Decision: the company-CRUD capability is a platform-scoped,
admin-assigned, audited delegation of exactly the hierarchy schema §5.5
visibility command — no read command, no other company operation, with
the mutation's inherent existence disclosure ratified as a bounded
carve-out (§8.1). Say "agreed" or name the additional operations (or
the curation listing) you want it to carry.
+197
View File
@@ -0,0 +1,197 @@
# Tool↔Gateway Mapping Contract (D8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
every webUI operation goes through the Gateway API backed by the same
official framework tooling the CLI uses, and a webUI operation with no
backing tool is scored **blocked on tooling** and the tool is built
first. Measured input: the webui-audit A5 tooling baseline
(operation-by-operation inventory of the current Gateway surface and the
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
targeted-update plan including building the D8 tools in A5's rank order.
Revision 2 (GLM review F1–F5): the §2 table completed against an
independent re-measurement of the live `apps/web` surface (mission
reads, coordination status, capability-gated `turn:send` added); rank-6
composition corrected to ranks 1 and 4; SOT citations corrected to §3
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
softened to match what the owning contracts actually schedule; §6.1
scoped to outbound calls with an extractability lint, and §6.3 given
static companions for §4.1 and §4.3.
This contract binds three things: the operation→tool mapping itself
(§2–§3), the command envelope every mapped operation satisfies
(§4), and the process rule that keeps the mapping closed (§5). Domain
semantics stay with their owning contracts — hierarchy (contract 1,
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
wizard (contract 3, `onboarding-wizard.md`), identity
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
§5 and Amendment A1), roll-up (contract 8), API artifact format
(contract 9).
## 1. Definitions
1. **Official tool**: a command implemented in the framework packages and
exposed through the Gateway API; the CLI remains the primary execution
method for the same command (D8). The webUI is a Gateway client only.
2. **Mapped operation**: a webUI operation with a named official path in
§2 or §3. Anything else the webUI wants to do is unmapped and follows
§5.
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
need but is contractually barred from backing it (§3.2).
## 2. P0 mapping (current operations, ratified as-is)
This table is the complete measured P0 surface: every Gateway call the
web app's production sources make at this revision's head appears as a
row (independently re-measured at review; the three calls the first
measurement missed — mission reads, coordination status, and the
capability-gated `turn:send` emit — are rows below). The surface stays
bound to these paths:
| WebUI operation | Official path |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
| Mission list (legacy read) | `GET /api/missions` |
| Coordination status (legacy read) | `GET /api/coord/status` |
| Conversation CRUD/search/messages | `/api/conversations*` |
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
P0 rows inherit §4 obligations as their backing controllers are next
touched; they are not required to be retrofitted in one sweep.
## 3. P1 mapping (bound to the build-first tools)
1. Every P1 operation maps to exactly one build-first command family, in
the T10-ruled rank order:
| Rank | Command family (owning contract) | P1 webUI operations it backs |
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
2. **Legacy non-substitutes.** The following MUST NOT back any P1
operation, matching the audit findings: legacy `/api/projects` and
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
the typed kanban boundary); `POST /api/workspaces` (filesystem
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
epoch transition, identity §3 — not the re-runnable wizard); the MCP
`brain_*` task mutations (legacy Brain writes, not the typed kanban
commands). These stay serving their existing P0/host consumers until
the owning contract (or a successor amendment) schedules each
retirement — no such migration is scheduled at this revision; the
freeze stands on its own.
3. New P1 mapping rows (operations this table does not list) are added by
amending this contract, not ad hoc (§5).
## 4. Command envelope (request / result / error / audit)
Binding on every mapped operation the build-first families expose:
1. **Typed request and result.** Each command and query has an explicit
request DTO and result DTO in the shared types package, validated at
the Gateway boundary; unvalidated pass-through and `any`-typed
payloads are non-conformant. Mutations on records with an
expected-version rule in their owning contract carry the expected
version in the request and fail on mismatch with the conflict error
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
conflict acceptance; hierarchy per contract 1).
2. **Error taxonomy.** Every error result carries a stable
machine-readable code from a closed per-family enum plus an HTTP
status mapping, distinguishing at minimum: validation failure,
authentication failure, authorization refusal, not-found, conflict
(version/uniqueness), precondition/state refusal (e.g. bootstrap
epoch, suspended team subjects), and internal fault. Where contract
2's no-existence-oracle rule applies, authorization refusal and
not-found are indistinguishable on the wire for unauthorized readers
— same code, same status, same shape.
3. **Audit linkage.** A mutating mapped operation emits exactly the
audit events its owning contract defines (contract 1 §5.2, contract 2
§4.4, identity §§2–4, SOT audit rules); the envelope contributes the
correlation: every request accepts/generates a correlation id,
carried into the audit events and returned in the result, so a UI
action is traceable end to end. The mapping layer itself adds no
second audit stream.
4. **Fail-closed.** A mapped operation that cannot evaluate its
authorization or reach its owning tool refuses (contract 2 §3.5); the
envelope never degrades to an unauthorized fallback read or a direct
data access.
5. **CLI parity.** Each build-first family is invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts. No webUI-only command exists; a
Gateway command without CLI exposure is a conformance gap tracked at
the family's implementing issue.
## 5. Closure rule (blocked on tooling)
1. A webUI change that needs an operation with no mapping row is
**blocked on tooling**: the backing tool is built and mapped first
(D8). Scoring a gap "blocked on tooling" is mandatory, not
discretionary; working around it in the UI (direct DB or filesystem
access, calling a legacy non-substitute, embedding domain logic in
the web app) is non-conformant.
2. The mapping is enforced closed by §6.1's inventory witness: the web
app's network surface must be a subset of the mapped paths.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Network-surface inventory witness:** a CI assertion extracting the
web app's outbound Gateway calls — route literals at request call
sites and outbound socket emits in `apps/web` sources (inbound
handler registrations are not calls and are out of scope) — and
failing on any call outside the §2/§3 mapped paths. The inventory is
closed like contract 1 §6.3's allowlist: a new call fails until a
mapping row exists in the same PR. Dynamic route construction that
evades extraction is resolved toward the witness, enforced by an
extractability lint: every request call site takes a literal or
template-literal path, and a call site that does not fails the
assertion itself (the web-side analogue of contract 1's
raw-execution prong), never an exemption for the caller.
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
endpoints — asserted by the same inventory, scoped per surface.
3. **Envelope witnesses per family:** for each build-first family — a
request with an invalid DTO is refused with the validation code; a
version-mismatch mutation returns the conflict code; an unauthorized
read of an existing node and a read of a nonexistent node return
indistinguishable results where the no-existence-oracle rule applies;
a correlation id submitted on a mutation appears in its audit
event(s) and result. Two static companions: a type-level assertion
that the family's boundary accepts no `any`-typed or unvalidated
pass-through payload (§4.1), and a single-emitter assertion that the
mapped operation's audit events originate only from the owning
contract's audit emitter (§4.3's no-second-audit-stream, made
checkable).
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
least one command and one query against the Gateway succeeds with the
same typed result the web client receives.
5. **Fail-closed witness:** with the owning tool or grant state
unreachable (fault injection), the mapped operation returns the
internal-fault or authorization-refusal class and performs no
fallback read/write (extends contract 2 §7.6 to the mapping layer).
## Ruling request
Ratify sections 1–6 as written, with one decision embedded:
- Decision (§3.2): the legacy endpoints named there are **frozen for new
consumers** as of ratification — existing P0/host consumers keep
working, new UI or tool code may not call them, and each is retired by
the migration its owning contract schedules. Alternative if rejected:
allow P1 surfaces to reuse legacy endpoints as interim backends —
rejected by the audit's finding that they cannot satisfy the
hierarchy/kanban/RBAC contracts, so the interim would ship
non-conformant semantics.
@@ -0,0 +1,23 @@
# #1311 Credential seat-store alignment
## Scope
Restore the missing `load_credentials` Gitea seat-slot behavior in the Stack framework. A known fleet seat must obtain its token through the production credential helper, while its Gitea URL remains provider configuration. A missing seat slot must fail closed and never fall back to the service store.
## Evidence
- Base: `2101c9b4468b22f57b2f02bb2c9da12067225819` (`origin/next`).
- Historical branch `origin/fix/credentials-gitea-seat-slots` contains the pre-refactor direct-loader fix, but it predates the current Python wrapper and ancestry fence.
- Red reproduction: `test-credentials-gitea-seats.sh` failed on base for populated seats, empty-seat no-fallback, and cross-seat ancestry cases.
- Canonical source: `packages/mosaic/framework/`. The package installer and `mosaic-doctor` describe the shipped framework as the source for deployed framework tools. The brain runtime copy is an estate runtime copy, not this framework change's source.
## Decision
The loader delegates seat token resolution to its sibling `git-credential-mosaic` production entrypoint. It does not invoke `.impl` directly or read a seat slot itself. This preserves the wrapper's environment sanitization and the implementation's process-ancestry fence. Non-seat and no-identity service-store behavior, non-Gitea services, and pre-existing `GITEA_TOKEN` precedence remain unchanged. The loader continues to export a caller-supplied token so child tool processes receive it.
## Validation and review
- The final hermetic matrix passes for Mosaic and USC seat slots, empty and cross-seat refusals, service-store paths, explicit and unexported caller tokens, and Woodpecker isolation. It fails against `origin/next` for the expected missing behavior.
- `bash -n` passes for the loader and new suite. The framework test-enumeration guard passes with the new suite enumerated.
- `pnpm --dir packages/mosaic run test:framework-shell` reaches `invariant_r_unittest.py` and stops on its existing host-runtime check: the test expects Pi `0.84.1` while this host reports `1.0.3`. No file in that invariant or its runtime probe changed in this task. The suite passes its earlier systemd check when the user-bus environment is supplied.
- Initial independent code review found that the new conditional stopped exporting a caller-supplied unexported `GITEA_TOKEN`. The fix exports it in both Gitea arms and adds Mosaic/USC child-process regressions. Re-review approved with no findings. Independent security review reported risk level `none`.
@@ -0,0 +1,81 @@
# Forge wrapper review round 1 (#1841)
Reviewed candidate: 55d6cc18e378306146505303c9b73da01164a3a9.
Original base: 924438016e2eb1c2ca352bfbf9107143b8b342bb.
Implementation checkpoint: 8325df706 (new commits, no history rewrite).
The security and code findings are addressed by the following commits and controls.
All provider fixtures use synthetic credentials; no test writes to a live forge.
| Finding | Fix commit | Regression evidence |
| --- | --- | --- |
| S1 | d93960111 | `Policy.test_head_and_uncommitted_policy_cannot_authorize_merge`: a committed head and uncommitted declaration cannot widen the pinned target policy; absent policy remains squash-only; unavailable target objects refuse. |
| S2 | d93960111 | `CLI.test_missing_identity_refuses_before_requests`, `test_mutation_verifies_actor`, `test_enterprise_auth_uses_explicit_host_store`: no inherited provider login authority. |
| S3 | d93960111 | `Auth.test_sigterm_leaves_no_credential_files`: anonymous descriptor delivery, no token in argv, no named credential file after termination. |
| S4 | d93960111 | `ReviewRegressions.test_malformed_integration_is_unknown`: empty, false, string and malformed objects refuse; only null means absence. |
| S5 | d93960111 | `ReviewRegressions.test_null_thread_is_not_resolved`: resolution must be boolean. |
| S6 | d93960111; fixture completion 02c7f2b18 and 8325df706 | `Auth.test_no_identity_cannot_borrow_shared_or_git_config`, `test_host_bound_credential_and_xtrace`, `test_provider_store_separates_seat_and_service`; explicit-actor fixture migration. |
| C1 | d93960111 | `ReviewRegressions.test_same_sha_retarget_snapshot_refused`, `test_retarget_merge_never_writes`: full head/base ref, SHA and repository identity stay pinned across policy, aggregation and pre-write/readback checks. |
| C2 | e62adbcc2 | `Parity.test_independent_paths_match_expected_timeline_verdicts`, `test_profiles_use_same_independent_entry_paths`, `test_ready_and_stop_oracle`, `test_missing_notifier_cannot_arm`: separate wake/status and notifier/predicate/reread paths, recorded calls, independent expected verdicts and no writes. |
| C3 | e62adbcc2, 02c7f2b18 | Offline schema fixtures in `test-detect-platform-outside-repo.sh`, `test-issue-create-body-safety.sh`, `test-pr-metadata-gitea.sh`; unexpected network clients denied in the isolated sweep. |
| C4 | e62adbcc2 | `test-pr-skill-contract.py`: optional operator-local forbidden patterns default to empty; generic placeholder control. |
| C5 | e62adbcc2 | `git diff --check 924438016e2eb1c2ca352bfbf9107143b8b342bb`: exit 0. |
The policy adapter reads the declaration with `git show` from the provider's pinned
base commit in the origin-matched object store. It never consults the PR checkout's
policy or invokes its local declaration loader for mutation authority. The installed
validator still checks the target declaration. Missing target objects fail closed.
GitHub uses the explicit host-bound credential transport rather than inherited CLI
sessions. Every authenticated adapter path requires an actor, and mutation transport
verifies the authenticated account first. The inherited credential resolver no longer
uses shared tokens or git-config identity. Anonymous pipes carry authorization while
stdin remains available for request JSON. Credential tracing is disabled in a subshell.
The independent parity oracle was also run against two scratch-copy mutants. The
unmodified oracle exited 0; replacing ready with waiting exited 1; removing the
full-stop scheduled-integration guard exited 1. The mutation-control driver exited 0
only after asserting those child results. Live T3 and notifier delivery remain unverified.
## Verification
The per-suite results are recorded under `review_round_1` in
[the evidence file](1841-forge-wrapper-evidence.json). Python suites all exit 0:
103 adapter contract cases, 10 CLI cases, 4 authentication cases, 4 guard cases,
6 policy cases, 5 skill cases and 6 parity cases (138 runner cases total).
The declaration validator passes 101 assertions; Ajv schema and negative controls
exit 0. Bash syntax checks pass for all 107 git shell files. Full ShellCheck exits 1
on both the prior candidate (106 files) and the revised tree (107 files), with no
new file/code/message diagnostics. Corrected credential fixtures separately exit 0.
Workspace preflight, typecheck, lint and format checks exit 0. The three package
skill/manifest Vitest suites pass 97 tests. No dependencies were installed during
this review round; the existing system ShellCheck binary was used. Generated
push-guard fixtures now use task scratch storage and clean up instead of leaving
malformed JSON beneath the source tree.
The isolated shell sweep ran all 67 suites: 65 passed, the milestone baseline failed,
and the issue-close fixture still depended on a shared token. Commit 8325df706
converted that final fixture to a private synthetic actor; its isolated rerun and
ShellCheck both exit 0. There are therefore 66 passing suites after the targeted
correction, with the original sweep codes and rerun preserved separately.
The milestone-create usage suite still exits 1: missing title reaches the no-origin
error before the expected usage exit 2. The same isolated suite was run against the
pinned base and exits 1 there. This baseline failure is separate from the fixes.
## Follow-up and limits
S7 is intentionally excluded: inherited deployment-specific host/service literals
remain in `tools/git/detect-platform.sh` and `tools/git/README.md`. The eventual PR
body must list their cleanup under Follow-up, per the review scope decision.
Qualified older native review mutations and unverifiable scheduled-integration
operations remain unsupported as previously documented. No server upgrade or
repository setting change was attempted. Live provider mutation, real integration
scheduling, CI delivery and notifier delivery are unverified. Independent review of
the new head, PR creation and merge remain separate gates.
No additional local fixes were carried in this round. The original carried fix
remains Python import isolation in the shared detection/auth helper; no wholesale
operator-local tool directory was copied.
@@ -0,0 +1,77 @@
# Forge wrapper review round 2 (#1841)
Reviewed candidate: a193cf28458a7fc08f9ff4d6395526c5efd7918c.
Original base: 924438016e2eb1c2ca352bfbf9107143b8b342bb.
## Fixes and regression controls
R2-C1 and R2-S2 are fixed in bec37b6fd. Public GitHub REST and GraphQL use
`https://api.github.com`; enterprise REST uses `/api/v3` and GraphQL uses
`/api/graphql` on the declared host. Credential lookup remains bound to the
logical forge host. The CLI fake accepts only test-declared complete URLs and
rejects unexpected schemes, authorities, paths and queries. Its public and
enterprise snapshot regression requires a ready verdict, not merely equal
results from two paths.
Every nonzero curl exit now refuses before HTTP status or body parsing, including
when stdout contains a complete-looking successful HTTP response. The transport
contract covers exit 28 and 56 for reads and writes on public GitHub, enterprise
GitHub, Gitea and Forgejo; successful controls remain accepted. Failed writes
retain an unknown outcome. The focused URL and curl controls each failed with
exit 1 before the corresponding fix and passed with exit 0 afterward.
R2-S1 extends the earlier S6 fix to the complete shell credential lifetime. The
round-1 direct resolver test did not cover caller-side command-substitution
tracing; its earlier S6 completion claim was too broad. All 13 token-consuming
shell wrappers now execute credential resolution and all consumers in a private
subprocess with xtrace disabled. The caller never owns those token variables,
and tracing is never restored while they exist. The shared request helper sends
an escaped authentication config over an anonymous descriptor, preserves request
stdin, checks curl's exit status and closes the descriptor. Named authentication
config files and raw authentication headers in exec argv have been removed from
these consumers. The existing merge transport continues to use anonymous stdin
config and retains its once-only credential resolution.
`Auth.test_all_legacy_callers_keep_credentials_private_under_xtrace` executes an
authenticated endpoint in each wrapper under `bash -x`, records only safe
observations and checks stderr and argv for exposure. An inventory assertion
requires controls for every shell token consumer. Quote/backslash escaping is
checked independently. Scratch-copy mutants that remove metadata's private
boundary or put its token in curl argv are detected. Existing termination and
identity-refusal controls remain. The edit wrapper's signal test also caught a
subprocess signal-target regression; it now targets its actual Bash process and
preserves termination status without continuing to mutate.
Legacy stateful server fixtures now accept readable anonymous descriptors rather
than requiring regular authentication files. Review readback cleanup and edit
signal tests pass; these continue to assert persisted state, exact identity and
failure behavior. No live provider writes are involved.
R2-C3 updates the README to one current credential contract: private untraced
credential lifetime and anonymous authentication config delivery. Temporary files
are limited to response and policy data in approved scratch storage.
## Verification and limits
The per-command results and finding mapping are recorded in the `review_round_2`
section of [the evidence file](1841-forge-wrapper-evidence.json).
No dependencies were installed. No additional operator-local fixes were copied.
The inherited deployment-specific literals identified as S7 remain deferred to
the eventual PR's Follow-up section. Live provider writes, scheduler behavior,
notifier delivery, terminal-green CI and independent review of this revised head
remain unverified. This round does not create a PR or merge a branch.
The final isolated run contains 75 suites: all seven Python suites pass (146
runner cases), 66 of 67 git shell suites pass, and declaration validation passes.
The shell driver exits 1 solely for the separately reproduced milestone-create
missing-title baseline failure. Schema and negative controls, watch parity and
its mutant controls pass with external network clients denied. Bash syntax
passes all 107 shell files. Full ShellCheck exits 1 for both the prior candidate
and this tree; the final targeted callback annotation removes the last new
file/code/message diagnostic. Existing diagnostics remain.
Workspace preflight, typecheck, lint and format checks exit 0. The three package
skill/manifest Vitest suites pass 97 tests. `git diff --check` against the original
base exits 0. Commit hooks emit existing template configuration warnings, then
exit 0; hooks were not bypassed.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
# Forge wrapper gaps (#1841)
Baseline: 924438016e2eb1c2ca352bfbf9107143b8b342bb, fetched integration head.
The implementation keeps provider APIs in wrappers, workflow authority in skills and
notifications in transport adapters. No live provider mutation is part of fixture tests.
Independent candidate review and integration remain separate delivery gates.
## Scope and decisions
- A: versioned capability/status envelope, exact-head checks and watch predicate.
- B: complete review/comment enumeration and verified native replies/resolution.
- C: declared merge methods and separate automatic-integration inspection/control.
- D: direct-write guard coverage, CI-wait mapping, compatibility fixtures and documentation.
- Skills: packaged workflows/dependencies with upstream attribution and transport parity tests.
The optional schema-v3 merge policy was approved during implementation. Absent policy
remains squash-only. Task, promotion and explicit absorb mappings constrain method requests;
provider allow_* settings further restrict them. Invalid/unknown policy never grants authority.
Schema 1/2 stay readable; version-3 consumers retain branch gates.
Gitea/Forgejo scheduled merge state remains unsupported because inspected schemas lack
readback. Native review mutations remain unsupported on the older inspected profiles.
Unknown capabilities and required evidence fail closed. App-bound required GitHub checks
remain unknown pending a source-specific matcher. Live authentication, repository settings,
provider mutations and notifier delivery are unverified.
## Compatibility and safety work
Source is upstream framework content. Local fixes are evaluated individually; Python import
isolation was carried into the shared detection/auth helper. No wholesale local tool copy was
used. Additional fixes bind metadata detection to authority, quote declaration values as shell
words, and execute legacy credential/CI bridges from a safe working directory.
Legacy fixture clients now explicitly supply public API metadata instead of relying on host
name guessing. The no-PyYAML fixture uses an isolated no-site interpreter, because production
-I intentionally ignores PYTHONPATH. The obsolete aggregate-only CI fixture migrated to the
normalized exact-head/empty-check controls. The unknown-version validator fixture moved to
version 4 when version 3 became supported.
## Verification checkpoint
Focused adapter, shell-client, policy, guard, skill and parity tests pass. The declaration suite
also passes its schema-version migration controls. New shell entry points pass ShellCheck.
Preflight, workspace typecheck and lint pass after installing locked dependencies. The package
dry-run includes the new skills, licenses and helper assets.
Final shell sweep ran 66 suites. After the isolated declaration rerun, 65 passed; the
milestone-create missing-title usage case remains exit 1 and reproduces on the pinned base.
Its provider/no-origin error precedes the expected usage exit 2. This unrelated baseline
failure is documented instead of absorbed. An earlier issue-close failure under ambient
host state disappeared in the fully isolated run.
All 68 declaration consumption assertions pass. The unknown-version fixture now uses v4;
the v3 validator and shell-quoting control pass. New shell entry points pass ShellCheck;
changed existing wrappers have no diagnostics beyond the measured baseline. All 106 git
shell files pass bash syntax checks. Package skill/manifest tests pass 97 cases, and the real
catalog discovers all six promoted skills. Publish-list assertions retain source and licenses
and exclude test bytecode, with a two-entry bytecode negative control before the exclusion.
See 1841-forge-wrapper-evidence.json for per-suite exit codes and file mapping. Locked workspace
dependencies were installed for required push checks. ShellCheck was extracted into temporary
scratch without a system install. No services, containers, migration runner, application server,
local framework rollout or provider mutation test was started. Independent review, PR, CI,
integration and release remain pending. A host notifier is an explicit optional prerequisite
outside supported T3 notifications; this change does not install one.
Parity tests replay normalized provider evidence through mocked transports. They do not prove
live T3 support or notifier delivery. A failed required read blocks either workflow; unsupported
schedule inspection blocks full-stop in either transport. Scheduling success is never a merge
completion claim.
## Review round 2
Public/enterprise API routing, transport-error refusal and complete shell
credential privacy are recorded in [round-2 evidence](1841-forge-review-round-2.md).
The round-1 S6 claim is corrected there: resolver tracing alone did not protect
caller-side token assignments. Review fixes remain on the original branch, with
new commits and no history rewrite; PR creation and merge remain held.
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
import { createMissionTasksRepo } from './mission-tasks.js';
/**
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
* the sole path that authors mission_tasks.status from caller input (storage
* tier migration is row transport and preserves stored values; the generic
* storage adapters have no mission_tasks caller), and it must never forward a
* caller-supplied status to the database on create or update. Callers keep
* working (the field is accepted and ignored), so these tests assert on what
* reaches the Drizzle chain, not on rejection.
*/
function makeInsertDb(returned: unknown[]) {
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
return { db: { insert: vi.fn(() => ({ values })) }, values };
}
function makeUpdateDb(returned: unknown[]) {
const set = vi.fn((_v: unknown) => ({
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
}));
return { db: { update: vi.fn(() => ({ set })) }, set };
}
describe('createMissionTasksRepo — status write prohibition', () => {
it('create strips a caller-supplied status before insert', async () => {
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
const repo = createMissionTasksRepo(db as never);
const result = await repo.create({
missionId: 'm1',
userId: 'u1',
status: 'done',
description: 'd',
} as never);
expect(values).toHaveBeenCalledTimes(1);
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in inserted).toBe(false);
expect(inserted.missionId).toBe('m1');
expect(inserted.description).toBe('d');
expect(result.id).toBe('mt1');
});
it('create without status still inserts (DB default applies)', async () => {
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
const repo = createMissionTasksRepo(db as never);
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in inserted).toBe(false);
});
it('update strips a caller-supplied status but keeps the other fields', async () => {
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
const repo = createMissionTasksRepo(db as never);
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
expect(set).toHaveBeenCalledTimes(1);
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in updated).toBe(false);
expect(updated.notes).toBe('n');
expect(updated.updatedAt).toBeInstanceOf(Date);
expect(result?.id).toBe('mt1');
});
it('update with only status degenerates to a timestamp-only update', async () => {
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
const repo = createMissionTasksRepo(db as never);
await repo.update('mt1', { status: 'blocked' } as never);
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
expect(Object.keys(updated)).toEqual(['updatedAt']);
});
});
+20 -2
View File
@@ -3,6 +3,24 @@ import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
export type MissionTask = typeof missionTasks.$inferSelect;
export type NewMissionTask = typeof missionTasks.$inferInsert;
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
// write source through the N-1 window. This repo is the sole path that authors
// status from caller input, so the field is stripped here — accepted and
// ignored rather than rejected, because the legacy surface is frozen with
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
// surfaces touch the column and are deliberately NOT stripped:
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
// must preserve the stored value verbatim, and the generic table-keyed storage
// adapters register mission_tasks but have no caller that targets it (runtime
// callers use fixed collection constants). Neither authors a new status. The
// column keeps its DB default, stays declared and readable, and is retired
// only after no readers remain.
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
const rest = { ...data };
delete rest.status;
return rest;
}
export function createMissionTasksRepo(db: Db) {
return {
async findByMission(missionId: string): Promise<MissionTask[]> {
@@ -30,14 +48,14 @@ export function createMissionTasksRepo(db: Db) {
},
async create(data: NewMissionTask): Promise<MissionTask> {
const rows = await db.insert(missionTasks).values(data).returning();
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
const rows = await db
.update(missionTasks)
.set({ ...data, updatedAt: new Date() })
.set({ ...stripStatus(data), updatedAt: new Date() })
.where(eq(missionTasks.id, id))
.returning();
return rows[0];
@@ -0,0 +1,5 @@
ALTER TABLE "hierarchy_audit_events" DROP CONSTRAINT "hierarchy_audit_events_verb_check";--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "visibility" text DEFAULT 'private' NOT NULL;--> statement-breakpoint
ALTER TABLE "companies" ADD CONSTRAINT "companies_visibility_check" CHECK (visibility IN ('private', 'directory'));--> statement-breakpoint
ALTER TABLE "hierarchy_audit_events" ADD CONSTRAINT "hierarchy_audit_events_verb_check" CHECK (verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke'));--> statement-breakpoint
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_role_check" CHECK (role IN ('viewer', 'member', 'owner'));
@@ -0,0 +1,47 @@
CREATE TYPE "public"."agent_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
CREATE TABLE "agent_audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"seq" bigint GENERATED ALWAYS AS IDENTITY (sequence name "agent_audit_events_seq_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"event_type" text NOT NULL,
"actor_id" text NOT NULL,
"agent_id" uuid NOT NULL,
"correlation_id" text NOT NULL,
"causation_id" uuid,
"payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_audit_events_type_check" CHECK (event_type IN ('agent.enrolled', 'agent.enrollment.replayed'))
);
--> statement-breakpoint
CREATE TABLE "agent_idempotency_fence" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"operation" text NOT NULL,
"idempotency_key" text NOT NULL,
"actor_id" text NOT NULL,
"authorization_scope" text NOT NULL,
"payload_digest" text NOT NULL,
"replay_mode" text DEFAULT 'actor-bound' NOT NULL,
"outcome_agent_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_idempotency_fence_replay_mode_check" CHECK (replay_mode IN ('actor-bound', 'shared'))
);
--> statement-breakpoint
CREATE TABLE "agent_outbox" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"correlation_id" text NOT NULL,
"status" "agent_outbox_status" DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"delivered_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "harness" text;--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "enrolled_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "agent_audit_events" ADD CONSTRAINT "agent_audit_events_causation_id_agent_audit_events_id_fk" FOREIGN KEY ("causation_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_outbox" ADD CONSTRAINT "agent_outbox_event_id_agent_audit_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "agent_audit_events_seq_idx" ON "agent_audit_events" USING btree ("seq");--> statement-breakpoint
CREATE INDEX "agent_audit_events_agent_seq_idx" ON "agent_audit_events" USING btree ("agent_id","seq");--> statement-breakpoint
CREATE INDEX "agent_audit_events_correlation_idx" ON "agent_audit_events" USING btree ("correlation_id");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_idempotency_fence_operation_key_idx" ON "agent_idempotency_fence" USING btree ("operation","idempotency_key");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_outbox_event_idx" ON "agent_outbox" USING btree ("event_id");--> statement-breakpoint
CREATE INDEX "agent_outbox_status_created_idx" ON "agent_outbox" USING btree ("status","created_at");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -141,6 +141,20 @@
"when": 1787880918208,
"tag": "0019_volatile_killraven",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1787963521142,
"tag": "0020_special_betty_brant",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1788053011351,
"tag": "0021_agent_enrollment",
"breakpoints": true
}
]
}
@@ -0,0 +1,412 @@
/**
* Agent enrollment schema witnesses — M4-4a, the schema-level half of the
* witness list in docs/plans/2026-08-29-agent-enrollment-command-design.md §5.
*
* Witnesses the guarantees migration 0021's tables themselves carry: the
* event-type CHECK, monotonic per-agent append order (`seq`), deletion-safe
* linkage (no foreign key from the events or fence tables into `agents` —
* rows survive a legacy CRUD DELETE of the agent), the causation self-FK,
* the outbox's FK/uniqueness/status shape, the fence's UNIQUE
* (operation, key) and replay-mode CHECK, and the nullable enrollment
* columns on `agents` (legacy rows insert without them). The command-level
* witnesses (never-echo, same-tx atomicity, replay semantics, correlation,
* CLI parity, fail-closed) belong to the M4-4b implementation slice.
*
* Two legs run the same witness body:
* - PGlite (WASM Postgres): always runs.
* - Real PostgreSQL: runs when DATABASE_URL is set — the binding witness;
* CI migrates ci-postgres before `pnpm test`.
*/
import { randomUUID } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createDb } from './client.js';
import { createPgliteDb } from './client-pglite.js';
import { runPgliteMigrations } from './migrate.js';
import { agentAuditEvents, agentIdempotencyFence, agentOutbox, agents } from './schema.js';
type AnyDb = {
db: {
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
};
close: () => Promise<void>;
};
/** Match a constraint failure anywhere along drizzle's cause chain. */
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
let err: unknown;
try {
await p;
} catch (e) {
err = e;
}
expect(err, label || 'expected the statement to be refused').toBeDefined();
const messages: string[] = [];
let cur: unknown = err;
while (cur instanceof Error) {
messages.push(cur.message);
cur = (cur as { cause?: unknown }).cause;
}
expect(messages.join(' | '), label).toMatch(re);
}
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
}
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
const T = `agent-e-${randomUUID().slice(0, 8)}`;
type EventInsert = typeof agentAuditEvents.$inferInsert;
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
return {
eventType: 'agent.enrolled',
actorId: `${T}-actor`,
agentId: randomUUID(),
correlationId: `${T}-corr-${randomUUID()}`,
payload: {
harness: 'claude-code',
provider: 'anthropic',
name: 'x',
credentialMode: 'reference',
},
...overrides,
};
}
type FenceInsert = typeof agentIdempotencyFence.$inferInsert;
function fenceRow(overrides: Partial<FenceInsert> = {}): FenceInsert {
return {
operation: 'agent.enroll',
idempotencyKey: `${T}-${randomUUID()}`,
actorId: `${T}-actor`,
authorizationScope: 'platform-user',
payloadDigest: `${T}-digest`,
outcomeAgentId: randomUUID(),
...overrides,
};
}
function witnessSuite(getHandle: () => AnyDb): void {
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
afterAll(async () => {
const d = db();
await d.execute(sql`DELETE FROM agent_outbox WHERE correlation_id LIKE ${T + '%'}`);
// Caused events first: the causation self-FK is RESTRICT.
await d.execute(
sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'} AND causation_id IS NOT NULL`,
);
await d.execute(sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM agent_idempotency_fence WHERE actor_id LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM agents WHERE name LIKE ${T + '%'}`);
});
// ── agents: nullable enrollment columns (no backfill semantics) ────────────
it('legacy agent rows insert without enrollment columns; enrolled rows carry both', async () => {
const legacyId = randomUUID();
await db()
.insert(agents)
.values({
id: legacyId,
name: `${T}-legacy`,
provider: 'anthropic',
model: 'claude-fable-5',
});
const legacy = rows(
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${legacyId}`),
)[0]!;
expect(legacy['harness']).toBeNull();
expect(legacy['enrolled_at']).toBeNull();
const enrolledId = randomUUID();
await db()
.insert(agents)
.values({
id: enrolledId,
name: `${T}-enrolled`,
provider: 'anthropic',
model: 'claude-fable-5',
harness: 'claude-code',
enrolledAt: new Date(),
});
const enrolled = rows(
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${enrolledId}`),
)[0]!;
expect(enrolled['harness']).toBe('claude-code');
expect(enrolled['enrolled_at']).not.toBeNull();
});
// ── agent_audit_events: CHECK, ordering, deletion-safe linkage ─────────────
it('accepts both declared event types and refuses an undeclared one', async () => {
await db()
.insert(agentAuditEvents)
.values(eventRow({ eventType: 'agent.enrolled' }));
await db()
.insert(agentAuditEvents)
.values(eventRow({ eventType: 'agent.enrollment.replayed' }));
await expectViolation(
db()
.insert(agentAuditEvents)
.values(eventRow({ eventType: 'agent.deleted' })),
/type_check|violates check/i,
'undeclared event type must be refused',
);
});
it('assigns strictly increasing seq in insert order for one agent', async () => {
const agentId = randomUUID();
const c1 = `${T}-seq-1-${randomUUID()}`;
const c2 = `${T}-seq-2-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ agentId, correlationId: c1 }));
await db()
.insert(agentAuditEvents)
.values(eventRow({ agentId, eventType: 'agent.enrollment.replayed', correlationId: c2 }));
const res = rows(
await db().execute(
sql`SELECT correlation_id, seq FROM agent_audit_events WHERE agent_id = ${agentId} ORDER BY seq ASC`,
),
);
expect(res.map((r) => r['correlation_id'])).toEqual([c1, c2]);
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
});
it('has no foreign key into agents, and events survive agent deletion', async () => {
const fks = rows(
await db().execute(sql`
SELECT ccu.table_name AS referenced_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_audit_events'
`),
);
// The causation self-FK is the ONLY foreign key on the events table.
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['agent_audit_events']);
const agentId = randomUUID();
await db()
.insert(agents)
.values({ id: agentId, name: `${T}-doomed`, provider: 'anthropic', model: 'claude-fable-5' });
const corr = `${T}-survive-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ agentId, correlationId: corr }));
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
const after = rows(
await db().execute(
sql`SELECT agent_id FROM agent_audit_events WHERE correlation_id = ${corr}`,
),
);
expect(after).toHaveLength(1);
expect(after[0]!['agent_id']).toBe(agentId);
});
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
await expectViolation(
db()
.insert(agentAuditEvents)
.values(eventRow({ causationId: randomUUID() })),
/foreign key/i,
'causation must reference an existing event',
);
const causeCorr = `${T}-cause-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ correlationId: causeCorr }));
const cause = rows(
await db().execute(
sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${causeCorr}`,
),
)[0]!;
await db()
.insert(agentAuditEvents)
.values(
eventRow({
eventType: 'agent.enrollment.replayed',
causationId: cause['id'] as string,
}),
);
await expectViolation(
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${cause['id'] as string}`),
/foreign key/i,
'a cause with dependent events must not be deletable',
);
});
// ── agent_outbox shape ─────────────────────────────────────────────────────
it('outbox rows require an existing event, one outbox row per event, closed status enum', async () => {
await expectViolation(
db()
.insert(agentOutbox)
.values({ eventId: randomUUID(), correlationId: `${T}-corr` }),
/foreign key/i,
'outbox must reference an existing event',
);
const corr = `${T}-ob-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ correlationId: corr }));
const event = rows(
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
)[0]!;
const eventId = event['id'] as string;
await db().insert(agentOutbox).values({ eventId, correlationId: corr });
await expectViolation(
db()
.insert(agentOutbox)
.values({ eventId, correlationId: `${T}-ob2` }),
/duplicate key|unique/i,
'one outbox record per event',
);
await expectViolation(
db().execute(
sql`INSERT INTO agent_outbox (event_id, correlation_id, status)
VALUES (${eventId}, ${`${T}-ob3`}, 'failed')`,
),
/invalid input value for enum|22P02/i,
'status outside pending/processing/delivered must be refused',
);
});
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
const corr = `${T}-obr-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ correlationId: corr }));
const event = rows(
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
)[0]!;
await db()
.insert(agentOutbox)
.values({ eventId: event['id'] as string, correlationId: corr });
await expectViolation(
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${event['id'] as string}`),
/foreign key/i,
);
});
// ── agent_idempotency_fence: (operation, key) uniqueness, mode CHECK ───────
it('refuses a duplicate (operation, key) pair but allows the same key under another operation', async () => {
const key = `${T}-fence-${randomUUID()}`;
await db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key }));
await expectViolation(
db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key })),
/duplicate key|unique/i,
'fence uniqueness is (operation, key)',
);
// Same key, different operation identifier: a distinct fence.
await db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key, operation: 'agent.other' }));
});
it('defaults replay mode to actor-bound and refuses an undeclared mode', async () => {
const key = `${T}-mode-${randomUUID()}`;
await db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key }));
const row = rows(
await db().execute(
sql`SELECT replay_mode FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
),
)[0]!;
expect(row['replay_mode']).toBe('actor-bound');
await expectViolation(
db()
.insert(agentIdempotencyFence)
.values(fenceRow({ replayMode: 'unbound' as 'actor-bound' })),
/replay_mode_check|violates check/i,
'a mode outside actor-bound/shared must be refused',
);
});
it('fence has no foreign key at all, and rows survive agent deletion', async () => {
const fks = rows(
await db().execute(sql`
SELECT ccu.table_name AS referenced_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_idempotency_fence'
`),
);
expect(fks).toHaveLength(0);
const agentId = randomUUID();
await db()
.insert(agents)
.values({
id: agentId,
name: `${T}-fdoomed`,
provider: 'anthropic',
model: 'claude-fable-5',
});
const key = `${T}-fsurvive-${randomUUID()}`;
await db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key, outcomeAgentId: agentId }));
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
const after = rows(
await db().execute(
sql`SELECT outcome_agent_id FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
),
);
expect(after).toHaveLength(1);
expect(after[0]!['outcome_agent_id']).toBe(agentId);
});
}
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
describe('agent enrollment schema witnesses — PGlite', () => {
let dir: string;
let handle: ReturnType<typeof createPgliteDb>;
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'agent-enroll-witness-'));
handle = createPgliteDb(dir);
await runPgliteMigrations(handle);
});
afterAll(async () => {
await handle.close();
rmSync(dir, { recursive: true, force: true });
});
witnessSuite(() => handle as unknown as AnyDb);
});
// ── Leg 2: real PostgreSQL (binding witness, ci-postgres in CI) ──────────────
const hasPostgres = Boolean(process.env['DATABASE_URL']);
describe.skipIf(!hasPostgres)('agent enrollment schema witnesses — real PostgreSQL', () => {
let handle: ReturnType<typeof createDb>;
beforeAll(() => {
handle = createDb(process.env['DATABASE_URL']!);
});
afterAll(async () => {
await handle.close();
});
witnessSuite(() => handle as unknown as AnyDb);
});
@@ -44,7 +44,7 @@ type AnyDb = {
/** Column allowlist — the exact declared sets of §2/§3. Nothing else. */
const COLUMN_ALLOWLIST: Record<string, string[]> = {
companies: ['id', 'name', 'slug', 'created_at', 'updated_at'],
companies: ['id', 'name', 'slug', 'visibility', 'created_at', 'updated_at'],
estates: ['id', 'name', 'slug', 'company_id'],
platform_projects: ['id', 'name', 'slug', 'estate_id'],
workspaces: ['id', 'name', 'slug', 'platform_project_id'],
@@ -377,7 +377,61 @@ function witnessSuite(getHandle: () => AnyDb): void {
// Control: same subject and target with a different role is a new grant.
await db()
.insert(hierarchyGrants)
.values({ userId: userA, companyId, role: `${T}-other-role`, grantedBy: userA });
.values({ userId: userA, companyId, role: 'member', grantedBy: userA });
});
// ── §2.6 role vocabulary CHECK ─────────────────────────────────────────────
it('refuses a grant role outside the ratified vocabulary, accepts each ratified role', async () => {
await expectViolation(
db()
.insert(hierarchyGrants)
.values({ userId: userB, companyId, role: 'superuser', grantedBy: userA }),
/check constraint/i,
);
// Serialized namespaced forms are storage-invalid too: rows hold bare roles.
await expectViolation(
db()
.insert(hierarchyGrants)
.values({ userId: userB, companyId, role: 'hierarchy:owner', grantedBy: userA }),
/check constraint/i,
);
for (const role of ['viewer', 'member', 'owner'] as const) {
await db()
.insert(hierarchyGrants)
.values({ userId: userB, estateId, role, grantedBy: userA });
}
await db().execute(
sql`DELETE FROM hierarchy_grants WHERE user_id = ${userB} AND estate_id = ${estateId}`,
);
});
// ── §2.8 visibility column ─────────────────────────────────────────────────
it('defaults companies.visibility to private and refuses values outside the class', async () => {
const visId = randomUUID();
await db().execute(
sql`INSERT INTO companies (id, name, slug) VALUES (${visId}, 'Vis', ${T + '-vis'})`,
);
const res = rows(await db().execute(sql`SELECT visibility FROM companies WHERE id = ${visId}`));
expect(res[0]!['visibility']).toBe('private');
await db().execute(
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis D', ${T + '-vis-d'}, 'directory')`,
);
await expectViolation(
db().execute(
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis X', ${T + '-vis-x'}, 'public')`,
),
/check constraint/i,
);
await expectViolation(
db().execute(sql`UPDATE companies SET visibility = 'hidden' WHERE id = ${visId}`),
/check constraint/i,
);
await expectViolation(
db().execute(sql`UPDATE companies SET visibility = NULL WHERE id = ${visId}`),
/null value|not-null/i,
);
});
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
@@ -391,7 +445,7 @@ function witnessSuite(getHandle: () => AnyDb): void {
);
await expectViolation(
db().execute(
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'x', NULL)`,
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'viewer', NULL)`,
),
/null value|not-null/i,
);
@@ -135,9 +135,9 @@
* production code is anomalous and review-visible; that blind spot is
* accepted as a residual, not closed.
*
* The writer allowlist names hierarchy command/repository modules ONLY. It is
* empty today: the hierarchy command family (M4-1b-ii) has not landed, so no
* production module may write the class tables. The infrastructure register
* The writer allowlist names hierarchy command/repository modules ONLY. Its
* single entry is the M4-1b-ii hierarchy command repository — the sole
* production module permitted to write the class tables. The infrastructure register
* holds legitimate non-hierarchy raw execution; registered modules are exempt
* from prong (iii) only — prongs (i) and (ii) apply to them with no
* exemption, and no registered module may appear on the writer allowlist.
@@ -181,13 +181,15 @@ const CLASS_TABLES = [
/**
* Writer allowlist (§6.3b): hierarchy command/repository modules only.
* EMPTY until the hierarchy command family lands (M4-1b-ii; M4-1b-i ships
* only the audit/outbox machinery, which writes no class table). Adding a module
* here is a contract-conformance decision reviewed under §5.1 — the module
* must be part of the Gateway hierarchy command path, and it must not export
* a function that executes caller-supplied SQL.
* Adding a module here is a contract-conformance decision reviewed under
* §5.1 — the module must be part of the Gateway hierarchy command path, and
* it must not export a function that executes caller-supplied SQL.
*/
const WRITER_ALLOWLIST: string[] = [];
const WRITER_ALLOWLIST: string[] = [
// The hierarchy command repository (M4-1b-ii): the sole class-table
// writer; every mutation is audited on its own transaction (§5.2).
'apps/gateway/src/hierarchy/hierarchy.repository.ts',
];
/**
* Infrastructure register: closed enumeration of legitimate non-hierarchy raw
+138 -9
View File
@@ -302,6 +302,11 @@ export const agents = pgTable(
skills: jsonb('skills').$type<string[]>(),
isSystem: boolean('is_system').notNull().default(false),
config: jsonb('config'),
// Enrollment (M4-4, docs/plans/2026-08-29-agent-enrollment-command-design.md §4).
// NULL on both marks a legacy (non-enrolled) row; no backfill — enrollment
// is a fact the rank-4 command creates, not one to invent for existing rows.
harness: text('harness'),
enrolledAt: timestamp('enrolled_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
@@ -1063,13 +1068,24 @@ export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens'
// command family only (§5.1), enforced by the writer-coverage assertion
// (§6.3b) — do not add writers outside that allowlist.
export const companies = pgTable('companies', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
/** Company visibility classes (contract 1 §2.8, Ruling 4b): 'private' is the
* only creatable class (§5.5 — creation carries no visibility argument);
* 'directory' discloses existence/name/slug to all users and is entered only
* through the admin-gated visibility-change command. */
export const COMPANY_VISIBILITY = ['private', 'directory'] as const;
export const companies = pgTable(
'companies',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
visibility: text('visibility').notNull().default('private'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
() => [check('companies_visibility_check', sql`visibility IN ('private', 'directory')`)],
);
export const estates = pgTable(
'estates',
@@ -1110,6 +1126,10 @@ export const workspaces = pgTable(
(t) => [unique('workspaces_platform_project_slug_uniq').on(t.platformProjectId, t.slug)],
);
/** Grant role vocabulary (contract 2 §2): totally ordered, viewer ⊂ member ⊂
* owner. Order in this tuple IS the ordering — index = strength. */
export const HIERARCHY_GRANT_ROLES = ['viewer', 'member', 'owner'] as const;
export const hierarchyGrants = pgTable(
'hierarchy_grants',
{
@@ -1126,7 +1146,8 @@ export const hierarchyGrants = pgTable(
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
onDelete: 'cascade',
}),
// Role vocabulary and its CHECK constraint are contract 2 §2 (M4-2).
// Role vocabulary per contract 2 §2: exactly viewer ⊂ member ⊂ owner,
// totally ordered; CHECK below closes the column to that vocabulary.
role: text('role').notNull(),
grantedBy: text('granted_by')
.notNull()
@@ -1134,6 +1155,7 @@ export const hierarchyGrants = pgTable(
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('hierarchy_grants_role_check', sql`role IN ('viewer', 'member', 'owner')`),
check('hierarchy_grants_subject_check', sql`num_nonnulls(user_id, team_id) = 1`),
check(
'hierarchy_grants_target_check',
@@ -1170,6 +1192,7 @@ export const HIERARCHY_AUDIT_VERBS = [
'create',
'rename',
'transfer',
'visibility_change',
'delete',
'grant_create',
'grant_change',
@@ -1220,7 +1243,7 @@ export const hierarchyAuditEvents = pgTable(
index('hierarchy_audit_events_correlation_idx').on(t.correlationId),
check(
'hierarchy_audit_events_verb_check',
sql`verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
sql`verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
),
check(
'hierarchy_audit_events_target_kind_check',
@@ -1261,3 +1284,109 @@ export const hierarchyOutbox = pgTable(
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
],
);
// ---------------------------------------------------------------------------
// Agent enrollment (M4-4) — rank-4 command family audit/outbox/fence stores.
// Design: docs/plans/2026-08-29-agent-enrollment-command-design.md §4.
// Pattern reuse from the hierarchy audit/outbox pair, separate store. Audit
// rows reference the agent by snapshot id, deliberately with NO FK, so audit
// history survives agent deletion through the legacy CRUD DELETE path.
// Idempotency for this family lives in agent_idempotency_fence (contract 3
// §4.3 envelope, ratified into contract 5 §4 via contract 3 §7 item 4) — the
// audit and outbox tables carry no idempotency key of their own.
export const AGENT_AUDIT_EVENT_TYPES = [
// Semantic mutation event of agent.enroll.
'agent.enrolled',
// Non-mutation access class: a passing idempotent replay appends this and
// nothing else (accessing principal, current correlation id, fence-row
// reference in the payload).
'agent.enrollment.replayed',
] as const;
export const agentAuditEvents = pgTable(
'agent_audit_events',
{
id: uuid('id').primaryKey().defaultRandom(),
// Global append order; per-agent ordering is a filter on agent_id ordered
// by seq.
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
eventType: text('event_type').notNull(),
// No FK: audit events outlive every principal and every target.
actorId: text('actor_id').notNull(),
agentId: uuid('agent_id').notNull(),
correlationId: text('correlation_id').notNull(),
causationId: uuid('causation_id').references((): AnyPgColumn => agentAuditEvents.id, {
onDelete: 'restrict',
}),
// Immutable snapshot at event time; never carries credential material
// (§3.1 rule 1: actor, agent id, harness, provider, name, credentialMode).
payload: jsonb('payload').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('agent_audit_events_seq_idx').on(t.seq),
index('agent_audit_events_agent_seq_idx').on(t.agentId, t.seq),
index('agent_audit_events_correlation_idx').on(t.correlationId),
check(
'agent_audit_events_type_check',
sql`event_type IN ('agent.enrolled', 'agent.enrollment.replayed')`,
),
],
);
export const agentOutboxStatusEnum = pgEnum('agent_outbox_status', [
'pending',
'processing',
'delivered',
]);
export const agentOutbox = pgTable(
'agent_outbox',
{
id: uuid('id').primaryKey().defaultRandom(),
// FK into the append-only events table: never dangles, RESTRICT is safe.
eventId: uuid('event_id')
.notNull()
.references(() => agentAuditEvents.id, { onDelete: 'restrict' }),
correlationId: text('correlation_id').notNull(),
status: agentOutboxStatusEnum('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
},
(t) => [
uniqueIndex('agent_outbox_event_idx').on(t.eventId),
index('agent_outbox_status_created_idx').on(t.status, t.createdAt),
],
);
// Contract 3 §4.3 fence shape. Uniqueness is (operation, key); the recorded
// replay mode is always 'actor-bound' for this family (`shared` is seed-only
// and refused at validation — design §3.1), but the column keeps the ratified
// envelope shape and serves the mode-mismatch collision check. The payload
// digest input EXCLUDES the credential value (design §3.1 rule 5).
export const agentIdempotencyFence = pgTable(
'agent_idempotency_fence',
{
id: uuid('id').primaryKey().defaultRandom(),
operation: text('operation').notNull(),
idempotencyKey: text('idempotency_key').notNull(),
// No FK: fence rows outlive principals, mirroring the audit tables.
actorId: text('actor_id').notNull(),
authorizationScope: text('authorization_scope').notNull(),
payloadDigest: text('payload_digest').notNull(),
replayMode: text('replay_mode').notNull().default('actor-bound'),
// Committed-outcome reference (the enrolled agent's id). Snapshot value,
// no FK: the fence must keep answering replays after a legacy DELETE.
outcomeAgentId: uuid('outcome_agent_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('agent_idempotency_fence_operation_key_idx').on(t.operation, t.idempotencyKey),
check(
'agent_idempotency_fence_replay_mode_check',
sql`replay_mode IN ('actor-bound', 'shared')`,
),
],
);
+1 -1
View File
@@ -46,7 +46,7 @@ whitelisted — see the tool header.
| tmux | `tools/tmux/agent-send.sh` | inter-agent messaging (see "Most-used" above) |
| git | `tools/git/*.sh` | issues, PRs, milestones, CI queue guard (platform-auto-detected) |
| woodpecker | `tools/woodpecker/*.sh` | CI pipelines (`-a mosaic`\|`usc`; match git remote host) |
| portainer | `tools/portainer/*.sh` | Docker Swarm stacks (status/redeploy/list) |
| portainer | `tools/portainer/*.sh` | Optional Docker Swarm tools when a Portainer credential is available |
| coolify | `tools/coolify/*.sh` | **DEPRECATED** — superseded by Portainer; do not use for new deployments |
| authentik | `tools/authentik/*.sh` | identity (users/groups/apps/flows) |
| cloudflare | `tools/cloudflare/*.sh` | DNS (zones/records; `-a` instance) |
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# mosaic — fleet launcher (shipped-first, split-home safe).
#
# T110 / P5-RM-009 stack side. Carries the T106 brain launcher contract
# (shipped-first, worktree dev opt-in, OFF pass-through, typed failure) with
# one split-home correction: the SHIPPED npm mosaic is resolved from the real
# user's home (passwd database), never from $HOME. Under split-home seat
# layouts HOME is a seat home: it carries no npm prefix, and a
# $HOME/.npm-global there would be a plantable descriptor, so the $HOME
# candidate is consulted only when the passwd lookup itself fails, and then
# only with a full symlink-component refusal (secure descriptor traversal).
#
# Contract:
# 1. SHIPPED npm mosaic is the default. Candidate order:
# a. <real-home>/.npm-global/bin/mosaic — real home from the passwd
# database. The final component may be npm's own bin symlink into
# lib/node_modules; that indirection is npm's layout, not a plant.
# b. $HOME/.npm-global/bin/mosaic — ONLY when the passwd lookup
# fails, and then only when the candidate is a trusted-shape
# absolute path: relative HOME and parent-escape (..) components
# are refused outright, and every remaining component must be a
# non-symlink (secure descriptor traversal). Refused candidates
# are never executed.
# 2. Worktree build is DEV OPT-IN: used only when MOSAIC_CLI_WORKTREE is
# explicitly set. Health-checked via --version; ANY doubt (absent,
# unreadable, or failing) falls back to the shipped npm mosaic with a
# warning on stderr. With no environment set, worktree candidates are
# never consulted — stale worktree builds cannot regain precedence.
# 3. MOSAIC_FLEET_CLI_OFF keeps its pass-through semantics: set (any
# value) forces pure pass-through. The dev path is not consulted even
# when MOSAIC_CLI_WORKTREE is also set.
# 4. Typed failure: with no runnable candidate the launcher prints one
# stderr line naming what was checked and exits 127.
# 5. NEVER writes to the mosaic home or the npm prefix. Deployment to the
# fleet goes through the real channel (PR to next -> mosaic update).
#
# Env:
# MOSAIC_CLI_WORKTREE dev opt-in: path to a stack worktree whose
# packages/mosaic/dist/cli.js is used (health-checked,
# shipped fallback on doubt)
# MOSAIC_FLEET_CLI_OFF set (any value) to force pure pass-through
#
# Component walk note: the descriptor guard splits on "/" without quoting so
# multi-byte HOME paths with spaces are not supported for the FALLBACK
# candidate; the passwd candidate needs no walk (trusted derivation).
set -u
fail() {
echo "mosaic: $*" >&2
exit 127
}
# Real user home from the passwd database (HOME-independent).
real_home() {
getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6
}
# True when any component of an ABSOLUTE candidate path is a symlink. Only
# ever called after fallback_candidate_usable's absolute-shape check.
path_has_symlink_component() {
local path="$1" dir base acc="" part
dir="$(dirname -- "$path")"
base="$(basename -- "$path")"
local IFS='/'
for part in $dir; do
acc="$acc/$part"
[ -L "$acc" ] && return 0
done
[ -L "$dir/$base" ] && return 0
return 1
}
# Reject the untrusted $HOME fallback candidate unless it is a trusted-shape
# absolute path: absolute, no parent-escape (..) components, and no symlink
# components anywhere on the path. Every rejection is named on stderr so the
# typed failure explains itself. This is the launcher's descriptor guard; the
# suite's mutation control (guard bypassed) must plant-exec, proving the guard
# is what stands between a hostile HOME and code execution.
fallback_candidate_usable() {
local candidate="$1"
case "$candidate" in
/*) ;;
*)
echo "mosaic: refusing \$HOME candidate $candidate: relative path is untrusted without a passwd home" >&2
return 1
;;
esac
if printf '%s' "$candidate" | grep -qE '(^|/)\.\.(/|$)'; then
echo "mosaic: refusing \$HOME candidate $candidate: parent-escape component" >&2
return 1
fi
if path_has_symlink_component "$candidate"; then
echo "mosaic: refusing \$HOME candidate $candidate: symlink component (untrusted without a passwd home)" >&2
return 1
fi
return 0
}
# Print shipped candidates in contract order. Refusals are reported on stderr
# so the typed failure names the cause.
shipped_candidates() {
local rh home_candidate
rh="$(real_home)"
if [ -n "$rh" ]; then
printf '%s\n' "$rh/.npm-global/bin/mosaic"
return 0
fi
# passwd lookup failed: the only fallback is $HOME, descriptor-guarded.
if [ -n "${HOME:-}" ]; then
home_candidate="$HOME/.npm-global/bin/mosaic"
if fallback_candidate_usable "$home_candidate"; then
printf '%s\n' "$home_candidate"
fi
fi
return 0
}
resolve_shipped() {
local candidate
while IFS= read -r candidate; do
[ -n "$candidate" ] || continue
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done < <(shipped_candidates)
return 1
}
# Dev opt-in only: an explicit MOSAIC_CLI_WORKTREE reaches the worktree build,
# and pure pass-through (MOSAIC_FLEET_CLI_OFF) outranks it.
if [ -n "${MOSAIC_CLI_WORKTREE:-}" ] && [ -z "${MOSAIC_FLEET_CLI_OFF:-}" ]; then
CLI="$MOSAIC_CLI_WORKTREE/packages/mosaic/dist/cli.js"
if [ -r "$CLI" ]; then
if v="$(node "$CLI" --version 2>/dev/null)" && [ -n "$v" ]; then
exec node "$CLI" "$@"
fi
echo "mosaic: worktree build at $CLI failed its health check; using shipped npm mosaic" >&2
else
echo "mosaic: worktree build at $CLI absent or unreadable; using shipped npm mosaic" >&2
fi
fi
SHIPPED="$(resolve_shipped)" || true
if [ -n "${SHIPPED:-}" ]; then
exec "$SHIPPED" "$@"
fi
fail "no runnable CLI (shipped npm mosaic absent from the passwd-home npm prefix and \$HOME; worktree build requires MOSAIC_CLI_WORKTREE)"
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# Hermetic suite for the fleet/bin/mosaic launcher (T110 / P5-RM-009).
#
# Arms cover the plan acceptance: split-home shipped-first positive, typed
# failure on missing candidates, stale-worktree non-precedence, OFF
# pass-through, and secure-descriptor refusal on the untrusted $HOME
# fallback. No network, no real npm install, no node package build: the
# "shipped mosaic" is a stub script and getent is PATH-stubbed (set
# GETENT_STUB=fail to make the passwd lookup fail, exercising the guarded
# $HOME fallback).
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
LAUNCHER="$SCRIPT_DIR/mosaic"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$LAUNCHER" ] || fail "missing launcher"
[ -x "$LAUNCHER" ] || fail "launcher is not executable"
bash -n "$LAUNCHER" || fail "launcher fails bash -n"
WORK=$(mktemp -d)
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
REAL_HOME="$WORK/real-home"
SEAT_HOME="$WORK/seat-home"
STUB_BIN="$WORK/stub-bin"
mkdir -p "$REAL_HOME/.npm-global/bin" "$SEAT_HOME" "$STUB_BIN"
cat >"$REAL_HOME/.npm-global/bin/mosaic" <<'SH'
#!/bin/sh
echo "0.0.0-shipped-stub"
SH
chmod +x "$REAL_HOME/.npm-global/bin/mosaic"
# PATH-stubbed getent: reports the real home for the current uid, unless
# GETENT_STUB=fail is in the launcher environment (exercises the guarded
# $HOME fallback path).
cat >"$STUB_BIN/getent" <<SH
#!/bin/sh
if [ "\${GETENT_STUB:-}" = "fail" ]; then exit 2; fi
if [ "\$1" = "passwd" ]; then
echo "stub:x:$(id -u):$(id -g):stub:$REAL_HOME:/bin/sh"
exit 0
fi
exit 2
SH
chmod +x "$STUB_BIN/getent"
run_launcher() { # run_launcher <home> [VAR=value ...] -- [args...]
local home="$1"; shift
[ "${1:-}" = "--" ] && shift
env -i PATH="$STUB_BIN:/usr/bin:/bin" HOME="$home" TERM="${TERM:-dumb}" "$LAUNCHER" "$@"
}
# A1 — acceptance 1: split-home positive. HOME is an empty seat home; the
# shipped mosaic resolves through the passwd real home.
out="$(printf '' | run_launcher "$SEAT_HOME" -- --version)"
[ "$out" = "0.0.0-shipped-stub" ] || fail "A1 split-home positive: got '$out', want shipped stub version"
# A3 — acceptance 3: a stale worktree build is NEVER consulted without the
# explicit opt-in, even when a worktree exists on disk.
WT="$WORK/stale-wt"
mkdir -p "$WT/packages/mosaic/dist"
printf 'console.log("0.0.0-stale-worktree")\n' >"$WT/packages/mosaic/dist/cli.js"
out="$(printf '' | run_launcher "$SEAT_HOME" -- --version)"
[ "$out" = "0.0.0-shipped-stub" ] || fail "A3 stale worktree regained precedence without opt-in: got '$out'"
# A2 (opt-in healthy) — explicit MOSAIC_CLI_WORKTREE reaches the worktree.
out="$(printf '' | env MOSAIC_CLI_WORKTREE="$WT" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version)"
[ "$out" = "0.0.0-stale-worktree" ] || fail "A2 opt-in worktree not used: got '$out'"
# A2b (opt-in unhealthy) — absent dist falls back to shipped with a warning.
out2="$(printf '' | env MOSAIC_CLI_WORKTREE="$WORK/empty-wt" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>/dev/null)"
[ "$out2" = "0.0.0-shipped-stub" ] || fail "A2b unhealthy worktree fallback output: '$out2'"
err2="$(printf '' | env MOSAIC_CLI_WORKTREE="$WORK/empty-wt" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1 >/dev/null)"
case "$err2" in *"absent or unreadable"*|*"health check"*) ;; *) fail "A2b unhealthy worktree fallback warning missing: '$err2'" ;; esac
# A4 — OFF pass-through: worktree opt-in is ignored when OFF is set.
out="$(printf '' | env MOSAIC_FLEET_CLI_OFF=1 MOSAIC_CLI_WORKTREE="$WT" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version)"
[ "$out" = "0.0.0-shipped-stub" ] || fail "A4 OFF did not force pass-through: got '$out'"
# A5 — acceptance 4: typed failure when no candidate exists (passwd lookup
# fails, seat home carries no npm prefix). Expect 127 + documented message.
set +e
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1 >/dev/null)"
rc=$?
set -e
[ "$rc" = "127" ] || fail "A5 typed failure rc: got $rc, want 127"
case "$err" in *"no runnable CLI"*) ;; *) fail "A5 typed failure message missing: '$err'" ;; esac
# A6 — secure descriptor traversal, ABSOLUTE symlink plant (corrected per
# rev-code-02 B3: the symlink points at $PLANT/.npm-global so the candidate
# resolves EXACTLY to the planted executable). passwd lookup fails and a
# symlink-planted $HOME/.npm-global is refused without execution.
PLANT="$WORK/planted-target"
mkdir -p "$PLANT/.npm-global/bin"
cat >"$PLANT/.npm-global/bin/mosaic" <<SH
#!/bin/sh
touch "$WORK/planted-sentinel"
echo "0.0.0-planted"
SH
chmod +x "$PLANT/.npm-global/bin/mosaic"
ln -s "$PLANT/.npm-global" "$SEAT_HOME/.npm-global"
set +e
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
rc=$?
set -e
[ "$rc" = "127" ] || fail "A6 planted descriptor was followed (rc $rc, out '$err')"
case "$err" in *"symlink component"*) ;; *) fail "A6 refusal diagnostic missing: '$err'" ;; esac
[ ! -e "$WORK/planted-sentinel" ] || fail "A6 planted mosaic EXECUTED"
# A6b — mutation control (rev-code-02 B3): a copy of the launcher with the
# descriptor guard bypassed MUST execute the plant under the identical hostile
# arm. If the mutant stays clean, the plant path is wrong and A6 proves
# nothing.
MUTANT="$WORK/mutant-mosaic"
sed 's/if fallback_candidate_usable "\$home_candidate"; then/if true; then/' "$LAUNCHER" >"$MUTANT"
chmod +x "$MUTANT"
[ "$(grep -c 'if true; then' "$MUTANT")" -eq 1 ] || fail "A6b mutant not created (guard call not replaced)"
set +e
mout="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
mrc=$?
set -e
[ "$mrc" = "0" ] || fail "A6b mutant did not execute the plant (rc $mrc, out '$mout') - A6 proves nothing"
[ -e "$WORK/planted-sentinel" ] || fail "A6b mutant ran but sentinel absent - plant path wrong, A6 proves nothing"
# A7 — relative-HOME hostile arm (rev-code-02 B2): a relative HOME whose name
# is a symlink in the launcher CWD must be refused outright, never resolved
# against the working directory.
CWD_SANDBOX="$WORK/cwd-sandbox"
REL_PLANT="$WORK/relative-plant"
mkdir -p "$CWD_SANDBOX" "$REL_PLANT/.npm-global/bin"
cat >"$REL_PLANT/.npm-global/bin/mosaic" <<SH
#!/bin/sh
touch "$WORK/relative-sentinel"
echo "0.0.0-relative-planted"
SH
chmod +x "$REL_PLANT/.npm-global/bin/mosaic"
ln -s "$REL_PLANT" "$CWD_SANDBOX/relative-home"
set +e
rout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
rrc=$?
set -e
[ "$rrc" = "127" ] || fail "A7 relative HOME was followed (rc $rrc, out '$rout')"
case "$rout" in *"relative path"*) ;; *) fail "A7 relative-refusal diagnostic missing: '$rout'" ;; esac
[ ! -e "$WORK/relative-sentinel" ] || fail "A7 relative plant EXECUTED"
# A7b — mutation control for the absolute-shape check: the same mutant (guard
# bypassed) MUST execute the relative plant under the identical arm.
set +e
rmout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
rmrc=$?
set -e
[ "$rmrc" = "0" ] || fail "A7b mutant did not execute the relative plant (rc $rmrc, out '$rmout') - A7 proves nothing"
[ -e "$WORK/relative-sentinel" ] || fail "A7b mutant ran but relative sentinel absent - arm wrong, A7 proves nothing"
# A8 — parent-escape hostile arm (rev-code-02 delta, B2 remains): an absolute
# HOME containing a literal '..' component must be refused by the
# parent-escape check — the traversal would otherwise land on a planted tree
# OUTSIDE the seat home with no symlink involved.
ESC_BASE="$WORK/escape-base"
ESC_TARGET="$WORK/escape-target"
mkdir -p "$ESC_BASE" "$ESC_TARGET/.npm-global/bin"
cat >"$ESC_TARGET/.npm-global/bin/mosaic" <<SH
#!/bin/sh
touch "$WORK/escape-sentinel"
echo "0.0.0-escape-planted"
SH
chmod +x "$ESC_TARGET/.npm-global/bin/mosaic"
set +e
eout="$(printf '' | env GETENT_STUB=fail HOME="$ESC_BASE/../escape-target" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
erc=$?
set -e
[ "$erc" = "127" ] || fail "A8 parent-escape HOME was followed (rc $erc, out '$eout')"
case "$eout" in *"parent-escape component"*) ;; *) fail "A8 parent-escape diagnostic missing: '$eout'" ;; esac
[ ! -e "$WORK/escape-sentinel" ] || fail "A8 escape plant EXECUTED"
# A8b — mutation control: the guard-bypassed copy MUST execute the parent-
# escape plant under the identical arm (sentinel present, rc 0), proving the
# parent-escape check is what stands.
set +e
emout="$(printf '' | env GETENT_STUB=fail HOME="$ESC_BASE/../escape-target" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
emrc=$?
set -e
[ "$emrc" = "0" ] || fail "A8b mutant did not execute the escape plant (rc $emrc, out '$emout') - A8 proves nothing"
[ -e "$WORK/escape-sentinel" ] || fail "A8b mutant ran but escape sentinel absent - arm wrong, A8 proves nothing"
echo "mosaic launcher suite: all arms passed"
@@ -46,7 +46,12 @@ systemd/**
templates/**
tools/**
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
# fleet/bin is exact-entry on purpose (T110 B1): the estate's fleet/bin carries
# operator-owned executables this package does not ship; a subtree glob here
# would make keep-mode update prune them.
fleet/README.md
fleet/bin/mosaic
fleet/bin/test-mosaic-launcher.sh
fleet/examples/**
fleet/profiles/**
fleet/roles/**
@@ -53,21 +53,23 @@ sends, it does not auto-reply.
### Exit codes
| rc | Meaning |
| --- | ---------------------------------------------- |
| 0 | delivered or queued |
| 1 | target session not found |
| 2 | text reached the pane but is **still a draft** |
| 3 | usage error (bad class, missing `-s`) |
| rc | Meaning |
| --- | -------------------------------------------------------------------------------------------- |
| 0 | delivered or queued |
| 1 | target session not found |
| 2 | submission unconfirmed: draft still on the input line, or no positive evidence of submission |
| 3 | usage error (bad class, missing `-s`) |
**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm
instead:
**Never retry on rc=2.** The message may be in the target pane, and a retry can double-send it.
Confirm instead:
```bash
tmux capture-pane -p -t <session>:0.0 | tail -20
```
rc=2 is the normal result when the target is an idle pi seat.
rc=0 is the normal result for both idle and busy pi seats (submission confirmed by draft
transition, not by prompt glyph). rc=2 on a healthy seat is exceptional — treat it as a real
report and investigate the pane.
## Durable comms
@@ -136,7 +136,8 @@ The human is escalation-only for missing access, hard policy conflicts, or irrev
### Supported Targets
- **Portainer**: Deploy via `~/.config/mosaic/tools/portainer/stack-redeploy.sh`, then verify with `stack-status.sh`.
- **Docker Swarm**: If a stack README documents `docker stack deploy` on the manager, use that deploy path and its stated verification procedure.
- **Portainer (optional)**: Use only when the estate holds a Portainer credential. Do not propose Portainer otherwise. Deploy via `~/.config/mosaic/tools/portainer/stack-redeploy.sh`, then verify with `stack-status.sh`.
- **Coolify**: Deploy via `~/.config/mosaic/tools/coolify/deploy.sh -u <uuid>`, then verify with `service-status.sh`.
- **Vercel**: Deploy via `vercel` CLI or connected Git integration, then verify preview/production URL health.
- **Other SaaS providers**: Use provider CLI/API/runbook with the same validation and rollback gates.
@@ -30,18 +30,25 @@ supply it explicitly on any host where the provider CLI's default account is an
| `issue-close.sh` | Close an issue |
| `issue-reopen.sh` | Reopen a closed issue |
| Pull requests | |
| ---------------- | --------------------------------------------------------- |
| `pr-create.sh` | Open a pull request |
| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state |
| `pr-view.sh` | Show one PR |
| `pr-list.sh` | List PRs |
| `pr-diff.sh` | Fetch a PR's diff |
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
| `pr-merge.sh` | Merge a PR |
| `pr-close.sh` | Close a PR without merging |
| Pull requests | |
| ----------------------- | ----------------------------------------------------------- |
| `pr-create.sh` | Open a pull request |
| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state |
| `pr-view.sh` | Show one PR |
| `pr-list.sh` | List PRs |
| `pr-diff.sh` | Fetch a PR's diff |
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
| `pr-merge.sh` | Merge a PR |
| `pr-close.sh` | Close a PR without merging |
| `pr-status.sh` | Aggregate exact-head PR readiness and required evidence. |
| `pr-watch-check.sh` | Evaluate the watch predicate from a status snapshot. |
| `pr-review-comments.sh` | Enumerate review comments with provider-qualified IDs. |
| `pr-review-reply.sh` | Reply to a review thread when the provider supports it. |
| `pr-review-resolve.sh` | Resolve or reopen a supported review thread. |
| `pr-auto-merge.sh` | Inspect or control supported automatic merge scheduling. |
| `pr-merge-queue.sh` | Inspect or control supported native merge-queue membership. |
| Milestones | |
| --------------------- | ------------------ |
@@ -60,10 +67,12 @@ supply it explicitly on any host where the provider CLI's default account is an
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
| Context | |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
| Context | |
| -------------------------- | ---------------------------------------------------------------------------------------- |
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
| `forge-capabilities.sh` | Report versioned provider capabilities and unsupported operations. |
| `fixture-schema-client.sh` | Offline test helper returning a synthetic public schema; rejects other requests. |
| Workspace | |
| -------------------- | ------------------------------------------------------------------------ |
@@ -226,11 +235,11 @@ Multi-instance support: `-a <instance>` selects a named instance (e.g. `personal
### Repo Structure Declaration (T51)
```bash
# Validate a .mosaic/repo.json declaration (schema v1/v2, host:/ grammar,
# Validate a .mosaic/repo.json declaration (schema v1/v2/v3, host:/ grammar,
# ref grammar, cross-field rules, remote normalization; spec §5)
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json
# CI authoring rule: new/edited declarations must be schema_version 2
# CI authoring rule: new/edited declarations must be schema_version 2 or 3
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --require-v2
# Display mode (warns and omits root-dependent checks when MOSAIC_HOST_ROOT unset)
@@ -360,3 +369,7 @@ mosaic-ensure-excalidraw --check # verify registration
- Prefer `trash` over `rm` when available — recoverable beats gone forever
- Never run destructive commands without explicit instruction
- Write it down — "mental notes" don't survive session restarts; files do
Repository declarations also support schema v3 with an optional, strictly validated merge policy.
See [the git wrapper merge-policy contract](../tools/git/README.md#repository-merge-policy-schema-version-3).
Absent policy remains squash-only; command-line method requests do not grant merge authority.
@@ -1,16 +1,16 @@
---
name: mosaic-deploy
description: 'Full end-to-end deploy flow for Mosaic Stack projects: push branch → open PR → wait for CI → merge → redeploy Portainer stack. Use when deploying a feature branch to production or staging, or when asked to ship a completed feature. Orchestrates mosaic-gitea, mosaic-woodpecker, and mosaic-portainer skills.'
description: 'Full end-to-end deployment flow: push branch → open PR → wait for CI → merge → deploy using the path documented by the stack. Use when deploying a feature branch to production or staging, or when asked to ship a completed feature.'
---
# mosaic-deploy
End-to-end deployment flow for Mosaic Stack projects.
End-to-end deployment flow.
## Full Deploy Sequence
```
push branch → open PR → CI passes → merge → portainer redeploy
push branch → open PR → CI passes → merge → documented deploy path
```
### Step 1: Push branch and open PR
@@ -49,25 +49,32 @@ review. Fix the cause; never route around it with a raw API call, a shared
credential, or `force_merge`. Exceptional cases go to the operator or the
coordinating seat, still merged through the wrapper.
### Step 4: Redeploy Portainer stack
### Step 4: Deploy Through the Documented Path
Read the stack README before deploying:
- If it documents `docker stack deploy` on the manager, use that deploy path and its verification procedure.
- Use Portainer only when the estate holds a Portainer credential. Do not propose Portainer otherwise.
For an authorized Portainer deployment:
```bash
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
```
Check deployment:
Check a Portainer deployment:
```bash
~/.config/mosaic/tools/portainer/stack-status.sh -n <stack-name>
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 50
```
## Stack Name Map
## Optional Portainer Stack Map
Maintain your estate's project → stack-name mapping in a skills-local override of
this skill (local copies take precedence over the shipped canonical one). Example
shape:
For deployments that use Portainer, maintain a project → stack-name mapping in a
skills-local override of this skill (local copies take precedence over the shipped
canonical one). Example shape:
| Project | Stack Name |
| ------------ | ----------------- |
@@ -77,6 +84,6 @@ shape:
## Notes
- Workers open PRs but **never merge** — orchestrator or Merge Guard handles step 3+
- Docker Swarm image pinning: if `-p` doesn't pull a new image, SSH to the Docker node (e.g. `node-01`) and run `docker pull <image>` manually, then redeploy
- Docker Swarm image pinning: `-p` does not change a digest-pinned image. Follow the stack README's documented deployment procedure.
- Worktrees: all coding work in `~/src/<repo>-worktrees/<task-slug>`, never in main checkout
- Always clean up worktree after push: `git worktree remove ~/src/<repo>-worktrees/<task-slug>`
@@ -1,93 +1,8 @@
---
name: mosaic-gitea
description: Interact with Gitea repositories on git.mosaicstack.dev — create/merge/close PRs, manage issues, milestones, and CI queue waits. Use when working with Mosaic Stack git repos. Wraps scripts in ~/.config/mosaic/tools/git/. Works on both Gitea and GitHub (auto-detected via detect-platform.sh).
description: Use when an older workflow requests the Mosaic Gitea wrapper reference.
---
# mosaic-gitea
# Git wrapper compatibility
Git operations via Mosaic wrapper scripts. Platform-aware (Gitea or GitHub).
## Setup
Scripts auto-detect platform from git remote. Run from inside the repo directory.
Credentials come from the framework credentials loader (never from a shared env
file):
```bash
source ~/.config/mosaic/tools/_lib/credentials.sh
load_credentials gitea-mosaicstack
# Exports: GITEA_TOKEN, GITEA_URL
```
## Script Reference
All scripts in `~/.config/mosaic/tools/git/`.
### Pull Requests
| Script | Purpose | Key flags |
| ---------------- | ----------------------- | -------------------------------------------------------- |
| `pr-create.sh` | Create a PR | `-t "Title" [-b "Body"] [-B base] [-H head] [-i issue#]` |
| `pr-list.sh` | List open PRs | `[-s state]` |
| `pr-view.sh` | View PR details | `-n <pr#>` |
| `pr-merge.sh` | Squash-merge a PR | `-n <pr#> [-d]` (delete branch) |
| `pr-close.sh` | Close a PR | `-n <pr#>` |
| `pr-diff.sh` | Show PR diff | `-n <pr#>` |
| `pr-review.sh` | Submit a review | `-n <pr#> [-a approve\|request-changes]` |
| `pr-ci-wait.sh` | Wait for CI on PR | `-n <pr#>` |
| `pr-metadata.sh` | Get PR metadata as JSON | `-n <pr#>` |
### Issues
| Script | Purpose | Key flags |
| ------------------ | --------------- | --------------------------------------------------- |
| `issue-create.sh` | Create an issue | `-t "Title" [-b "Body"] [-l labels] [-m milestone]` |
| `issue-list.sh` | List issues | `[-s state] [-l label]` |
| `issue-view.sh` | View issue | `-n <issue#>` |
| `issue-close.sh` | Close issue | `-n <issue#>` |
| `issue-comment.sh` | Add comment | `-n <issue#> -c "Comment"` |
| `issue-assign.sh` | Assign issue | `-n <issue#> -u username` |
| `issue-edit.sh` | Edit issue | `-n <issue#> [-t title] [-b body]` |
### Milestones
| Script | Purpose | Key flags |
| --------------------- | ---------------- | ---------------------------- |
| `milestone-create.sh` | Create milestone | `-t "Title" [-d "due date"]` |
| `milestone-list.sh` | List milestones | — |
| `milestone-close.sh` | Close milestone | `-n <number>` |
### CI / Queue
| Script | Purpose | Key flags |
| -------------------- | ---------------------- | --------------------------- |
| `ci-queue-wait.sh` | Wait for CI queue slot | `[-t timeout] [-B branch]` |
| `detect-platform.sh` | Detect git platform | outputs `gitea` or `github` |
## Common Workflows
**Create PR from current branch:**
```bash
cd ~/src/<repo>
~/.config/mosaic/tools/git/pr-create.sh -t "feat: my feature" -b "Description" -i <issue#>
```
**Merge a PR (squash, with CI queue guard):**
```bash
cd ~/src/<repo>
~/.config/mosaic/tools/git/pr-merge.sh -n <pr#> -d
```
Branch protection is a gate, not an obstacle: if it blocks a merge, fix the cause —
a failing check, a moved head, or a missing review. Never bypass it with a raw
API call, a shared credential, or `force_merge`. Exceptional cases go to the
operator or the coordinating seat, still merged through the wrapper.
## Notes
- Mosaic policy: squash merges only, targeting `main`
- Gitea SSH: `[email protected]` → resolves via `~/.ssh/config` to the Gitea host's SSH port (e.g. `gitea.example.internal:2222`)
- Workers push branches and open PRs but **never merge** — orchestrator handles merges
Use [ms-git-operations](../ms-git-operations/SKILL.md) for the provider-neutral wrapper contract and capability table. Repository policy and provider capabilities determine behavior. For PR waiting use [ms-babysit-pr](../ms-babysit-pr/SKILL.md). Do not keep separate forge-specific write recipes.
@@ -1,15 +1,19 @@
---
name: mosaic-portainer
description: Manage Portainer stacks on the Mosaic infrastructure. Use when asked to list, start, stop, redeploy, or check logs of Docker Swarm stacks via Portainer. Wraps scripts in ~/.config/mosaic/tools/portainer/. Requires load_credentials portainer first.
description: Manage Docker Swarm stacks through Portainer when a Portainer credential is available. Use when asked to list, start, stop, redeploy, or check logs through Portainer.
---
# mosaic-portainer
Manage Portainer stacks via pre-built Mosaic scripts.
Manage Portainer stacks through supplied scripts.
## Decision Gate
Portainer is optional. Use this skill only when the estate holds a Portainer credential. If a stack README documents `docker stack deploy` on the manager, that is the deploy path. Do not propose Portainer otherwise.
## Setup
Always load credentials before running scripts:
After confirming a Portainer credential is available, load it before running scripts:
```bash
source ~/.config/mosaic/tools/_lib/credentials.sh
@@ -33,11 +37,11 @@ All scripts live in `~/.config/mosaic/tools/portainer/`.
## Common Workflows
**Redeploy a stack with fresh images:**
**Redeploy a stack through Portainer:**
```bash
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n mosaic-stack -p
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
```
**Check all stack statuses:**
@@ -51,12 +55,10 @@ source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
```bash
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
~/.config/mosaic/tools/portainer/stack-logs.sh -n mosaic-stack -l 100
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 100
```
## Notes
- Portainer URL: `https://portainer.example.internal:9443`
- Primary Docker host: `node-01`, managed via Portainer agent
- Docker Swarm image updates: `stack-redeploy.sh -p` does NOT guarantee new image pull if digest is pinned; SSH to node and `docker pull` first if needed
- Credentials: `load_credentials portainer` (framework credentials store)
- `stack-redeploy.sh -p` does not override a digest-pinned image. Follow the stack README's documented deployment procedure for pinned images.
- Credentials are loaded through `load_credentials portainer`.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,24 @@
---
name: ms-babysit-pr
description: Use when the user asks to monitor, watch or babysit a pull request through review and CI.
---
# Babysit a PR
Follow ms-full-send's proof and authority gates, ms-git-operations and ms-watch. Monitoring grants no merge authority. Full-stop never merges or schedules.
Handle current events before waiting. Read `pr-status.sh -n NUMBER --json`, pin head/base, then enumerate `pr-review-comments.sh -n NUMBER --json` completely. Verify findings against source, including old and resolved/outdated comments. Fix real in-scope defects, prove changes and refresh independent review on the new head. Use `pr-review-reply.sh --comment-id ID --body-file PATH --expect-head SHA --json` for native replies and `pr-review-resolve.sh --thread-id ID --resolve --expect-head SHA --json` for authorized resolution. PR number and repository targeting are required too. Never substitute a PR-level issue comment for a native reply. Unsupported conversation operations and incomplete lists leave explicit blockers.
T3 linking and notifications are optional transports. When available, register the URL with `link_pull_request`, verify it through `list_thread_pull_requests`, and report failures. They never supply readiness truth.
- Delegated children return URL, head SHA and pending evidence to their parent. They establish no competing watches.
- If `watch_pull_request` supports the forge, handle current events, arm one watch and yield. On wake, reread pr-status. A runtime error is not proof of unsupported forge; report it without creating a second owner.
- If T3 watching is absent or explicitly unsupported, use one bounded ms-watch over `pr-watch-check.sh -n NUMBER --baseline ABS_JSON --json`, with explicit host/repo. Store a private baseline envelope per owner/host/repo/PR. The predicate performs one snapshot and never runs pr-ci-wait or another blocking wait.
- Watch terminal success, failure, cancellation, closure, merge and relevant changes. Preserve retry metadata on rate limits. Never convert a read error to unchanged pending.
- No supported notifier means a watch-delivery blocker. Do not claim a shell polling loop is equivalent delivery.
Wake verdicts are `merged`, `closed_unmerged`, `ready_to_merge`, `waiting`, `blocked` or `timed_out`. Failed CI is blocked. Unknown required evidence blocks readiness. Full-stop hands off a ready candidate; full-send may merge only with explicit role authority. Both paths use the same snapshot and exit/error mapping.
Call `unwatch_pull_request` or stop the fallback watch before final handoff. Report URL, head/base/merge SHAs, checks, review verdict and SHA, thread completeness, integration state, blockers, authority and watch ownership. Scheduling success is not a terminal merge result.
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/babysit-pr) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,19 @@
---
name: ms-council
description: Use when the user asks for a council or an independent panel of reviews on one candidate.
---
# Council
Collect independent reviews of one exact candidate. Follow ms-full-send's identity, proof, storage and repository boundaries. Council grants no merge authority.
1. Pin candidate SHA, scope, acceptance criteria and raw test evidence. Resolve known defects first unless the requested review is about replacing the design.
2. Use the requested membership, subject to available policy and budget. Otherwise choose independent capable reviewers from the live model catalog. Do not hardcode coding/review roles by provider. Report unavailable models and explicit policy fallbacks.
3. In T3, use orchestrator_capabilities for live provider/model IDs and option descriptors. Delegate a separate read-only task per member, with explicit target options and retained taskId/clientRequestId. Elsewhere use supported delegation with equivalent scope. Do not create top-level threads for child reviews. Children neither mutate nor watch the candidate.
4. Give each member the same evidence without other members' findings. Require paths, severity, confidence, reproducible findings, verdict, reviewed SHA and optional score. Scores never replace approval.
5. Reproduce findings, fix accepted defects, explain rejected suggestions and preserve dissent. Changed candidates invalidate previous exact-head approval. Each new T3 round is a fresh delegate_task including original brief, prior findings/responses and unresolved objections, with a new stable round key.
6. Through pr-edit.sh, record reviewed SHA, verdicts, findings, dissent, round count and optional scores while preserving existing PR content. Without a PR return the same evidence to the coordinator. Missing scores remain missing; do not invent them. A co-author is not independent.
Follow the repository's review gates and authority assignments. Budget/access exhaustion leaves an explicit incomplete verdict, never an approval.
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/council) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,23 @@
---
name: ms-full-send
description: Use when the user explicitly asks for full send or unattended end-to-end delivery.
---
# Full send
Own the requested change through the repository's declared completion gates. Invocation authorizes routine delivery within scope. It never waives independent review, hooks, protected operations or the assigned merge role. Repository instructions and the operator's authority boundary apply.
Use ms-git-operations for every forge action. Inspect existing worktrees and the declared integration branch first. Preserve other authors' work. Use the acting identity's own credential. Keep generated data on operator-approved disk-backed storage and set TMPDIR, TMP and TEMP before tools that create temporary files. Never force-push or bypass hooks.
1. Finish the scoped change and prove its behavior. Record baseline and candidate SHAs, test commands and actual exit codes. Follow the repository's delivery and review guides when present.
2. Obtain independent review on the exact candidate SHA. Select available models according to applicable policy, without hardcoding a provider/model division of labor. A co-author cannot approve the work. Use ms-council when a panel is requested. Changed candidates require fresh review evidence.
3. Run `ci-queue-wait.sh --purpose push` before an ordinary task-branch push. Create or edit the PR through wrappers, with explicit head/base and concise verification evidence.
4. Follow ms-babysit-pr for checks, findings and waiting. Require `pr-status.sh -n NUMBER --expect-head SHA --json` to return a complete, ready snapshot. Enumerate inline findings through `pr-review-comments.sh`; use native reply and resolution wrappers with exact-head pins. Unsupported operations leave explicit missing evidence, never fabricated issue-comment replies.
5. Revalidate the current intended base, independent review SHA, required checks, thread completeness and mergeability immediately before integration. Resolve the declared merge method; default remains squash when no merge policy is declared. `--method` requests a method and cannot override repository policy. Use `pr-merge.sh -n NUMBER --expect-head SHA --json`, within assigned merge authority. Workers return the candidate to the coordinator when the coordinator owns integration.
6. Verify merged SHA and required post-merge CI, then close tracking only when authorized. Merge, deployment and product acceptance are distinct states. Report remaining gates explicitly.
Auto-merge and queue enabling can merge immediately. They require explicit merge authority, declared method resolution and verified server-side protection against stale approvals. They are off by default. Never use scheduling to bypass final-head review or failed gates.
Report URL, head/base/merge SHAs, checks, reviewer verdict and reviewed SHA, thread completeness, auto/queue state, blockers, authority mode and watch ownership. A high review score, scheduling success or an open PR is not delivery completion. Retire any owned watch before final handoff.
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/full-send) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,18 @@
---
name: ms-full-stop
description: Use when the user says full stop or asks for a reviewed PR ready for them to merge.
---
# Full stop
Follow ms-full-send's preparation, implementation, proof, independent review and ms-babysit-pr workflow. These handoff limits override integration. Never merge, enable auto-merge, enqueue a PR, push the integration/default branch, or delegate any of those actions. Only scoped task-branch commits and ordinary pushes are authorized.
Before editing an existing PR, before every push, and at handoff, inspect `pr-auto-merge.sh -n NUMBER --action inspect --json` and `pr-merge-queue.sh -n NUMBER --action inspect --json`. Establish disabled/absent states from verified readback. An unsupported inspection of a scheduler with raw scheduling support is unknown safety evidence, never proof it is disabled. Unknown, access-denied and unsupported inspection block dependent work identically inside and outside T3. For a new branch without a PR, record that no PR exists and inspect as soon as one is created.
Disable/dequeue only when explicit authority permits cancellation; re-read afterward. Otherwise report the blocker to the operator/coordinator. Inspection and cancellation never authorize enabling. Direct-commit repositories receive a prepared diff and evidence within the declared handoff boundary.
At handoff, fetch the intended base and reread `pr-status.sh -n NUMBER --expect-head SHA --json`. Require independent review on that exact SHA, complete required checks and conversations, open/non-draft state and clean mergeability. Report `ready_to_merge`, `waiting`, `blocked`, `closed_unmerged`, `merged` or `timed_out` from measured evidence. `ready_to_merge` means handoff, never permission to merge. A merge performed by someone else is reported as observed, not performed.
Retire/unwatch before handoff. Include URL or prepared diff, head/base/merge SHAs, checks, reviewer verdict and SHA, thread completeness, auto/queue states, blockers, authority mode and watch ownership. Readiness is not delivery completion.
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/full-stop) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
@@ -0,0 +1,33 @@
---
name: ms-git-operations
description: Use for git-host operations, pull requests, issues, milestones, reviews and CI queue guards.
---
# Git operations
Use installed `tools/git/` wrappers for provider operations. They own API dialects and identity; skills own workflow authority. Never bypass wrappers, hooks, repository protection or exact-head gates. Use the acting identity's own credential without persisting it in shared git config. Missing credentials fail closed. Read wrapper help; do not invent flags.
Authenticated normalized adapters require explicit `MOSAIC_GIT_IDENTITY` and a host-bound token for every provider. Missing identity refuses before requests; mutations verify the authenticated actor. Shared token variables, git config identity and inherited provider logins grant no authority. See the wrapper reference for credential provisioning.
New PR commands accept `-n NUMBER --host HOST --repo OWNER/REPO --json`, or infer an unambiguous origin. Review mutations require `--expect-head FULL_SHA`. Python helper calls use import isolation. Preserve legacy wrapper codes unless using the versioned JSON contract. See [wrapper reference](../../tools/git/README.md) for full schemas and permissions.
| Operation | Wrapper | GitHub | Qualified Gitea/Forgejo |
| ------------------------------ | ----------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------- |
| Capabilities | forge-capabilities.sh | supported | versioned profiles |
| Snapshot / predicate | pr-status.sh / pr-watch-check.sh | supported | partial fields explicit |
| Inline enumeration | pr-review-comments.sh | supported | supported |
| Native reply / resolution | pr-review-reply.sh / pr-review-resolve.sh | supported | Gitea 28.2 supported; older inspected profiles unsupported |
| Merge methods | pr-merge.sh --json | merge, squash, rebase | also rebase-merge and fast-forward-only |
| Auto-merge / queue | pr-auto-merge.sh / pr-merge-queue.sh | conditional on settings and verified protection | unsupported without documented state readback |
| Create / edit / review / close | existing pr wrappers | retained | retained API-compatible dispatch |
| CI guard / wait | ci-queue-wait.sh / pr-ci-wait.sh | retained separate contracts | retained separate contracts |
Envelope v1 contains schema_version, operation, provider, host, repo, number, head_sha, data and error. Exit codes: 0 verified result, 1 unchanged pending predicate, 2 usage, 3 POLICY-REFUSED or gate failure, 4 unsupported method/operation or forge setting, 5 access/auth, 6 malformed/incomplete/unverified, 75 transient/unknown outcome, 124 timeout. Error contains kind, message, retry_after_seconds and write_outcome. A 401/403/404 is not capability evidence. Never blindly retry an ambiguous mutation.
Merge policy is read from the trusted target/base commit pinned by the provider, never from the PR head or uncommitted checkout. Fetch that revision first; missing objects refuse. Head and base ref, SHA and repository must remain unchanged through policy, readiness and the final mutation check.
Merge policy lives in optional schema-v3 repo.json `merge`, validated through repo-decl.sh and its shared validator. allowed*methods is a non-empty subset of squash, merge, rebase, rebase-merge, fast-forward-only; default_method must belong to it. Optional shapes map task, promotion and absorb to allowed methods. No policy means squash only. An explicit method must be allowed and match any shape mapping. Forge allow*\* settings are intersected afterward; never change them. Absorb requires an explicit shape mapping. Task requests cannot invent integration authority.
Inspect auto-merge/queue separately from enabling/cancellation. Enable/enqueue can merge immediately and require explicit merge authority plus server-enforced checks, reviews and stale-approval dismissal. Full-stop never enables. Unknown schedule state blocks full-stop even when raw scheduling endpoints exist.
Use ms-babysit-pr and ms-watch for waiting. The CI queue guard controls push/merge admission, not a provider PR merge queue. Run `ci-queue-wait.sh --purpose push` before ordinary push. Required negative or unknown evidence closes the gate.
@@ -0,0 +1,16 @@
---
name: ms-watch
description: Use before creating a watch or arranging a later check.
---
# Watch
Use an existing owner/notifier first. If one command answers now, run it. Durable recurring work needs a scheduler; this bounded watch is for a wait owned by the current agent.
For PRs use ms-babysit-pr. Prefer a supported T3 watch. Otherwise verify an operator-provisioned notifier such as `tools/agent-watch/agent-watch.sh` with a unique owner-scoped name, absolute condition paths, interval at least ten seconds and a finite timeout. The notifier is an optional runtime prerequisite, not a bundled guarantee. If it is absent or cannot deliver to this agent, report a watch-delivery blocker. Do not create a polling loop in the agent turn.
The condition returns 0 for a relevant event/terminal result, 1 for unchanged pending, and any other code for a broken read. Preserve errors and retry metadata. Never mask errors with grep/test, watch only for green, or call a blocking CI wait inside the predicate.
Use `pr-watch-check.sh -n NUMBER --repo OWNER/REPO --host HOST --baseline ABS_JSON --json`. Initialize the baseline from a successful pr-status envelope in private disk-backed storage. Keep it separate per owner/host/repo/PR. Verify the control case returns 1 before arming. Watch timeout maps to timed_out, provider errors to blocked, and every wake rereads status. A watch notification is not readiness evidence.
Arm exactly one watch, yield, and retire it on final handoff. No competing child watches. Do not retry ambiguous writes, and never use raw API or credential recipes in predicates.
@@ -24,6 +24,14 @@
# $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
#
# Gitea has one additional identity-aware path. When the resolved git identity
# names a fleet seat, gitea-mosaicstack and gitea-usc obtain the token through
# tools/git/git-credential-mosaic rather than reading a slot directly. That
# production entrypoint enforces the clean-environment and process-ancestry
# fence before it reads a seat slot. A seat-slot miss is terminal: this loader
# never substitutes the service-store token for it. URLs remain provider
# configuration and continue to come from this loader's service store.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
@@ -94,6 +102,85 @@ _mosaic_load_woodpecker_legacy() {
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
}
_mosaic_resolve_git_identity() {
local ident="${MOSAIC_GIT_IDENTITY:-}"
if [[ -z "$ident" ]]; then
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
fi
printf '%s' "$ident"
}
_mosaic_git_identity_is_seat() {
local ident="$1" brain_home
[[ -n "$ident" ]] || return 1
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
[[ -d "$brain_home/fleet/agents/$ident" ]]
}
_mosaic_gitea_seat_token_from_helper() {
# Use the production wrapper, not its Bash implementation. The wrapper
# removes BASH_ENV/function injection before the implementation evaluates
# MOSAIC_AGENT_NAME ancestry, so a loader consumer cannot bypass that fence.
local host="$1" ident="$2" script_dir helper response key value
local username="" password="" username_seen=0 password_seen=0
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
helper="$script_dir/../git/git-credential-mosaic"
if [[ ! -x "$helper" ]]; then
echo "Error: Gitea seat credential helper is unavailable: $helper" >&2
return 1
fi
if ! response="$(printf 'protocol=https\nhost=%s\n\n' "$host" | "$helper" get)"; then
return 1
fi
while IFS='=' read -r key value; do
[[ -n "$key" ]] || continue
case "$key" in
username)
if (( username_seen )); then
echo 'Error: Gitea seat credential helper returned duplicate username fields' >&2
return 1
fi
username="$value"
username_seen=1
;;
password)
if (( password_seen )); then
echo 'Error: Gitea seat credential helper returned duplicate password fields' >&2
return 1
fi
password="$value"
password_seen=1
;;
*)
echo 'Error: Gitea seat credential helper returned an invalid protocol field' >&2
return 1
;;
esac
done <<< "$response"
if [[ "$username_seen" -ne 1 || "$password_seen" -ne 1 || "$username" != "$ident" || -z "$password" ]]; then
echo "Error: Gitea seat credential helper did not return a valid credential for '$ident'" >&2
return 1
fi
printf '%s' "$password"
}
_mosaic_gitea_token() {
# $1 is the Gitea host and $2 is the legacy service-store jq path.
# Seats are delegated to the fenced helper; all other identities retain the
# existing service-store behavior. A failed seat delegation returns nonzero
# to the caller and deliberately cannot fall through to _mosaic_read_cred.
local host="$1" service_jq_path="$2" ident
ident="$(_mosaic_resolve_git_identity)"
if _mosaic_git_identity_is_seat "$ident"; then
_mosaic_gitea_seat_token_from_helper "$host" "$ident"
return
fi
_mosaic_read_cred "$service_jq_path"
}
load_credentials() {
local service="$1"
@@ -183,16 +270,31 @@ EOF
;;
gitea-mosaicstack)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; }
# An explicit caller value retains the loader's established precedence.
# Otherwise, a known seat delegates to the ancestry-fenced helper and a
# non-seat identity uses the established service-store lookup.
if [[ -z "${GITEA_TOKEN:-}" ]]; then
local _gitea_token
_gitea_token="$(_mosaic_gitea_token 'git.mosaicstack.dev' '.gitea.mosaicstack.token')" || return 1
GITEA_TOKEN="$_gitea_token"
fi
# Preserve the loader's contract even when the caller supplied an
# unexported shell variable before invoking load_credentials.
export GITEA_TOKEN
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; }
;;
gitea-usc)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" ]]; then
local _gitea_token
_gitea_token="$(_mosaic_gitea_token 'git.uscllc.com' '.gitea.usc.token')" || return 1
GITEA_TOKEN="$_gitea_token"
fi
export GITEA_TOKEN
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
;;
woodpecker-*)
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
# Hermetic regression for load_credentials Gitea seat-slot resolution.
#
# It runs against copied framework tools under a fake HOME, fixture credentials,
# and fake seat slots only. No real credential path is read.
#
# Contract pinned here:
# G1-G3 an ancestry-owned seat resolves its own host-scoped token through
# the production git-credential-mosaic entrypoint, whether identity
# comes from env or per-worktree git config.
# G4 an owned seat with no slot fails closed and never uses the service
# token.
# G5 a seat cannot request another seat's slot through load_credentials;
# the helper's ancestry fence remains the authorization boundary.
# G6/G7 no seat identity and a non-seat identity retain service-store
# behavior.
# G8-G10 explicitly supplied GITEA_TOKEN values keep their established
# precedence and are exported for child tool processes.
# G11 non-Gitea services remain unaffected.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/credentials-gitea-seats}"
FAKE_HOME="$WORK_DIR/home"
FRAMEWORK_TOOLS="$FAKE_HOME/.config/mosaic/tools"
BRAIN_DIR="$WORK_DIR/brain"
REPO_DIR="$WORK_DIR/repo"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
SPOOL_DIR="$WORK_DIR/spool"
LOADER="$FRAMEWORK_TOOLS/_lib/credentials.sh"
HELPER="$FRAMEWORK_TOOLS/git/git-credential-mosaic"
rm -rf "$WORK_DIR"
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$FRAMEWORK_TOOLS/_lib" "$FRAMEWORK_TOOLS/git" "$BRAIN_DIR/fleet/agents" \
"$REPO_DIR" "$SPOOL_DIR"
cp "$SCRIPT_DIR/credentials.sh" "$LOADER"
cp "$SCRIPT_DIR/../git/git-credential-mosaic" "$HELPER"
cp "$SCRIPT_DIR/../git/git-credential-mosaic.impl" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
chmod +x "$HELPER" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" config user.name 'Credential seat test'
git -C "$REPO_DIR" config user.email '[email protected]'
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "fixture-service-token"
},
"usc": {
"url": "https://git.uscllc.com",
"token": "fixture-usc-service-token"
}
},
"woodpecker": {
"default": "mosaic",
"mosaic": {
"url": "https://ci.example.invalid",
"token": "fixture-woodpecker-token"
}
}
}
JSON
for seat in seat-owner seat-config seat-usc seat-victim empty-seat; do
mkdir -p "$BRAIN_DIR/fleet/agents/$seat/secrets"
done
printf '%s' 'fixture-owner-slot-token' > "$BRAIN_DIR/fleet/agents/seat-owner/secrets/gitea-mosaicstack-seat-owner.token"
printf '%s' 'fixture-config-slot-token' > "$BRAIN_DIR/fleet/agents/seat-config/secrets/gitea-mosaicstack-seat-config.token"
printf '%s' 'fixture-usc-slot-token' > "$BRAIN_DIR/fleet/agents/seat-usc/secrets/gitea-usc-seat-usc.token"
printf '%s' 'fixture-victim-slot-token' > "$BRAIN_DIR/fleet/agents/seat-victim/secrets/gitea-mosaicstack-seat-victim.token"
chmod 600 "$BRAIN_DIR"/fleet/agents/*/secrets/*.token
# Establishes a seat identity in an exec-frozen ancestor. The empty-name root
# carries the lineage fence, preventing the helper from seeing this suite's
# real parent process outside its hermetic fixture.
cat > "$WORK_DIR/lineage-root.sh" <<'ROOT'
#!/usr/bin/env bash
set -euo pipefail
caller="$1"
carrier="$2"
shift 2
env MOSAIC_AGENT_NAME="$caller" PATH="$PATH" HOME="$HOME" \
bash "$carrier" "$@"
ROOT
cat > "$WORK_DIR/lineage-carrier.sh" <<'CARRIER'
#!/usr/bin/env bash
set -euo pipefail
loader="$1"
brain="$2"
credentials="$3"
repo="$4"
spool="$5"
identity_source="$6"
target="$7"
preexisting_token="$8"
service="${9:-gitea-mosaicstack}"
cd "$repo"
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
git config --unset mosaic.gitIdentity 2>/dev/null || true
case "$identity_source" in
env) export MOSAIC_GIT_IDENTITY="$target" ;;
config) git config mosaic.gitIdentity "$target" ;;
none) ;;
*) echo "unknown identity source: $identity_source" >&2; exit 2 ;;
esac
if [[ "$preexisting_token" != '-' ]]; then
export GITEA_TOKEN="$preexisting_token"
fi
export MOSAIC_BRAIN_HOME="$brain"
export MOSAIC_CREDENTIALS_FILE="$credentials"
export MOSAIC_CREDENTIAL_SPOOL="$spool"
# shellcheck source=/dev/null
source "$loader"
load_credentials "$service"
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
CARRIER
chmod +x "$WORK_DIR/lineage-root.sh" "$WORK_DIR/lineage-carrier.sh"
run_lineage() {
local caller="$1" source="$2" target="$3" preset="$4" service="${5:-gitea-mosaicstack}"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
bash "$WORK_DIR/lineage-root.sh" "$caller" "$WORK_DIR/lineage-carrier.sh" \
"$LOADER" "$BRAIN_DIR" "$CREDENTIALS_FILE" "$REPO_DIR" "$SPOOL_DIR" \
"$source" "$target" "$preset" "$service"
}
run_plain() {
local source="$1" target="$2" preset="$3" service="${4:-gitea-mosaicstack}"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
LOADER="$LOADER" REPO_DIR="$REPO_DIR" IDENTITY_SOURCE="$source" TARGET="$target" \
PRESET="$preset" SERVICE="$service" bash -c '
set -euo pipefail
cd "$REPO_DIR"
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
git config --unset mosaic.gitIdentity 2>/dev/null || true
case "$IDENTITY_SOURCE" in
env) export MOSAIC_GIT_IDENTITY="$TARGET" ;;
config) git config mosaic.gitIdentity "$TARGET" ;;
none) ;;
*) exit 2 ;;
esac
if [[ "$PRESET" != "-" ]]; then export GITEA_TOKEN="$PRESET"; fi
source "$LOADER"
load_credentials "$SERVICE"
printf "url=%s\\ntoken=%s\\n" "$GITEA_URL" "$GITEA_TOKEN"
'
}
run_unexported_token() {
local service="$1"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" LOADER="$LOADER" SERVICE="$service" \
bash -s <<'UNEXPORTED'
set -euo pipefail
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
GITEA_TOKEN='fixture-unexported-token'
source "$LOADER"
load_credentials "$SERVICE"
child_token="$(bash -c 'printf "%s" "${GITEA_TOKEN:-}"')"
[[ "$child_token" == "$GITEA_TOKEN" ]] || {
echo 'GITEA_TOKEN was not exported to a child process' >&2
exit 1
}
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
UNEXPORTED
}
fail=0
assert_success() {
local desc="$1" expected_token="$2" expected_url="$3"
shift 3
local err="$WORK_DIR/stderr.tmp" out rc token url
: > "$err"
set +e
out=$("$@" 2>"$err")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: $desc — expected success, got rc=$rc" >&2
cat "$err" >&2
fail=1
return
fi
token="$(printf '%s\n' "$out" | awk -F= '/^token=/{print substr($0, 7)}')"
url="$(printf '%s\n' "$out" | awk -F= '/^url=/{print substr($0, 5)}')"
if [[ "$token" != "$expected_token" ]]; then
echo "FAIL: $desc — resolved the wrong token source" >&2
fail=1
fi
if [[ "$url" != "$expected_url" ]]; then
echo "FAIL: $desc — URL did not come from provider configuration" >&2
fail=1
fi
}
assert_refused() {
local desc="$1" expected_reason="$2"
shift 2
local err="$WORK_DIR/stderr.tmp" out rc
: > "$err"
set +e
out=$("$@" 2>"$err")
rc=$?
set -e
local diagnostics
diagnostics="$(cat "$err")"
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $desc — expected refusal, got success" >&2
fail=1
fi
if [[ -n "$out" ]]; then
echo "FAIL: $desc — refusal emitted credential output" >&2
fail=1
fi
if [[ "$diagnostics" != *"$expected_reason"* ]]; then
echo "FAIL: $desc — refusal did not retain helper reason $expected_reason" >&2
fail=1
fi
if [[ "$out$diagnostics" == *'fixture-service-token'* || "$out$diagnostics" == *'fixture-victim-slot-token'* ]]; then
echo "FAIL: $desc — refusal exposed or fell back to another store" >&2
fail=1
fi
}
# G1: env identity, owning seat, populated Mosaic slot.
assert_success 'G1 env-owned seat uses its Mosaic slot through the helper' 'fixture-owner-slot-token' 'https://git.mosaicstack.dev' \
run_lineage seat-owner env seat-owner -
# G2: the same contract when the helper and loader resolve git config identity.
assert_success 'G2 config-owned seat uses its Mosaic slot through the helper' 'fixture-config-slot-token' 'https://git.mosaicstack.dev' \
run_lineage seat-config config seat-config -
# G3: the USC arm remains host-scoped rather than borrowing Mosaic credentials.
assert_success 'G3 USC-owned seat uses its USC slot through the helper' 'fixture-usc-slot-token' 'https://git.uscllc.com' \
run_lineage seat-usc env seat-usc - gitea-usc
# G4: a seat slot miss must be terminal, never service-store fallback.
assert_refused 'G4 empty owned seat refuses without service fallback' 'no-token-for-identity' \
run_lineage empty-seat env empty-seat -
# G5: a child cannot select another seat by rewriting MOSAIC_GIT_IDENTITY.
assert_refused 'G5 cross-seat identity is refused by ancestry fencing' 'cross-seat-identity-refused' \
run_lineage seat-owner env seat-victim -
# G6/G7: non-seat paths retain the existing shared service-store behavior.
assert_success 'G6 no identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
run_plain none '' -
assert_success 'G7 non-seat identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
run_plain env service-automation -
# G8: caller-provided env values retain the established loader precedence.
assert_success 'G8 explicit GITEA_TOKEN remains caller-owned' 'fixture-preexisting-token' 'https://git.mosaicstack.dev' \
run_lineage seat-owner env seat-owner fixture-preexisting-token
# G9/G10: pre-existing shell variables keep the loader's export contract.
assert_success 'G9 unexported Mosaic token reaches child processes' 'fixture-unexported-token' 'https://git.mosaicstack.dev' \
run_unexported_token gitea-mosaicstack
assert_success 'G10 unexported USC token reaches child processes' 'fixture-unexported-token' 'https://git.uscllc.com' \
run_unexported_token gitea-usc
# G11: only Gitea has seat slots; Woodpecker remains service-scoped.
wp_out=$(env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_GIT_IDENTITY=seat-owner \
LOADER="$LOADER" bash -c '
set -euo pipefail
unset WOODPECKER_URL WOODPECKER_TOKEN
source "$LOADER"
load_credentials woodpecker
printf "%s|%s" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
')
if [[ "$wp_out" != 'https://ci.example.invalid|fixture-woodpecker-token' ]]; then
echo 'FAIL: G11 Woodpecker changed under a Gitea seat identity' >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo 'credentials Gitea seat-store regression passed'
fi
exit "$fail"
@@ -82,6 +82,27 @@ STUB
chmod +x "$FAKE_BIN/tea" "$FAKE_BIN/codex"
export PATH="$FAKE_BIN:$PATH"
# The local bare repositories have no forge authority. Inject a synthetic
# provider only for these owned fixture origins; production detection stays strict.
FIXTURE_DETECTOR="$TMP_DIR/detect-platform.sh"
export PR_FIXTURE_ROOT="$TMP_DIR" DETECT_LOG="$TMP_DIR/detect.log"
: > "$DETECT_LOG"
cat > "$FIXTURE_DETECTOR" <<'STUB'
detect_platform() {
local remote_url
remote_url=$(git remote get-url origin) || return 1
case "$remote_url" in
"$PR_FIXTURE_ROOT"/*/origin.git)
PLATFORM=gitea
export PLATFORM
printf 'fixture-gitea\n' >> "$DETECT_LOG"
printf 'gitea\n'
;;
*) return 1 ;;
esac
}
STUB
# The valid fixture is a fresh clone on the non-main base. The PR head exists only
# at refs/pull/795/head, so local HEAD cannot accidentally satisfy the assertion.
if [[ "${1:-all}" != "fail-closed" ]]; then
@@ -90,6 +111,8 @@ if [[ "${1:-all}" != "fail-closed" ]]; then
cd "$VALID_WORK"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
# shellcheck source=/dev/null
source "$FIXTURE_DETECTOR"
diff_context=$(build_diff_context pr 795)
assert_contains "$diff_context" "actual-pr-change"
@@ -123,7 +146,7 @@ if [[ "${1:-all}" != "pr-head" ]]; then
cp "$SCRIPT_DIR/schemas/code-review-schema.json" \
"$SCRIPT_DIR/schemas/security-review-schema.json" \
"$SANDBOX/tools/codex/schemas/"
cp "$SCRIPT_DIR/../git/detect-platform.sh" "$SANDBOX/tools/git/"
cp "$FIXTURE_DETECTOR" "$SANDBOX/tools/git/detect-platform.sh"
cat > "$SANDBOX/tools/git/pr-review.sh" <<'STUB'
#!/bin/bash
@@ -156,3 +179,9 @@ STUB
echo "ok - empty ${review_kind} PR diff fails closed before Codex and auto-post"
done
fi
expected_detections=3
[[ "${1:-all}" == "pr-head" ]] && expected_detections=1
[[ "${1:-all}" == "fail-closed" ]] && expected_detections=2
[[ "$(wc -l < "$DETECT_LOG")" -eq "$expected_detections" ]] || \
fail "synthetic provider seam was not used by every fixture path"
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+209 -3
View File
@@ -21,7 +21,11 @@ A successful provider write command—or a wrapper message based only on that co
## Credential handling
The Gitea API token is **never passed on a curl command line.** An `Authorization: token <value>` argument would be visible to any local process that can read the process table (`ps` / `/proc/<pid>/cmdline`) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-`0600` config file under `$TMPDIR` and passes it with `curl --config <file>` (`gitea_write_auth_config`), so only the file _path_ — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's `RETURN` trap.
Credential resolution and the complete legacy request workflow run in a private
subprocess with xtrace disabled. Tokens stay out of caller traces and process
arguments. `gitea_curl_with_token` supplies an anonymous config descriptor to curl;
request bodies retain stdin. The normalized adapter uses the same anonymous
transport contract. No named authentication scratch files are created.
## `tea` invocation notes (Gitea)
@@ -44,8 +48,8 @@ account/token configured through `tools/_lib/credentials.sh`. That means every a
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
separation between an author and a reviewer.
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
identity**:
`git-credential-mosaic`, `get_gitea_token()`, and the `gitea-mosaicstack` /
`gitea-usc` arms of `load_credentials` resolve an optional **per-agent identity**:
1. `MOSAIC_GIT_IDENTITY` environment variable, or
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
@@ -65,6 +69,12 @@ The store is chosen by what the identity **is**, not by which file happens to ex
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
`packages/mosaic/src/fleet/brain-home.ts` performs.
The Gitea arms of `load_credentials` obtain a seat token through the production
`git-credential-mosaic` entrypoint, rather than reading the slot directly. That retains
the entrypoint's clean-environment and process-ancestry fence. The Gitea URL remains
provider configuration from the service store. A caller-supplied `GITEA_TOKEN` retains
its established environment precedence.
**There is no precedence between the two stores and no fallback from one to the other.**
A seat whose slot is empty is refused even when a same-named token sits in the framework
store. One credential lives in exactly one location: a second copy is drift rather than
@@ -194,3 +204,199 @@ Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a
counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a
separate, larger design (mapping identities to `tea login` profiles) and is out of scope
here.
## Versioned PR contract
The provider-neutral entry points below accept `--repo OWNER/REPO --host HOST`, or
infer an unambiguous origin. PR operations require `-n NUMBER`. Use `--host`, not
`-H` (head in older wrappers). `--json` opts existing merge/CI-wait callers into
version 1; new commands always emit one JSON envelope. Diagnostics use stderr.
| Command | Additional arguments |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| forge-capabilities.sh | `--operation status\|review_comments\|review_reply\|review_resolve\|merge\|rebase_merge\|fast_forward_only\|auto_merge\|merge_queue` |
| pr-status.sh | `--expect-head FULL_SHA` optional; one bounded snapshot |
| pr-watch-check.sh | `--baseline ABSOLUTE_ENVELOPE_JSON`; one snapshot, no internal wait |
| pr-review-comments.sh | Complete paginated reviews and inline comments |
| pr-review-reply.sh | `--comment-id OPAQUE_ID --body-file PATH --expect-head FULL_SHA` |
| pr-review-resolve.sh | `--thread-id OPAQUE_ID --resolve\|--unresolve --expect-head FULL_SHA` |
| pr-merge.sh | `--json --expect-head FULL_SHA [-m METHOD]` |
| pr-auto-merge.sh | `--action inspect\|enable\|disable [--method METHOD]`; mutations require expected head |
| pr-merge-queue.sh | `--action inspect\|enqueue\|dequeue [--method METHOD]`; mutations require expected head |
| pr-ci-wait.sh | `[-t SECONDS] [-i SECONDS] [--expect-head FULL_SHA] [--json]` |
Methods are `squash`, `merge`, `rebase`, `rebase-merge`, `fast-forward-only`.
GitHub supports only the first three. Never request force-merge or manually-merged.
Normalized merge requires the local origin/declaration context, even with an explicit
host/repository, and preserves queue, branch, independent-review and exact-head gates.
Legacy squash mode retains its existing flags/output/codes. Explicit non-squash methods
use the verified adapter. Message-trailer and branch-deletion flags belong to legacy mode;
the normalized adapter refuses unknown flags rather than silently dropping them.
The envelope schema is [pr-status.schema.json](pr-status.schema.json). Fields are
`schema_version`, `operation`, `provider`, `host`, `repo`, `number`, `head_sha`, `data`
and nullable `error`. Errors contain `kind`, `message`, nullable
`retry_after_seconds`, and `write_outcome` (`none`, `confirmed`, `unknown`). No blind
write retries. Reconcile an unknown outcome against the exact returned ID/state.
| Code | Version 1 meaning |
| ---- | ----------------------------------------------------------------------- |
| 0 | Successful read or verified mutation; inspect ready/blockers separately |
| 1 | Watch predicate unchanged and pending only |
| 2 | Usage error |
| 3 | Gate failure or POLICY-REFUSED (`error.kind=policy`) |
| 4 | Unsupported API/method or forge allow\_\* setting disabled |
| 5 | Access/auth, including hidden-resource 404; never capability evidence |
| 6 | Malformed, incomplete, unknown capability or failed verification |
| 75 | Transient/rate limit, moved head or unknown transport outcome |
| 124 | Bounded wait timeout |
CI wait consumes normalized checks. Default mode maps gate/provider/read failures to
legacy exit 1, usage to 2 and timeout to 124. `--json` retains version-1 error codes.
Missing checks never become green through repeated empty polls. The CI admission guard
and push guard retain their legacy codes; CI admission is separate from a PR merge queue.
Status includes head/base refs, SHAs and repositories; closure/merge SHA; draft and
mergeability; checks with required/observed/complete; independent current-head reviews;
thread completeness; integration states; capabilities; ready/blockers; observation time
and canonical fingerprint. Head/base/closure are reread after aggregation. Incomplete
reads return nonzero with ready=false. Nullable fields remain unknown, never default false.
Fingerprint excludes observation time and includes review/comment and integration evidence.
The observed snapshot is not an atomic lock: mutations still pin the head at the provider.
Required branch policy must be readable. Empty check history is not a declared no-CI policy.
App-bound GitHub required checks currently remain unknown rather than trusting a same-name
check from another app. Code-owner review additionally requires the provider decision.
Unsupported branch-pattern policy/readback leaves missing evidence and blocks readiness.
## Qualified provider profiles and permissions
GitHub uses documented REST and GraphQL APIs. `GH_HOST` explicitly selects a configured
enterprise host. Other hosts must identify Gitea/Forgejo through public swagger metadata;
unfamiliar hosts are never guessed to be Gitea. Qualified profiles are Gitea 1.24.7 and
28.2.0, and the inspected Forgejo 16.0.0 development schema. Unknown versions expose unknown
capabilities until qualified. Legacy dispatch reports `gitea` for the compatible Forgejo API.
All profiles list every review and review-comment page. GitHub also paginates nested
reviewThreads/comments. Authoritative IDs remain provider-qualified. Gitea lists do not
invent a thread graph: thread_id/reply_to are null without evidence. For Gitea 28.2 resolution,
pass the listed `gitea:comment:ID` as the resolution selector; this names the API's review
comment, not a fabricated thread relationship. Resolved and outdated comments remain listed.
| Operation | GitHub | Gitea 28.2 | Gitea 1.24.7 / inspected Forgejo |
| --------------------------------- | ----------- | ---------------------- | -------------------------------- |
| Snapshot / inline enumeration | supported | partial state explicit | partial state explicit |
| Native reply / resolution | supported | supported | exit 4 before mutation |
| Merge/squash/rebase | supported | supported | supported |
| Rebase-merge / fast-forward-only | exit 4 | supported | supported |
| Verified auto-merge / merge queue | conditional | exit 4 | exit 4 |
| Repository policy refuses method | exit 3 | exit 3 | exit 3 |
| Forge setting disables method | exit 4 | exit 4 | exit 4 |
Public schemas inspected: Gitea swagger.v1.json; GitHub
[REST pulls](https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request) and
[GraphQL pull operations](https://docs.github.com/en/graphql/reference/pulls).
Gitea/Forgejo expose raw deferred-merge/cancellation endpoints, but the inspected schemas
provide no schedule-state readback. These wrappers therefore inspect/schedule/cancel as
unsupported without making a mutation. Deferred merge is not a native merge queue.
Reads require PR/review/check and branch-protection visibility. Replies/resolution require
conversation write permission. Merge and integration controls require repository merge
permission and applicable product settings. 401/403 is access failure; documented rate-limit
headers yield 75 and a retry delay. Neither permissions nor feature settings are modified.
Enable/enqueue require explicit authority, declared method policy, repository allow\_\* settings,
server-enforced checks and independent reviews, stale-approval dismissal and no admin bypass.
Queue configuration must agree with the policy method. Inspect/disable/dequeue are separate
operations and do not require merge policy. Every mutation requires expected head/readback;
scheduling may merge immediately and returns observed merged state rather than queue success.
## Repository merge policy (schema version 3)
Schema 1/2 remain readable. Schema 3 adds optional `merge`, with strict key/value validation
through the shared declaration validator and repo-decl helper. Consumers apply the existing
branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash.
```json
{
"schema_version": 3,
"integration_trunk": "integration",
"release_branch": "stable",
"flow": "trunk-release",
"canonical_remote": "https://git.example.test/team/project",
"canonical_clone": "host:/src/project",
"merge": {
"allowed_methods": ["squash", "merge"],
"default_method": "squash",
"shapes": { "task": "squash", "promotion": "merge", "absorb": "merge" }
}
}
```
`allowed_methods` is non-empty and unique; default and shape methods must belong to it.
Unknown keys/shape names fail validation. Promotion is declared trunk→release; absorb is
release→trunk and requires its explicit shape mapping; other allowed contributions are task.
Other targets still fail transition validation. Method requests cannot override a shape mapping.
Invalid declarations never fall back to a wider merge policy.
## Watch transport parity and release
T3 linking/watching is optional notification transport. Read normalized status after every wake.
Fallback watching uses pr-watch-check with one owner-scoped private baseline and a supported
notifier, never pr-ci-wait in a predicate. Unknown schedule state blocks full-stop in both paths.
Children return pending evidence instead of owning watches. Retire watches at handoff.
Run `test-pr-watch-parity.sh`, `test-forge-contract.py`, `test-forge-cli.py`,
`test-merge-policy.py`, `test-pr-skill-contract.py` and all legacy test-\*.sh suites.
Fixtures deny unexpected network calls. Parity tests mock notification transports; they do not
claim live notifier delivery, authenticated provider writes or T3 forge support.
The package manifest includes framework assets. Release/install the reviewed package before
operator-owned tool/skill synchronization. Verify deployed files by hash against that release;
an upgrade alone does not prove owned copies were synchronized. No local rollout is performed
by these tests or by promoting the skill sources.
Set absolute `TMPDIR` to approved scratch storage for response and policy files.
The credential transport contract is described below. Fallback notification requires
an operator-provisioned notifier; its absence is an explicit watch-delivery blocker.
This change packages the status/predicate and skills and does not install a notifier.
### Trusted policy and authentication
The normalized merge and scheduling adapters read `.mosaic/repo.json` from the
PR target's pinned base commit, using the matching local origin's object store.
Fetch the target revision before invoking a mutation. Missing objects refuse;
head commits and uncommitted declarations cannot grant merge authority. Full
head and base identities (ref, SHA, repository) must remain unchanged through
policy evaluation, readiness aggregation, admission, mutation and readback.
Authenticated adapter reads and writes require explicit `MOSAIC_GIT_IDENTITY`.
A mutation first verifies `/user` matches that identity. Inherited client login
sessions and shared token environment variables grant no authority. Existing
provider-specific seat/service token stores remain supported. Other hosts,
including GitHub, use `<brain>/credentials/forge/<host>/<identity>.token`, where
`MOSAIC_BRAIN_HOME` selects `<brain>` (default `~/.mosaic`). Provision the
selected actor's token separately. Authorization reaches curl through an
anonymous pipe; request JSON uses stdin. Tokens never enter named scratch files.
Legacy shell wrappers keep credential resolution and the complete request workflow
in a private subprocess with tracing disabled. Caller tracing never receives a
secret variable; each request consumes authorization through an anonymous descriptor.
The skill firewall accepts optional newline-separated forbidden patterns via
`MOSAIC_OPERATOR_FORBIDDEN_PATTERNS`; its default is empty. Deployment-specific
patterns belong in operator configuration, outside the framework source.
Public GitHub REST requests use `https://api.github.com`, and GraphQL uses
`https://api.github.com/graphql`. Enterprise REST requests use
`https://<host>/api/v3`, and GraphQL uses `https://<host>/api/graphql`.
The normalized adapter disables default curl configuration as its first curl
option. It follows at most three GET redirects (301, 302, 303, 307, 308), resolving
relative or absolute targets only within the original HTTPS API origin, including
its effective port. Missing or ambiguous Location headers, invalid targets,
userinfo, origin changes, downgrade, cycles and exhausted bounds refuse before a
next request. Non-GET requests, including mutations, are never redirected or replayed.
Authorization remains in anonymous descriptors throughout the read chain.
Credentials remain bound to the selected forge host, such as `github.com`,
while the adapter selects its documented API authority. CLI fixtures match
complete request URLs, including scheme, authority, path and query.
@@ -7,6 +7,14 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
# All workflow state belongs to this one private subprocess.
# shellcheck disable=SC2030,SC2031
mosaic_private_forge_main() (
set +x
BRANCH=""
TARGET_REPO=""
HEAD_SHA=""
@@ -343,7 +351,7 @@ gitea_get_branch_head_sha() {
# error. A not-yet-pushed feature branch has no in-flight pipeline, so the
# pre-push queue guard must treat 404 as "queue clear", not crash.
local resp code body
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url")
resp=$(gitea_curl_with_token "$token" -sS -H "User-Agent: curl/8" -w $'\n%{http_code}' "$url")
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
if [[ "$code" == "404" ]]; then
@@ -367,7 +375,7 @@ gitea_get_commit_status_json() {
local sha="$3"
local token="$4"
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
gitea_curl_with_token "$token" -fsSL -H "User-Agent: curl/8" "$url"
}
# Repository-admin state for the acting credential, Gitea flavor. The guard's
@@ -382,7 +390,7 @@ gitea_repo_admin_state() {
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
local resp code body
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2
resp=$(gitea_curl_with_token "$token" -sS -H "User-Agent: curl/8" -w $'\n%{http_code}' "$url") || return 2
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
if [[ "$code" != "200" ]]; then
@@ -473,6 +481,12 @@ if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then
exit 1
fi
if [[ "$NO_CI_EXPECTED" -eq 1 && -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then
record_assertion_event 'ASSERTION_UNATTRIBUTABLE' 'actor-unattributable' 'unknown' || echo 'Warning: attribution audit unavailable.' >&2
echo "Error: ASSERTION_UNATTRIBUTABLE state=unknown purpose=${PURPOSE} asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; explicit MOSAIC_GIT_IDENTITY is required (exit 78)." >&2
exit 78
fi
OWNER="unknown"
REPO="unknown"
PLATFORM="unknown"
@@ -514,7 +528,7 @@ if git rev-parse --show-toplevel >/dev/null 2>&1 \
repo_decl_load
if [[ "$DECL_STATE" == invalid ]]; then
repo_decl_report_invalid
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then
route="feature"
if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then
route="trunk (integration head)"
@@ -676,3 +690,6 @@ while true; do
;;
esac
done
)
mosaic_private_forge_main "$@"
@@ -15,22 +15,30 @@ detect_platform() {
return 1
fi
# Check for GitHub
if [[ "$remote_url" == *"github.com"* ]]; then
PLATFORM="github"
export PLATFORM
echo "github"
return 0
# Parse the authority, rather than matching github.com anywhere in a URL.
local host profile rc
host=$(get_remote_host) || return 1
if [[ "$host" == github.com || ( -n "${GH_HOST:-}" && "$host" == "$GH_HOST" ) ]]; then
PLATFORM=github; export PLATFORM; echo github; return 0
fi
# Check for common Gitea indicators
# Gitea URLs typically don't contain github.com, gitlab.com, bitbucket.org
if [[ "$remote_url" != *"gitlab.com"* ]] && \
[[ "$remote_url" != *"bitbucket.org"* ]]; then
# Assume Gitea for self-hosted repos
PLATFORM="gitea"
export PLATFORM
echo "gitea"
profile=$(curl --silent --show-error --fail --max-time 10 --url "https://$host/swagger.v1.json") || {
echo "error: forge metadata could not be verified for $host" >&2
return 1
}
rc=$(printf '%s' "$profile" | python3 -I -c '
import json,sys
try:
info=json.load(sys.stdin)["info"]
title=str(info["title"]).lower(); version=str(info["version"])
print("forgejo" if "forgejo" in title or "gitea" in title and "+gitea-" in version else "gitea" if "gitea" in title else "unknown")
except (ValueError, KeyError, TypeError):
print("unknown")
') || return 1
if [[ "$rc" == gitea || "$rc" == forgejo ]]; then
# Legacy scripts dispatch both API-compatible providers as gitea.
FORGE_PROVIDER="$rc"; PLATFORM=gitea
export FORGE_PROVIDER PLATFORM
echo gitea
return 0
fi
@@ -87,7 +95,7 @@ get_repo_slug() {
gitea_url_matches_host() {
local url="${1:-}" host="${2:-}"
[[ -n "$url" && -n "$host" ]] || return 1
python3 - "$url" "$host" <<'PY'
python3 -I - "$url" "$host" <<'PY'
import sys
from urllib.parse import urlparse
@@ -156,7 +164,7 @@ find_tea_login_for_host() {
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY'
TEA_LOGINS_JSON="$logins_json" python3 -I - "$host" <<'PY'
import json
import os
import sys
@@ -226,7 +234,7 @@ tea_login_exists() {
local logins_json
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER'
TEA_LOGINS_JSON="$logins_json" python3 -I - "$login_name" <<'PY_INNER'
import json, os, sys
want = sys.argv[1]
try:
@@ -246,7 +254,7 @@ tea_login_matches_host() {
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" "$host" <<'PY'
TEA_LOGINS_JSON="$logins_json" python3 -I - "$login_name" "$host" <<'PY'
import json
import os
import sys
@@ -281,7 +289,7 @@ print_gitea_login_diagnostic() {
available=$(
command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; }
logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; }
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
TEA_LOGINS_JSON="$logins_json" python3 -I - <<'PY'
import json, os
from urllib.parse import urlparse
try:
@@ -390,7 +398,7 @@ get_gitea_authenticated_user() {
command -v tea >/dev/null 2>&1 || return 1
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
TEA_AUTHENTICATED_USER_JSON="$response" python3 -I - <<'PY'
import json
import os
@@ -412,7 +420,7 @@ get_default_tea_login() {
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
TEA_LOGINS_JSON="$logins_json" python3 -I - <<'PY'
import json
import os
@@ -501,7 +509,7 @@ get_host_from_url() {
local url="${1:-}"
[[ -n "$url" ]] || return 1
python3 - "$url" <<'PY'
python3 -I - "$url" <<'PY'
import sys
from urllib.parse import urlparse
@@ -531,7 +539,7 @@ get_gitea_repo_slug_for_url() {
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
if [[ "$remote_url" =~ ^https?:// ]]; then
python3 - "$remote_url" "$configured_url" <<'PY'
python3 -I - "$remote_url" "$configured_url" <<'PY'
import sys
from urllib.parse import urlparse
@@ -648,160 +656,55 @@ get_gitea_url_for_host() {
}
# Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
get_gitea_token() {
local host="$1"
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local cred_loader="$script_dir/../_lib/credentials.sh"
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
# stored credential for this host, act AS that agent so API tooling
# (pr-create, issue-create, …) authors under the right identity — matching the
# git credential helper, which this block deliberately mirrors.
local _ident="${MOSAIC_GIT_IDENTITY:-}"
local _ident_src="MOSAIC_GIT_IDENTITY"
if [[ -z "$_ident" ]]; then
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
# Explicit identity and host-bound storage only. Disable tracing before handling
# credentials; the subshell preserves the caller's xtrace state.
get_gitea_token() (
set +x
local host="$1" _ident="${MOSAIC_GIT_IDENTITY:-}"
if [[ ! "$_ident" =~ ^[A-Za-z0-9_.-]+$ || ! "$host" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then
echo 'Error: explicit MOSAIC_GIT_IDENTITY and a valid host are required.' >&2
return 1
fi
# Recognized Gitea hosts are the ones carrying the per-identity token scheme.
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
if [[ -n "$_ident" && -n "$_idpfx" ]]; then
# Credential store selection: an identity is a SEAT or a SERVICE, and which
# one it is decides where its credential lives. No precedence between the
# two stores and no fallback from one to the other — a seat with an empty
# slot fails loud rather than reading a service credential of the same name.
# One credential, one location: two copies diverge, and the stale copy fails
# in a way that reads as a revoked token rather than as drift.
local _idtok _ident_kind
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" _idtok
if [[ -n "$_idpfx" ]]; then
if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then
_ident_kind="seat"
_idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token"
else
_ident_kind="service identity"
_idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
fi
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
# but no credential exists for THAT identity. Refuse to fall through to the
# shared/default credential loader below — silently borrowing another identity's
# token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review
# attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop
# instead so the caller aborts loudly rather than acting as the wrong identity.
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2
echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2
else
_idtok="$_brain_home/credentials/forge/$host/$_ident.token"
fi
if [[ ! -r "$_idtok" ]]; then
echo 'Error: selected identity has no readable host-bound credential.' >&2
return 1
fi
cat "$_idtok"
)
# FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist,
# every record must name the agent that made it, so an unattributable request is
# refused rather than handed the shared account. `fleet/agents` existing is the
# same signal brain-home.ts uses to decide a brain is active. A host with no fleet
# keeps the shared path below unchanged: there the shared account is the operator's
# own and there is no attribution to lose.
if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then
echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2
echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2
echo " Set MOSAIC_GIT_IDENTITY=<agent-id> or 'git config mosaic.gitIdentity <agent-id>'." >&2
# Consume a credential inside an untraced shell function. Only an anonymous
# config descriptor reaches the curl process; stdin remains the request body.
# Callers must keep credential resolution and this call in their private boundary.
gitea_curl_with_token() {
set +x
local token="$1" escaped auth_fd request_rc=0
shift
if [[ -z "$token" || ${#token} -gt 4096 || "$token" == *[[:cntrl:]]* ]]; then
echo 'Error: invalid authentication credential.' >&2
return 1
fi
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
if [[ -f "$cred_loader" ]]; then
local token
token=$(
# shellcheck source=/dev/null
source "$cred_loader"
# Host-specific wrapper resolution must not inherit caller/global GITEA_*.
# load_credentials intentionally preserves existing env vars for interactive use,
# but metadata/merge wrappers need credentials matching the remote host.
unset GITEA_TOKEN GITEA_URL
case "$host" in
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
*)
local matched=false
for svc in gitea-mosaicstack gitea-usc; do
unset GITEA_TOKEN GITEA_URL
load_credentials "$svc" 2>/dev/null || continue
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
matched=true
break
fi
done
if [[ "$matched" != true ]]; then
unset GITEA_TOKEN GITEA_URL
fi
;;
esac
echo "${GITEA_TOKEN:-}"
)
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
if [[ -n "${GITEA_TOKEN:-}" ]]; then
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
echo "$GITEA_TOKEN"
return 0
fi
fi
# 3. ~/.git-credentials file
local creds="$HOME/.git-credentials"
if [[ -f "$creds" ]]; then
local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
return 1
}
# Stage the Gitea bearer credential for curl OUTSIDE the process argument vector.
# Passing "-H 'Authorization: token <value>'" on the curl command line exposes
# the token to anyone who can read the process table (ps / /proc/<pid>/cmdline)
# for the lifetime of the request. Instead, write the header into a private
# (mode 0600) curl config file and have callers pass it with `curl --config`, so
# only the FILE PATH — never the token — appears in argv. Prints the temp file
# path on success; the caller OWNS the file and MUST remove it on every exit
# path (success and failure). $1 = bearer token. Callers must not log the token.
gitea_write_auth_config() {
local token="$1" auth_file
auth_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-gitea-auth.XXXXXX") || return 1
# mktemp already creates the file with 0600; be explicit in case of an
# unusual umask so the credential is never briefly group/other readable.
chmod 600 "$auth_file" 2>/dev/null || true
# `header = "..."` is curl's config syntax for an extra request header. Only
# this filename reaches curl's argv; the token stays on disk, readable solely
# by this user, and is unlinked by the caller's trap after the request.
if ! printf 'header = "Authorization: token %s"\n' "$token" > "$auth_file"; then
rm -f "$auth_file"
return 1
fi
printf '%s' "$auth_file"
escaped="${token//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
exec {auth_fd}< <(printf 'header = "Authorization: token %s"\n' "$escaped")
curl --config "/dev/fd/$auth_fd" "$@" || request_rc=$?
exec {auth_fd}<&-
return "$request_rc"
}
# Resolve the API token for a SPECIFIC tea login name from tea's own config
@@ -828,7 +731,7 @@ get_gitea_token_for_login() {
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
[[ -f "$config_file" ]] || return 1
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 -I - "$config_file" <<'PY'
import datetime
import os
import re
@@ -1656,7 +1559,7 @@ get_gitea_basic_auth() {
return 1
fi
python3 - "$host" "$creds" <<'PY'
python3 -I - "$host" "$creds" <<'PY'
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Provision synthetic, actor/host-bound credentials for offline wrapper suites."""
import json
import re
import pathlib
import sys
home, brain, actor, host, token = sys.argv[1:]
root = pathlib.Path(brain)
p = root / 'credentials/forge' / host / (actor + '.token')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(token)
# Existing provider-specific stores derive their prefixes from configured
# service keys. The fixture never imports an operator's credential loader.
credentials = pathlib.Path(home) / '.config/mosaic/credentials.json'
if credentials.is_file():
for service, value in json.loads(credentials.read_text()).get('gitea', {}).items():
if value.get('url', '').removeprefix('https://').rstrip('/') == host:
p = pathlib.Path(home) / '.config/mosaic/secrets/gitea-tokens' / ('gitea-' + service + '-' + actor + '.token')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(token)
# Provider store layout is an inherited compatibility contract. Reuse its
# declared mapping without embedding deployment names in new fixture code.
source=(pathlib.Path(__file__).parent/'detect-platform.sh').read_text()
for mapped_host, prefix in re.findall(r'^\s*([A-Za-z0-9.-]+)\)\s+_idpfx=([A-Za-z0-9-]+)',source,re.MULTILINE):
if mapped_host == host:
p=pathlib.Path(home)/'.config/mosaic/secrets/gitea-tokens'/(prefix+'-'+actor+'.token')
p.parent.mkdir(parents=True,exist_ok=True)
p.write_text(token)
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Public schema fixture; all other network calls fail closed.
if [[ "$*" == *'/swagger.v1.json'* ]]; then
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
exit 0
fi
printf 'Unexpected network request in offline suite\n' >&2
exit 97
@@ -0,0 +1,937 @@
#!/usr/bin/env python3
"""Provider adapters for the versioned forge wrapper contract.
Run with Python -I. No repository modules, provider code or fixture files are imported.
All network operations have finite timeouts and pagination bounds. Writes are never retried.
"""
import argparse
import datetime
import hashlib
import json
import ipaddress
import os
import pathlib
import re
import subprocess
import sys
import time
import tempfile
import urllib.parse
HERE = pathlib.Path(__file__).resolve().parent
LIMIT = 100
MAX_PAGES = 100
class Failure(Exception):
def __init__(self, code, kind, message, retry=None, outcome='none'):
super().__init__(message)
self.code, self.kind, self.retry, self.outcome = code, kind, retry, outcome
REDIRECT_STATUSES = (301, 302, 303, 307, 308)
MAX_REDIRECTS = 3
def redirect_uri(value):
"""Check raw RFC 3986 URI components before parser normalization."""
if (not value or re.search(r"[^A-Za-z0-9._~!$&'()*+;=:%/@?\[\]-]", value)
or re.search(r'%(?![0-9A-Fa-f]{2})|%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF]|5[cC])', value)):
raise ValueError('invalid URI characters or encoding')
parts = urllib.parse.urlsplit(value)
# Brackets delimit an IP literal only in authority, never path/query globs.
if (not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+;=:%/@-]*", parts.path)
or not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+;=:%/@?-]*", parts.query)):
raise ValueError('invalid URI component')
return parts
def redirect_target(current, location, origin):
"""Resolve one unambiguous HTTPS target without changing API authority."""
try:
given = redirect_uri(location)
if (given.scheme and (given.scheme.lower() != 'https' or not given.netloc)
or location.startswith('//') and not given.netloc):
raise ValueError('incomplete or non-HTTPS target')
target = redirect_uri(urllib.parse.urljoin(current, location))
hostname = target.hostname
if hostname and ':' in hostname:
ipaddress.IPv6Address(hostname)
elif not hostname or not re.fullmatch(r'[A-Za-z0-9.-]+', hostname):
raise ValueError('invalid host')
if (target.scheme.lower() != 'https'
or target.username is not None or target.password is not None
or target.netloc.endswith(':') or target.fragment
or target.port is not None and not 1 <= target.port <= 65535):
raise ValueError('invalid authority')
target_origin = (target.scheme.lower(), hostname.lower(), target.port or 443)
if target_origin != origin:
raise ValueError('different API origin')
authority = ('[' + hostname.lower() + ']' if ':' in hostname else hostname.lower())
authority += ':' + str(target.port) if target.port is not None else ''
resolved = urllib.parse.urlunsplit(('https', authority, target.path or '/', target.query, ''))
redirect_uri(resolved)
return resolved
except ValueError:
raise Failure(6, 'malformed', 'Provider redirect changes or obscures the HTTPS API origin')
def capabilities(provider, version):
names = ('status', 'review_comments', 'review_reply', 'review_resolve', 'merge',
'rebase_merge', 'fast_forward_only', 'auto_merge', 'merge_queue')
result = {n: {'support': 'unknown', 'reason': 'No qualified API profile', 'schema_source': None} for n in names}
def mark(names, support, reason, source):
for n in names:
result[n] = dict(support=support, reason=reason, schema_source=source)
if provider == 'github':
source = 'https://docs.github.com/en/graphql/reference/objects#pullrequest'
mark(names, 'supported', 'Documented API; repository permissions and settings still apply', source)
mark(('rebase_merge', 'fast_forward_only'), 'unsupported', 'GitHub merge API has no such method', 'https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request')
elif (provider, version) in (('gitea', '28.2.0'), ('gitea', '1.24.7'), ('forgejo', '16.0.0-dev')):
source = 'swagger.v1.json:' + provider + ':' + version
mark(('status', 'review_comments', 'merge', 'rebase_merge', 'fast_forward_only'), 'supported', 'Qualified versioned schema', source)
mark(('review_reply', 'review_resolve'), 'supported' if version == '28.2.0' else 'unsupported', 'Native conversation routes present' if version == '28.2.0' else 'No native conversation mutation routes in qualified schema', source)
mark(('auto_merge', 'merge_queue'), 'unsupported', 'No documented schedule readback or native merge queue; raw scheduling/cancellation is not safely verifiable', source)
return result
def fingerprint(data):
stable = {k: v for k, v in data.items() if k not in ('observed_at', 'fingerprint')}
return hashlib.sha256(json.dumps(stable, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def checks(statuses, runs, required, allow_none=False):
observed = {}
for s in sorted(statuses, key=lambda s: s.get('id', 0)):
name = s.get('context')
if not name:
raise Failure(6, 'malformed', 'Status context missing')
observed[name] = s.get('state') if s.get('state') in ('success','failure','error','pending','cancelled') else 'unknown'
for r in sorted(runs, key=lambda r: r.get('id', 0)):
name = r.get('name')
if not name:
raise Failure(6, 'malformed', 'Check run name missing')
conclusion = r.get('conclusion')
observed[name] = ('pending' if r.get('status') != 'completed' else
'success' if conclusion in ('success', 'neutral', 'skipped') else
'cancelled' if conclusion == 'cancelled' else 'failure' if conclusion in ('failure', 'timed_out', 'action_required', 'startup_failure') else 'unknown')
state = 'unknown'
if required is not None:
selected = [observed.get(n, 'missing') for n in required] if required else list(observed.values())
if not selected:
state = 'not_required' if allow_none and required == [] else 'missing'
else:
state = next((s for s in ('failure', 'error', 'cancelled', 'missing', 'unknown', 'pending', 'in_progress', 'queued') if s in selected), 'success')
state = {'error': 'failure', 'in_progress': 'pending', 'queued': 'pending'}.get(state, state)
return dict(state=state, required=required, observed=observed, complete=True)
def reviews(items, head, author, required_count=1):
latest = {}
for r in sorted(items, key=lambda r: r.get('id', 0)):
login = (r.get('user') or {}).get('login')
state = str(r.get('state', '')).upper()
if login and state not in ('COMMENTED', 'PENDING'):
latest[login] = r
rejected = any(str(r.get('state')).upper() in ('REQUEST_CHANGES', 'CHANGES_REQUESTED') for r in latest.values())
approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head for login, r in latest.items()) >= max(1, required_count) and not rejected
return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved)
def run(argv, data=None, cwd=None, env=None, pass_fds=(), binary=False):
try:
return subprocess.run(argv, input=data.encode() if binary and data is not None else data, text=not binary, capture_output=True, timeout=45, cwd=cwd, env=env, pass_fds=pass_fds)
except (OSError, subprocess.TimeoutExpired):
raise Failure(75, 'transient', 'Provider client unavailable or timed out')
class Client:
def __init__(self, host, repo):
self.host, self.repo = host, repo
self.provider, self.version, self.token = 'unknown', '', None
self.write_attempted = False
self.actor_verified = False
self.root = '/repos/' + repo
def detect(self):
if self.host == 'github.com' or self.host == os.environ.get('GH_HOST'):
self.provider, self.version = 'github', 'documented'
return
schema = self.http('/swagger.v1.json', public=True)
info = schema.get('info', {})
title, version = str(info.get('title', '')).lower(), str(info.get('version', ''))
if 'forgejo' in title or '+gitea-' in version:
self.provider = 'forgejo'
# This profile is the inspected 16.0.0 development schema only.
self.version = '16.0.0-dev' if version.startswith('16.0.0-dev') else version
elif 'gitea' in title:
self.provider, self.version = 'gitea', version
else:
raise Failure(4, 'unsupported', 'Host metadata does not identify a qualified forge')
def credential(self):
if self.token is None:
# Fixed script/arguments; no user input becomes shell source.
p = run(['bash', '-c', 'source "$1/detect-platform.sh"; get_gitea_token "$2"', 'forge-auth', str(HERE), self.host], cwd='/')
if p.returncode or not p.stdout.strip():
raise Failure(5, 'access', 'Acting identity has no credential for this host')
self.token = p.stdout.strip()
return self.token
def http(self, path, method='GET', body=None, public=False):
writing = method != 'GET' and not (path == 'graphql' and body and body.get('query', '').lstrip().startswith('query'))
if not public:
actor = os.environ.get('MOSAIC_GIT_IDENTITY', '')
if not re.fullmatch(r'[A-Za-z0-9_.-]+', actor):
raise Failure(5, 'access', 'Explicit MOSAIC_GIT_IDENTITY is required')
token = self.credential()
if writing and not self.actor_verified:
user = self.http('/user')
if not isinstance(user, dict) or user.get('login') != actor:
raise Failure(5, 'access', 'Authenticated actor differs from explicit identity')
self.actor_verified = True
prefix = '/api/v3' if self.provider == 'github' and self.host != 'github.com' else '' if self.provider == 'github' else '/api/v1'
route = ('/graphql' if self.host == 'github.com' else '/api/graphql') if path == 'graphql' else prefix + path
authority = 'api.github.com' if self.provider == 'github' and self.host == 'github.com' and not public else self.host
url = 'https://' + authority + (path if public else route)
return self._request(url, method, body, public, token if not public else None, writing)
def _request(self, url, method, body, public, token, writing):
initial = urllib.parse.urlsplit(url)
origin = (initial.scheme.lower(), initial.hostname.lower(), initial.port or 443)
visited = {(initial.hostname.lower(), initial.port or 443, initial.path or '/', initial.query)}
for hop in range(MAX_REDIRECTS + 1):
args = ['curl', '--disable', '--globoff', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper']
if body is not None:
args += ['--header', 'Content-Type: application/json', '--data-binary', '@-']
args += ['--url', url]
if not public:
if len(token) > 4096 or any(ord(c) < 32 or ord(c) == 127 for c in token):
raise Failure(5, 'access', 'Credential contains invalid control characters')
escaped = token.replace('\\', '\\\\').replace('"', '\\"')
# Anonymous pipe: no credential pathname survives termination. The body
# retains stdin, while curl reads authentication from the inherited fd.
read_fd, write_fd = os.pipe()
try:
os.write(write_fd, ('header = "Authorization: token ' + escaped + '"\n').encode())
os.close(write_fd)
write_fd = None
self.auth_fd = read_fd
p = self.transport(args + ['--config', '/dev/fd/' + str(read_fd)], body, writing)
finally:
self.auth_fd = None
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
else:
p = self.transport(args, body, writing)
if p.returncode:
raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none')
# Preserve curl's bytes: text-mode universal newlines can turn a
# forbidden bare CR into a plausible header boundary before validation.
raw = p.stdout
if not isinstance(raw, bytes):
raise Failure(6, 'malformed', 'Provider response bytes missing')
status, headers = None, {}
# Proxies can emit CONNECT and servers interim response blocks.
while raw.startswith(b'HTTP/'):
block, sep, raw = raw.partition(b'\r\n\r\n')
if not sep:
raise Failure(6, 'malformed', 'Provider response headers incomplete')
byte_lines = block.split(b'\r\n')
if (not re.fullmatch(rb'HTTP/[0-9.]+ [0-9]{3}(?: [\x09\x20-\x7e\x80-\xff]*)?', byte_lines[0])
or any(not re.fullmatch(rb"[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\x09\x20-\x7e\x80-\xff]*", line) for line in byte_lines[1:])):
raise Failure(6, 'malformed', 'Provider response header framing or value is invalid')
# Only validated CRLF separators and field values reach text parsing.
lines = [line.decode('latin-1') for line in byte_lines]
status = int(lines[0].split()[1])
headers = {k.lower(): v.strip(' \t') for line in lines[1:] for k, v in [line.split(':', 1)]}
if status is None:
raise Failure(6, 'malformed', 'Provider did not return HTTP metadata')
if status in REDIRECT_STATUSES:
if method != 'GET':
raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider mutation or non-GET redirect refused', outcome='unknown' if writing else 'none')
locations = [(k, v.strip(' \t')) for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)] if k.lower().strip() == 'location']
if (len(locations) != 1 or locations[0][0].lower() != 'location'
or any(line[:1].isspace() or ':' not in line for line in lines[1:])):
raise Failure(6, 'malformed', 'Provider redirect Location is missing or ambiguous')
target = redirect_target(url, locations[0][1], origin)
# Normalize default ports for loop detection as well as origin checks.
parts = urllib.parse.urlsplit(target)
identity = (parts.hostname.lower(), parts.port or 443, parts.path, parts.query)
if identity in visited or hop >= MAX_REDIRECTS:
raise Failure(6, 'malformed', 'Provider redirect loop or hop limit exceeded')
visited.add(identity)
url = target
continue
if status == 429 or status == 403 and (headers.get('x-ratelimit-remaining') == '0' or 'retry-after' in headers):
retry = headers.get('retry-after')
if retry is None:
reset = headers.get('x-ratelimit-reset','')
retry = str(max(1,int(reset)-int(time.time()))) if reset.isdigit() else '60'
raise Failure(75, 'rate_limit', 'Provider rate limit; use a bounded later wake', int(retry) if retry.isdigit() else 60, 'unknown' if writing else 'none')
if status in (401, 403, 404):
raise Failure(5, 'access', 'Provider access denied or resource hidden (HTTP %s)' % status, outcome='unknown' if writing else 'none')
if status >= 500:
raise Failure(75, 'transient', 'Provider server error', outcome='unknown' if writing else 'none')
if not 200 <= status < 300:
raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider refused request (HTTP %s)' % status)
if not raw.strip() and status == 204:
return None
try:
result = json.loads(raw)
except ValueError:
raise Failure(6, 'malformed', 'Provider returned invalid JSON', outcome='unknown' if writing else 'none')
return result
def transport(self, args, body, writing):
if writing:
self.write_attempted = True
try:
return run(args, json.dumps(body) if body is not None else None, pass_fds=(self.auth_fd,) if getattr(self, 'auth_fd', None) is not None else (), binary=True)
except Failure as e:
if writing:
e.outcome = 'unknown'
raise
def pages(self, path, key=None):
result = []
expected_total = 0
for page in range(1, MAX_PAGES + 1):
query = ('&' if '?' in path else '?') + ('per_page' if self.provider == 'github' else 'limit') + '=100&page=' + str(page)
value = self.http(path + query)
if key:
total = value.get('total_count')
if not isinstance(total,int) or isinstance(total,bool) or total < 0:
raise Failure(6,'incomplete','Check-run total_count missing or invalid')
expected_total = max(expected_total,total)
value = value.get(key)
if not isinstance(value, list):
raise Failure(6, 'malformed', 'Provider collection is not a list')
result.extend(value)
if len(value) < LIMIT:
if len(result) < expected_total:
raise Failure(6,'incomplete','Provider returned fewer records than total_count')
return result
raise Failure(6, 'incomplete', 'Pagination bound reached; collection incomplete')
def gql(self, query, variables):
result = self.http('graphql', 'POST', {'query': query, 'variables': variables})
if not isinstance(result, dict) or result.get('errors') or not isinstance(result.get('data'), dict):
raise Failure(6, 'incomplete', 'GraphQL returned errors or incomplete data', outcome='unknown' if query.lstrip().startswith('mutation') else 'none')
return result['data']
def pr(self, number):
value = self.http(self.root + '/pulls/' + str(number))
if not isinstance(value, dict) or value.get('number') != number or not re.fullmatch('[0-9a-fA-F]{40}', (value.get('head') or {}).get('sha', '')):
raise Failure(6, 'malformed', 'PR identity or head SHA missing')
return value
def require(client, operation):
cap = capabilities(client.provider, client.version)[operation]
if cap['support'] != 'supported':
raise Failure(4 if cap['support'] == 'unsupported' else 6, 'unsupported' if cap['support'] == 'unsupported' else 'incomplete', cap['reason'])
def base_identity(p):
b = p['base']
h = p['head']
return (b.get('ref'), b.get('sha'), (b.get('repo') or {}).get('full_name'), h.get('ref'), h.get('sha'), (h.get('repo') or {}).get('full_name'))
def same_base(p, original):
if base_identity(p) != base_identity(original):
raise Failure(75, 'base_moved', 'PR target changed; discard policy and readiness')
return p
def same_head(client, number, expected):
p = client.pr(number)
if p['head']['sha'] != expected:
raise Failure(75, 'head_moved', 'PR head changed; discard readiness and refresh review')
return p
def github_conversations(client, number):
owner, repo = client.repo.split('/')
cursor, seen, threads, integration = None, set(), [], None
fields = '''id isResolved isOutdated comments(first:100) { nodes { databaseId id replyTo { databaseId } } pageInfo { hasNextPage endCursor } }'''
for _ in range(MAX_PAGES):
q = '''query($owner:String!,$repo:String!,$number:Int!,$cursor:String) {
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
id reviewDecision autoMergeRequest { mergeMethod } mergeQueueEntry { id }
reviewThreads(first:100,after:$cursor) { nodes { ''' + fields + ''' } pageInfo { hasNextPage endCursor } }
} } }'''
pr = client.gql(q, dict(owner=owner, repo=repo, number=number, cursor=cursor))['repository']['pullRequest']
if not isinstance(pr, dict) or not pr.get('id') or 'autoMergeRequest' not in pr or 'mergeQueueEntry' not in pr:
raise Failure(6, 'incomplete', 'Missing integration state')
auto, queue = pr['autoMergeRequest'], pr['mergeQueueEntry']
if auto is not None and (not isinstance(auto, dict) or auto.get('mergeMethod') not in ('SQUASH', 'MERGE', 'REBASE')):
raise Failure(6, 'malformed', 'Unknown auto-merge state')
if queue is not None and (not isinstance(queue, dict) or not isinstance(queue.get('id'), str) or not queue['id']):
raise Failure(6, 'malformed', 'Unknown merge-queue state')
if integration is None:
integration = dict(id=pr['id'], review_decision=pr.get('reviewDecision'), auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':((pr['autoMergeRequest'] or {}).get('mergeMethod') or '').lower() or None}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')})
connection = pr['reviewThreads']
for thread in connection['nodes']:
if type(thread.get('isResolved')) is not bool:
raise Failure(6, 'malformed', 'Review thread resolution must be boolean')
comments = thread['comments']
nodes = list(comments['nodes'])
nested_seen = set()
for _ in range(MAX_PAGES):
if comments['pageInfo']['hasNextPage'] is False:
break
nested_cursor = comments['pageInfo']['endCursor']
if not nested_cursor or nested_cursor in nested_seen:
raise Failure(6, 'incomplete', 'Review comment cursor missing or repeated')
nested_seen.add(nested_cursor)
comments = client.gql('''query($id:ID!,$cursor:String!) { node(id:$id) { ... on PullRequestReviewThread { comments(first:100,after:$cursor) { nodes { databaseId id replyTo { databaseId } } pageInfo { hasNextPage endCursor } } } } }''', {'id':thread['id'], 'cursor':nested_cursor})['node']['comments']
nodes.extend(comments['nodes'])
else:
raise Failure(6, 'incomplete', 'Nested comment pagination bound reached')
thread['comments'] = nodes
threads.append(thread)
if connection['pageInfo']['hasNextPage'] is False:
return threads, integration
cursor = connection['pageInfo']['endCursor']
if not cursor or cursor in seen:
raise Failure(6, 'incomplete', 'Review thread cursor missing or repeated')
seen.add(cursor)
raise Failure(6, 'incomplete', 'Review thread pagination bound reached')
def review_comments(client, number):
require(client, 'review_comments')
root = client.root + '/pulls/' + str(number)
revs = client.pages(root + '/reviews')
comments, threads, integration = [], [], None
if client.provider == 'github':
raw = client.pages(root + '/comments')
threads, integration = github_conversations(client, number)
mapping = {}
for t in threads:
for c in t['comments']:
cid = c.get('databaseId')
if cid is None or cid in mapping:
raise Failure(6, 'incomplete', 'Thread comment identity missing or duplicated')
mapping[cid] = t
for c in raw:
if c.get('id') not in mapping:
raise Failure(6, 'incomplete', 'REST comment absent from authoritative thread graph')
if set(mapping) != {c.get('id') for c in raw}:
raise Failure(6, 'incomplete', 'Comment collections disagree')
else:
raw = []
mapping = {}
for r in revs:
if r.get('id') is None:
raise Failure(6, 'malformed', 'Review ID missing')
page = client.pages(root + '/reviews/' + str(r['id']) + '/comments')
for c in page:
c = dict(c, pull_request_review_id=r['id'])
raw.append(c)
seen = set()
for c in raw:
cid = c.get('id')
if cid is None or cid in seen:
raise Failure(6, 'incomplete', 'Comment ID missing or duplicated')
seen.add(cid)
t = mapping.get(cid)
parent = c.get('in_reply_to_id')
# A resolver proves a resolved comment; it does not prove a thread graph.
resolved = t['isResolved'] if t else bool(c.get('resolver')) if 'resolver' in c else None
comments.append(dict(id=f'{client.provider}:comment:{cid}', review_id=c.get('pull_request_review_id'), thread_id=f"github:thread:{t['id']}" if t else None, reply_to=f'{client.provider}:comment:{parent}' if parent else None, author=(c.get('user') or {}).get('login'), body=c.get('body'), path=c.get('path'), line=c.get('line') or c.get('position') or c.get('new_position'), commit_sha=c.get('commit_id'), outdated=t['isOutdated'] if t else c.get('outdated'), resolved=resolved, url=c.get('html_url') or c.get('url')))
return dict(complete=True, reviews=revs, comments=comments), threads, integration
def protection(client, base):
if client.provider == 'github':
p = client.http(client.root + '/branches/' + urllib.parse.quote(base, safe='') + '/protection')
status = p.get('required_status_checks')
required = sorted(set((status or {}).get('contexts', []) + [c['context'] for c in (status or {}).get('checks', [])])) if status is not None else []
review = p.get('required_pull_request_reviews') or {}
client.review_count = review.get('required_approving_review_count', 1)
client.codeowners_required = review.get('require_code_owner_reviews') is True
enforced = bool(required and review.get('required_approving_review_count', 0) > 0 and review.get('dismiss_stale_reviews') is True and (p.get('enforce_admins') or {}).get('enabled') is True)
if status is not None and any(c.get('app_id') not in (None,-1) for c in status.get('checks',[])):
# App-bound contexts need source-specific matching; unknown never clears a gate.
required = None
return required, enforced
rules = client.pages(client.root + '/branch_protections')
matching = [r for r in rules if r.get('branch_name') == base]
if len(matching) != 1:
return None, False
r = matching[0]
client.review_count = r.get('required_approvals',1)
return (r.get('status_check_contexts') if r.get('enable_status_check') is True else None), False
def snapshot(client, number, expected):
require(client, 'status')
p = client.pr(number)
head = p['head']['sha']
if expected and head != expected:
raise Failure(3, 'gate', 'Expected head differs from PR head')
base = p['base']
required, enforced = protection(client, base['ref'])
root = client.root
statuses = client.pages(root + '/commits/' + head + '/statuses')
runs = client.pages(root + '/commits/' + head + '/check-runs', 'check_runs') if client.provider == 'github' else []
check = checks(statuses, runs, required)
comments, threads, integration = review_comments(client, number)
issue_comments = client.pages(root + '/issues/' + str(number) + '/comments')
review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1))
if getattr(client,'codeowners_required',False) and (integration or {}).get('review_decision') != 'APPROVED':
review.update(approved=False,decision='codeowner_approval_unknown',reviewed_sha=None)
unresolved = sum(t['isResolved'] is False for t in threads) if client.provider == 'github' else sum(c['resolved'] is False for c in comments['comments']) if all(c['resolved'] is not None for c in comments['comments']) else None
thread_state = dict(unresolved=unresolved, complete=comments['complete'] and unresolved is not None)
state = 'merged' if p.get('merged') is True else p['state']
if state not in ('open','closed','merged'):
raise Failure(6,'malformed','Provider PR state is unknown')
mergeability = 'conflict' if p.get('mergeable') is False else 'clean' if p.get('mergeable') is True else 'unknown'
blockers = []
if not re.fullmatch('[0-9a-fA-F]{40}',base.get('sha','')):
blockers.append('base_sha_unknown')
if not (p['head'].get('repo') or {}).get('full_name') or not (base.get('repo') or {}).get('full_name'):
blockers.append('repository_unknown')
if state != 'open':
blockers.append(state)
if p.get('draft') is not False:
blockers.append('draft_or_unknown')
if check['state'] not in ('success', 'not_required'):
blockers.append('checks_' + check['state'])
if not review['approved']:
blockers.append('review_' + review['decision'])
if unresolved != 0:
blockers.append('review_threads_unresolved_or_unknown')
if mergeability != 'clean':
blockers.append('mergeability_' + mergeability)
data = dict(url=p.get('html_url'), state=state, draft=p.get('draft'), head_ref=p['head'].get('ref'), head_sha=head, base_ref=base.get('ref'), base_sha=base.get('sha'), head_repository=(p['head'].get('repo') or {}).get('full_name'), base_repository=(base.get('repo') or {}).get('full_name'), merge_sha=p.get('merge_commit_sha') if state == 'merged' else None, mergeability=mergeability, checks=check, reviews=review, threads=thread_state, auto_merge=integration['auto_merge'] if integration else {'state':'unsupported'}, queue=integration['queue'] if integration else {'state':'unsupported'}, capabilities=capabilities(client.provider, client.version), ready=not blockers, blockers=blockers, comments=comments['comments'], review_records=comments['reviews'], issue_comments=issue_comments, protection_enforced=enforced, observed_at=datetime.datetime.now(datetime.timezone.utc).isoformat())
after = same_head(client, number, head)
same_base(after, p)
if after.get('state') != p.get('state') or after.get('merged') != p.get('merged'):
raise Failure(75, 'snapshot_changed', 'PR base or closure changed during aggregation')
data['fingerprint'] = fingerprint(data)
return data
def owned_comment(client, number, opaque):
data, threads, integration = review_comments(client, number)
matches = [c for c in data['comments'] if c['id'] == opaque]
if len(matches) != 1:
raise Failure(3, 'gate', 'Comment is not uniquely owned by the requested PR')
return matches[0]
def reply(client, number, opaque, body, expected):
require(client, 'review_reply')
same_head(client, number, expected)
target = owned_comment(client, number, opaque)
if not re.fullmatch(re.escape(client.provider) + r':comment:[1-9][0-9]*', opaque):
raise Failure(2, 'usage', 'Use the provider-qualified comment ID from pr-review-comments')
actor = client.http('/user').get('login')
if not actor:
raise Failure(5, 'access', 'Acting user could not be verified')
same_head(client, number, expected)
cid = opaque.rsplit(':', 1)[1]
path = client.root + '/pulls/' + str(number) + '/comments/' + cid + '/replies'
created = client.http(path, 'POST', {'body':body})
if not isinstance(created, dict) or not isinstance(created.get('id'), int):
raise Failure(6, 'verification', 'Reply response has no created ID; reconcile before retry', outcome='unknown')
new_id = f"{client.provider}:comment:{created['id']}"
try:
if client.provider == 'github':
item = client.http(client.root + '/pulls/comments/' + str(created['id']))
expected_url = '/repos/' + client.repo + '/pulls/' + str(number)
parsed = urllib.parse.urlparse(item.get('pull_request_url', ''))
authority = urllib.parse.urlparse('https://' + ('api.github.com' if client.host == 'github.com' else client.host))
actual_path = parsed.path.removeprefix('/api/v3') if client.host != 'github.com' else parsed.path
if actual_path != expected_url or parsed.scheme != 'https' or parsed.hostname != authority.hostname or (parsed.port or 443) != (authority.port or 443) or parsed.username or parsed.password or item.get('in_reply_to_id') != int(cid):
raise Failure(6, 'verification', 'Reply repository or parent readback differs', outcome='unknown')
actual = {'id':f"github:comment:{item.get('id')}", 'body':item.get('body'), 'author':(item.get('user') or {}).get('login'), 'commit_sha':item.get('commit_id')}
else:
actual = owned_comment(client, number, new_id)
if actual.get('id') != new_id or actual.get('body') != body or actual.get('author') != actor or target.get('commit_sha') and actual.get('commit_sha') != target['commit_sha']:
raise Failure(6, 'verification', 'Created reply identity, author, body or commit readback differs', outcome='unknown')
same_head(client, number, expected)
except Failure as e:
e.outcome = 'unknown'
raise
return dict(id=new_id, reply_to=opaque, thread_id=target.get('thread_id'), verified=True, head_sha=expected)
def resolve(client, number, opaque, desired, expected):
require(client, 'review_resolve')
same_head(client, number, expected)
data, threads, integration = review_comments(client, number)
if client.provider == 'github':
matches = [t for t in threads if 'github:thread:' + t['id'] == opaque]
if len(matches) != 1:
raise Failure(3, 'gate', 'Thread is not uniquely owned by the requested PR')
target = matches[0]
if target.get('isResolved') is desired:
return dict(thread_id=opaque, resolved=desired, verified=True, changed=False)
same_head(client, number, expected)
mutation = 'resolveReviewThread' if desired else 'unresolveReviewThread'
client.gql('mutation($id:ID!) { ' + mutation + '(input:{threadId:$id}) { thread { id isResolved } } }', {'id':target['id']})
else:
# This API resolves a review comment. Its ID is a provider selector,
# never an invented path/line-based thread graph.
matches = [c for c in data['comments'] if c['id'] == opaque]
if len(matches) != 1 or not re.fullmatch('gitea:comment:[1-9][0-9]*', opaque):
raise Failure(3, 'gate', 'Resolution selector is not owned by this PR')
if matches[0]['resolved'] is desired:
return dict(thread_id=opaque, resolved=desired, verified=True, changed=False)
same_head(client, number, expected)
client.http(client.root + '/pulls/comments/' + opaque.rsplit(':',1)[1] + ('/resolve' if desired else '/unresolve'), 'POST')
try:
new_data, new_threads, _ = review_comments(client, number)
states = [t['isResolved'] for t in new_threads if 'github:thread:' + t['id'] == opaque] if client.provider == 'github' else [c['resolved'] for c in new_data['comments'] if c['id'] == opaque]
if states != [desired]:
raise Failure(6, 'verification', 'Resolution readback does not prove requested state', outcome='unknown')
same_head(client, number, expected)
except Failure as e:
e.outcome = 'unknown'
raise
return dict(thread_id=opaque, resolved=desired, verified=True, changed=True)
def forge_method(client, method):
if method not in ('squash','merge','rebase','rebase-merge','fast-forward-only'):
raise Failure(2, 'usage', 'Unknown merge method')
if client.provider == 'github' and method in ('rebase-merge','fast-forward-only'):
raise Failure(4, 'unsupported', 'GitHub has no ' + method + ' merge method')
settings = client.http(client.root)
key = ({'squash':'allow_squash_merge','merge':'allow_merge_commit','rebase':'allow_rebase_merge'} if client.provider == 'github' else {'squash':'allow_squash_merge','merge':'allow_merge_commits','rebase':'allow_rebase','rebase-merge':'allow_rebase_explicit','fast-forward-only':'allow_fast_forward_only_merge'})[method]
if settings.get(key) is False:
raise Failure(4, 'unsupported', 'Forge repository setting ' + key + ' disables method')
if settings.get(key) is not True:
raise Failure(6, 'incomplete', 'Forge repository setting ' + key + ' could not be verified')
return settings
def declared_method(client, p, requested):
root = run(['git','rev-parse','--show-toplevel'])
if root.returncode:
raise Failure(3,'policy','POLICY-REFUSED: mutation requires a local repository declaration context')
origin = run(['git','remote','get-url','origin']).stdout.strip()
parsed = urllib.parse.urlparse(origin if not origin.startswith('git@') else 'ssh://' + origin.replace(':','/',1))
if parsed.hostname != client.host.split(':')[0] or parsed.path.strip('/').removesuffix('.git') != client.repo:
raise Failure(3,'policy','POLICY-REFUSED: explicit target differs from local origin')
base = p['base']
if (base.get('repo') or {}).get('full_name') != client.repo or not re.fullmatch('[0-9a-fA-F]{40}', base.get('sha', '')):
raise Failure(3, 'policy', 'POLICY-REFUSED: trusted target revision unavailable')
revision = base['sha']
if run(['git', 'cat-file', '-e', revision + '^{commit}']).returncode:
raise Failure(3, 'policy', 'POLICY-REFUSED: fetch the pinned target revision before merging')
listing = run(['git', 'ls-tree', revision, '--', '.mosaic/repo.json'])
if listing.returncode:
raise Failure(6, 'verification', 'Cannot inspect pinned target declaration')
if not listing.stdout.strip():
if requested not in (None, 'squash'):
raise Failure(3, 'policy', 'POLICY-REFUSED: absent merge policy permits squash only')
if base['ref'] != client.http(client.root).get('default_branch'):
raise Failure(3, 'policy', 'POLICY-REFUSED: absent declaration requires the repository default base')
return 'squash'
content = run(['git', 'show', revision + ':.mosaic/repo.json'])
if content.returncode:
raise Failure(6, 'verification', 'Cannot read pinned target declaration')
# This temporary file contains public policy, never authentication material.
with tempfile.TemporaryDirectory() as directory:
declaration = pathlib.Path(directory) / 'repo.json'
declaration.write_text(content.stdout)
validator = run([str(HERE.parent/'structure/validate-repo-json.sh'), str(declaration), '--mode', 'display'])
if validator.returncode:
raise Failure(6, 'malformed', 'Invalid target repository declaration: ' + validator.stdout.strip())
doc = json.loads(content.stdout)
canonical = doc.get('canonical_remote', '').removesuffix('.git').rstrip('/')
normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', canonical])
origin_normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', origin])
if normal.returncode or origin_normal.returncode or normal.stdout != origin_normal.stdout:
raise Failure(3, 'policy', 'POLICY-REFUSED: target declaration canonical remote differs from origin')
args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref']]
if requested:
args += ['--method', requested]
result = run(args)
value = json.loads(result.stdout)
if result.returncode:
raise Failure(result.returncode, 'policy' if result.returncode == 3 else 'malformed', value.get('error', 'Merge policy refused'))
return value['method']
def queue_guard(client, p):
# This is the CI push/merge guard, never GitHub's PR merge queue.
git_dir = run(['git','rev-parse','--absolute-git-dir'])
work_tree = run(['git','rev-parse','--show-toplevel'])
if git_dir.returncode or work_tree.returncode:
raise Failure(3,'gate','CI admission needs the verified local repository context')
guard_env = dict(os.environ,GIT_DIR=git_dir.stdout.strip(),GIT_WORK_TREE=work_tree.stdout.strip())
result = run([str(HERE/'ci-queue-wait.sh'),'--purpose','merge','--repo',client.repo,'--sha',p['head']['sha'],'-B',p['head']['ref'],'-t','1','-i','1'],cwd='/',env=guard_env)
if result.returncode:
mapping = {75:75, 78:3, 124:124}
raise Failure(mapping.get(result.returncode,3),'gate','CI queue guard refused (legacy exit %s)' % result.returncode)
def merge(client, number, requested, expected):
require(client, 'merge')
p = same_head(client,number,expected)
method = declared_method(client,p,requested)
forge_method(client,method)
status = snapshot(client,number,expected)
if (status['base_ref'], status['base_sha'], status['base_repository'], status['head_ref'], status['head_sha'], status['head_repository']) != base_identity(p):
raise Failure(75, 'base_moved', 'PR identity changed after policy evaluation')
if not status['ready']:
raise Failure(3,'gate','Merge readiness failed: ' + ', '.join(status['blockers']))
queue_guard(client,p)
same_base(same_head(client,number,expected), p)
if client.provider == 'github':
client.http(client.root+'/pulls/'+str(number)+'/merge','PUT',{'merge_method':method,'sha':expected})
else:
key = 'do' if client.version == '28.2.0' else 'Do'
client.http(client.root+'/pulls/'+str(number)+'/merge','POST',{key:method,'head_commit_id':expected})
try:
after = same_base(same_head(client,number,expected), p)
if after.get('merged') is not True or not after.get('merge_commit_sha'):
raise Failure(6,'verification','Merge not proven by PR readback',outcome='unknown')
except Failure as e:
e.outcome='unknown'
raise
return dict(requested_method=requested, effective_method=method, merged=True, merge_sha=after['merge_commit_sha'],head_sha=expected,base_ref=after['base']['ref'])
def integration(client, number, operation, action, requested, expected):
require(client,operation)
if action not in (('inspect','enable','disable') if operation == 'auto_merge' else ('inspect','enqueue','dequeue')):
raise Failure(2,'usage','Invalid action for integration operation')
if action != 'inspect' and not expected:
raise Failure(2,'usage','Mutation requires --expect-head')
p = same_head(client,number,expected) if expected else client.pr(number)
_, state = github_conversations(client,number)
key = 'auto_merge' if operation == 'auto_merge' else 'queue'
before = state[key]
if action == 'inspect':
same_base(same_head(client,number,p['head']['sha']), p)
return dict(supported=True,state=before['state'],method=before.get('method'),entry_id=before.get('entry_id'),expected_head=expected,head_sha=p['head']['sha'],verified=True)
enabling = action in ('enable','enqueue')
method = before.get('method')
if enabling:
method = declared_method(client,p,requested)
settings = forge_method(client,method)
_, enforced = protection(client,p['base']['ref'])
if not enforced:
raise Failure(3,'gate','Scheduling requires enforced checks, independent reviews, stale approval dismissal and no administrator bypass')
if p.get('state') != 'open' or p.get('draft') is not False:
raise Failure(3,'gate','Scheduling requires an open non-draft PR')
owner, repo = client.repo.split('/')
queue = client.gql('query($owner:String!,$repo:String!,$branch:String!) { repository(owner:$owner,name:$repo) { mergeQueue(branch:$branch) { configuration { mergeMethod } } } }',{'owner':owner,'repo':repo,'branch':p['base']['ref']})['repository']['mergeQueue']
queue_method = ((queue or {}).get('configuration') or {}).get('mergeMethod')
if action == 'enqueue' and not queue_method:
raise Failure(4,'unsupported','No configured merge queue method available')
if queue_method and queue_method.lower() != method:
raise Failure(3,'policy','POLICY-REFUSED: queue effective method conflicts with requested/declaration method')
if action == 'enable' and settings.get('allow_auto_merge') is not True:
raise Failure(4,'unsupported','Repository allow_auto_merge is disabled or unknown')
queue_guard(client,p)
same_base(same_head(client,number,expected), p)
variables = {'id':state['id']}
if action == 'enable':
query = 'mutation($id:ID!,$head:GitObjectID!,$method:PullRequestMergeMethod!) { enablePullRequestAutoMerge(input:{pullRequestId:$id,expectedHeadOid:$head,mergeMethod:$method}) { pullRequest { id } } }'
variables.update(head=expected,method=method.upper())
elif action == 'disable':
query = 'mutation($id:ID!) { disablePullRequestAutoMerge(input:{pullRequestId:$id}) { pullRequest { id } } }'
elif action == 'enqueue':
query = 'mutation($id:ID!,$head:GitObjectID!) { enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$head}) { mergeQueueEntry { id } } }'
variables['head']=expected
else:
query = 'mutation($id:ID!) { dequeuePullRequest(input:{id:$id}) { mergeQueueEntry { id } } }'
client.gql(query,variables)
try:
after = same_base(same_head(client,number,expected), p)
if after.get('merged') is True:
if not after.get('merge_commit_sha'):
raise Failure(6,'verification','Merged PR lacks merge SHA',outcome='unknown')
return dict(supported=True,state='merged',method=method,entry_id=None,expected_head=expected,verified=True,merge_sha=after['merge_commit_sha'])
_, readback = github_conversations(client,number)
desired = ('enabled' if enabling else 'disabled') if operation == 'auto_merge' else ('queued' if enabling else 'absent')
actual = readback[key]
if actual['state'] != desired or enabling and operation == 'auto_merge' and actual.get('method','').lower() != method or desired == 'queued' and not actual.get('entry_id'):
raise Failure(6,'verification','Integration readback does not prove requested state',outcome='unknown')
same_base(same_head(client,number,expected), p)
except Failure as e:
e.outcome='unknown'
raise
return dict(supported=True,state=desired,method=method,entry_id=actual.get('entry_id'),expected_head=expected,verified=True)
def workflow_verdict(data, mode):
if data.get('state') == 'merged':
return 'merged'
if data.get('state') == 'closed':
return 'closed_unmerged'
if mode == 'full-stop' and (data.get('auto_merge',{}).get('state') != 'disabled' or data.get('queue',{}).get('state') != 'absent'):
return 'blocked'
if data.get('reviews',{}).get('decision') in ('changes_requested','codeowner_approval_unknown'):
return 'blocked'
if data.get('checks',{}).get('state') in ('failure','cancelled','unknown','missing'):
return 'blocked'
if data.get('ready') is True:
return 'ready_to_merge'
if data.get('mergeability') in ('conflict','unknown') or any(b.startswith('review_threads') or 'unknown' in b for b in data.get('blockers',[])):
return 'blocked'
return 'waiting'
def ci_wait(client, number, expected, timeout, interval):
if timeout <= 0 or interval <= 0:
raise Failure(2,'usage','Timeout and interval must be positive')
deadline = time.monotonic() + timeout
while True:
data = snapshot(client,number,expected)
state = data['checks']['state']
if state in ('success','not_required'):
return data
if state in ('failure','cancelled'):
raise Failure(3,'gate','CI reached terminal ' + state)
if time.monotonic() >= deadline:
raise Failure(124,'timeout','CI wait timed out')
time.sleep(min(interval,max(0,deadline-time.monotonic())))
def main():
class Parser(argparse.ArgumentParser):
def error(self, message):
print(message, file=sys.stderr)
print(json.dumps(dict(schema_version=1,operation=sys.argv[1] if len(sys.argv)>1 else 'unknown',provider='unknown',host=None,repo=None,number=None,head_sha=None,data=None,error=dict(kind='usage',message=message,retry_after_seconds=None,write_outcome='none'))))
raise SystemExit(2)
parser = Parser(description='Versioned forge adapter. See README for provider limitations.')
parser.add_argument('operation', choices=['capabilities', 'status', 'watch-check', 'review-comments', 'review-reply', 'review-resolve', 'merge', 'auto-merge', 'merge-queue', 'ci-wait'])
parser.add_argument('-n', '--number', type=int)
parser.add_argument('-r', '--repo')
parser.add_argument('--host')
parser.add_argument('--json', action='store_true')
parser.add_argument('--operation', dest='requested_operation')
parser.add_argument('--expect-head')
parser.add_argument('--baseline')
parser.add_argument('-m', '--method')
parser.add_argument('--action')
parser.add_argument('-t','--timeout',type=int,default=1800)
parser.add_argument('-i','--interval',type=int,default=15)
parser.add_argument('--comment-id')
parser.add_argument('--thread-id')
parser.add_argument('--body-file')
resolution = parser.add_mutually_exclusive_group()
resolution.add_argument('--resolve', action='store_true')
resolution.add_argument('--unresolve', action='store_true')
args = parser.parse_args()
client, data, head, error, code = None, None, None, None, 0
try:
if args.number is not None and args.number < 1:
raise Failure(2, 'usage', 'PR number must be positive')
if args.operation != 'capabilities' and not args.number:
raise Failure(2, 'usage', '-n NUMBER is required')
if args.expect_head and not re.fullmatch('[0-9a-fA-F]{40}', args.expect_head):
raise Failure(2, 'usage', '--expect-head requires a full SHA')
if args.operation in ('review-reply','review-resolve','merge') and not args.expect_head:
raise Failure(2,'usage','Mutation requires --expect-head')
if args.operation == 'review-reply' and (not args.comment_id or not args.body_file or not pathlib.Path(args.body_file).is_file()):
raise Failure(2,'usage','Reply requires --comment-id and a readable --body-file')
if args.operation == 'review-resolve' and (not args.thread_id or not (args.resolve or args.unresolve)):
raise Failure(2,'usage','Resolution requires --thread-id and --resolve or --unresolve')
if args.operation in ('auto-merge','merge-queue') and args.action not in (('inspect','enable','disable') if args.operation == 'auto-merge' else ('inspect','enqueue','dequeue')):
raise Failure(2,'usage','Explicit --action is required')
if args.operation in ('auto-merge','merge-queue') and args.action != 'inspect' and not args.expect_head:
raise Failure(2,'usage','Mutation requires --expect-head')
if args.operation == 'watch-check' and (not args.baseline or not os.path.isabs(args.baseline)):
raise Failure(2,'usage','--baseline requires an absolute path')
if not args.host or not args.repo:
p = run(['git', 'remote', 'get-url', 'origin'])
remote = p.stdout.strip()
if remote.startswith('git@'):
remote = 'ssh://' + remote.replace(':', '/', 1)
parsed = urllib.parse.urlparse(remote)
args.host = args.host or parsed.hostname
args.repo = args.repo or parsed.path.strip('/').removesuffix('.git')
if args.host:
args.host = args.host.lower()
if not args.host or not re.fullmatch(r'[A-Za-z0-9.-]+(?::[0-9]+)?', args.host) or not args.repo or not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repo) or any(part in ('.','..') for part in args.repo.split('/')):
raise Failure(2, 'usage', 'Use an unambiguous origin or --host HOST --repo OWNER/REPO')
if args.operation != 'capabilities' and not re.fullmatch(r'[A-Za-z0-9_.-]+', os.environ.get('MOSAIC_GIT_IDENTITY', '')):
raise Failure(5, 'access', 'Explicit MOSAIC_GIT_IDENTITY is required before provider requests')
client = Client(args.host, args.repo)
client.detect()
if args.operation == 'capabilities':
data = {'version': client.version, 'operations': capabilities(client.provider, client.version)}
if args.requested_operation:
if args.requested_operation not in data['operations']:
raise Failure(2, 'usage', 'Unknown capability operation')
require(client, args.requested_operation)
elif args.operation == 'ci-wait':
data = ci_wait(client,args.number,args.expect_head,args.timeout,args.interval)
head = data['head_sha']
elif args.operation in ('merge','auto-merge','merge-queue'):
if args.operation == 'merge':
if not args.expect_head:
raise Failure(2,'usage','Merge requires --expect-head')
data = merge(client,args.number,args.method,args.expect_head)
else:
data = integration(client,args.number,'auto_merge' if args.operation == 'auto-merge' else 'merge_queue',args.action,args.method,args.expect_head)
head = data.get('head_sha') or args.expect_head
elif args.operation == 'review-comments':
before = client.pr(args.number)['head']['sha']
data, _, _ = review_comments(client, args.number)
same_head(client, args.number, before)
head = before
elif args.operation in ('review-reply', 'review-resolve'):
if not args.expect_head:
raise Failure(2, 'usage', 'Mutation requires --expect-head')
head = args.expect_head
if args.operation == 'review-reply':
if not args.comment_id or not args.body_file:
raise Failure(2, 'usage', 'Reply requires --comment-id and --body-file')
try:
body = pathlib.Path(args.body_file).read_text()
except OSError:
raise Failure(2, 'usage', 'Body file unreadable')
if not body.strip():
raise Failure(2, 'usage', 'Reply body must not be empty')
data = reply(client, args.number, args.comment_id, body, args.expect_head)
else:
if not args.thread_id or not (args.resolve or args.unresolve):
raise Failure(2, 'usage', 'Resolution requires --thread-id and --resolve or --unresolve')
data = resolve(client, args.number, args.thread_id, args.resolve, args.expect_head)
else:
data = snapshot(client, args.number, args.expect_head)
head = data['head_sha']
if args.operation == 'watch-check':
if not args.baseline or not os.path.isabs(args.baseline):
raise Failure(2, 'usage', '--baseline must be an absolute JSON path')
try:
baseline = json.loads(pathlib.Path(args.baseline).read_text())
except (OSError, ValueError):
raise Failure(6, 'malformed', 'Baseline cannot be read as JSON')
if baseline.get('schema_version') != 1 or baseline.get('error') is not None or not re.fullmatch('[0-9a-f]{64}',(baseline.get('data') or {}).get('fingerprint','')):
raise Failure(6,'malformed','Baseline is not a successful version-1 snapshot')
if any(baseline.get(k) != v for k, v in [('provider', client.provider), ('host', args.host), ('repo', args.repo), ('number', args.number)]):
raise Failure(6, 'malformed', 'Baseline belongs to a different PR/watch owner')
code = 0 if data['state'] != 'open' or data['ready'] or workflow_verdict(data,'full-send') == 'blocked' or data['fingerprint'] != (baseline.get('data') or {}).get('fingerprint') else 1
except Failure as e:
if data is None and args.operation in ('status','watch-check','ci-wait'):
data = dict(ready=False,checks={'state':'unknown','complete':False},reviews={'decision':'unknown','complete':False},threads={'unresolved':None,'complete':False})
code = e.code
if client and client.write_attempted and e.outcome == 'none' and code in (6,75,124):
e.outcome = 'unknown'
error = dict(kind=e.kind, message=str(e), retry_after_seconds=e.retry, write_outcome=e.outcome)
print(str(e), file=sys.stderr)
except (ValueError, TypeError, KeyError, AttributeError) as e:
code = 6
error = dict(kind='malformed', message='Provider evidence malformed: ' + str(e), retry_after_seconds=None, write_outcome='unknown' if client and client.write_attempted else 'none')
print(json.dumps(dict(schema_version=1, operation=args.operation, provider=client.provider if client else 'unknown', host=args.host, repo=args.repo, number=args.number, head_sha=head, data=data, error=error), sort_keys=True))
if args.operation == 'ci-wait' and not args.json and code not in (0,2,124):
return 1
return code
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -I "$SCRIPT_DIR/forge-api.py" capabilities "$@"
@@ -1,220 +1,64 @@
#!/bin/bash
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
# Mosaic credential store at runtime so remote URLs never embed secrets.
#!/usr/bin/python3
# git-credential-mosaic — production entrypoint (P0-SEC R4, rev-code-02 B1).
#
# Install (one-time, per clone or globally):
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
# WHY THIS IS NOT BASH: three review rounds falsified every in-bash startup
# guard. A non-interactive bash sources $BASH_ENV and imports exported
# functions BEFORE the first script line, so read(), unset(), exit(),
# declare(), printf() — every callable — can be shadows that fake the
# ancestry, defeat the scrub, or forge diagnostics (rev-code-02 probes 1 and
# 2, artifacts fc49e9d9 lineage). No in-language dispatch survives that.
#
# Per-agent identity (Gate-16 author != reviewer separation):
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
# This entrypoint is unshapable at the bash level: python does not read
# BASH_ENV and imports no bash functions, and the interpreter is pinned by
# absolute shebang (no PATH resolution). It builds the child environment BY
# ALLOWLIST and execve's the bash implementation directly — the child bash
# starts with no BASH_ENV, no BASH_FUNC_*, no SHELLOPTS/BASHOPTS, and exactly
# the variables the credential protocol needs. stdin/stdout/stderr and argv
# pass through untouched.
#
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
# This helper used to end by emitting the shared account's token for any request
# it could not resolve to an identity. A seat with no identity, or with an
# identity whose token was never provisioned, therefore received the most
# privileged credential configured on the host — silently, and indistinguishably
# from correct operation. Every record it then created (commit, push, PR, review)
# was attributed to that shared account, so author != reviewer separation was
# unenforceable and the true actor was unrecoverable after the fact.
#
# Under-provisioning must fail loudly, not impersonate. A refused git operation
# is recoverable in one command; a merged pull request attributed to the wrong
# principal is not.
#
# ── CONTRACT ───────────────────────────────────────────────────────────────────
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
# username git supplies on stdin
# store : chosen by what the identity IS, with no precedence and no
# cross-store fallback (see "Credential store selection" below)
# hit : emit username + password, exit 0
# miss : emit NOTHING, spool a durable escalation record, explain on
# stderr, exit 1 — git surfaces the failure and nothing is attributed
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
# remotes handled by another helper)
#
# Backward compatibility is preserved for exactly one case: a host with no fleet
# and no identity requested still gets the shared account, because on such a host
# the shared account is the operator's own and there is no attribution to lose.
# A host that HAS a fleet has agents whose records must be distinguishable, so
# the shared fallback is refused there.
#
# A token is never written to stderr, to the escalation record, or to any log.
# The implementation file (git-credential-mosaic.impl) refuses to run without
# the clean-mode marker, so it cannot be invoked directly as a shaped-entry
# bypass of this wrapper.
[ "$1" = "get" ] || exit 0
import os
import sys
host=""; username_in=""
while IFS= read -r line; do
[ -z "$line" ] && break
case "$line" in
host=*) host=${line#host=};;
username=*) username_in=${line#username=};;
esac
done
IMPL = os.path.join(os.path.dirname(os.path.realpath(__file__)), "git-credential-mosaic.impl")
# Absolute-path candidates ONLY — never PATH resolution (an attacker-shaped
# PATH must not choose the interpreter). /usr/bin/bash is the fleet-host
# layout; /bin/bash is alpine and other FHS variants (found by the T125
# gateway-image verification: the hardcoded /usr/bin/bash made every call
# exit 127 inside node:22-alpine).
BASH_CANDIDATES = ("/usr/bin/bash", "/bin/bash")
BASH = next((p for p in BASH_CANDIDATES if os.access(p, os.X_OK)), None)
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
# declined quietly — another helper owns it, and refusing would break it.
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) exit 0;;
esac
# Allowlist: everything else in the environment dies at this boundary. Adding
# a variable here is a security decision — it crosses into a shell that no
# longer has any startup shaping, but it also becomes the only context the
# implementation can see.
KEEP = (
"HOME",
"PATH",
"LANG",
"MOSAIC_GIT_IDENTITY",
"MOSAIC_AGENT_NAME",
"MOSAIC_BRAIN_HOME",
"MOSAIC_CREDENTIAL_SPOOL",
"MOSAIC_CREDENTIAL_LINEAGE_FENCE",
)
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
if [ -z "$ident" ]; then
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
ident_src="git config mosaic.gitIdentity"
fi
if [ -z "$ident" ]; then
ident="$username_in"
ident_src="the username git supplied"
fi
env = {"_MOSAIC_HELPER_CLEAN": "1"}
for name in KEEP:
value = os.environ.get(name)
if value is not None:
env[name] = value
# ── Credential store selection ────────────────────────────────────────────────
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
# its credential lives. There is no precedence rule between the two stores and no
# fallback from one to the other: a seat whose slot is empty fails closed rather
# than reading a service credential that happens to share its name.
#
# seat — <brain>/fleet/agents/<ident>/ exists
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
# service — it does not
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
#
# One credential, one location. Two copies of one credential diverge, and the
# stale copy fails in a way that reads as a revoked token rather than as drift.
#
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
idtok=""; ident_kind=""
if [ -n "$ident" ]; then
if [ -d "$brain_home/fleet/agents/$ident" ]; then
ident_kind="seat"
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
else
ident_kind="service identity"
idtok="$svc_store/${idpfx}-${ident}.token"
fi
if [ -r "$idtok" ]; then
echo "username=${ident}"
echo "password=$(cat "$idtok")"
exit 0
fi
fi
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
# is active. Where there are seats, records must be attributable, so an
# unresolvable request is refused instead of borrowing the shared account.
fleet_present=0
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh"
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
# the password field, not from the username string, so any non-empty
# placeholder works — deliberately NOT a real account name, since framework
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
echo "username=${GITEA_USER:-git}"
echo "password=$GITEA_TOKEN"
exit 0
fi
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
if [ -z "$ident" ]; then
reason="no-identity"
else
reason="no-token-for-identity"
fi
seat="${MOSAIC_AGENT_NAME:-unknown}"
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# The escalation RECORD is durable and unconditional; any notification built on
# top of it is best-effort. Record and alert are deduplicated separately — a cap
# on the alert alone lets the spool grow without bound exactly while the operator
# is being told nothing, so the louder the failure the quieter it gets.
#
# A record field is arbitrary operator-supplied text: an identity comes from git
# config or the environment, and cwd is whatever directory git ran in. Either can
# contain a quote or a backslash, which would make the line unparseable JSON --
# and a spool that silently stops parsing is worse than no spool, because the
# operator only discovers it while reading the record that explains an outage.
json_escape() {
local s=$1
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\t'/\\t}
s=${s//$'\r'/\\r}
s=${s//$'\n'/\\n}
printf '%s' "$s"
}
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
spool_record=""
if mkdir -p "$spool" 2>/dev/null; then
chmod 700 "$spool" 2>/dev/null
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
if [ ! -e "$dedupe" ]; then
: > "$dedupe" 2>/dev/null
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
"$(json_escape "$ts")" "$(json_escape "$reason")" \
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
"$(json_escape "$host")" "$(json_escape "$PWD")" \
>> "$spoolfile" 2>/dev/null
chmod 600 "$spoolfile" 2>/dev/null
fi
# Name the record only if one is actually on disk. Printing the path
# unconditionally sends the operator to a file that does not exist on exactly
# the hosts where the spool could not be created.
[ -s "$spoolfile" ] && spool_record="$spoolfile"
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
fi
cat >&2 <<EOF
git-credential-mosaic: REFUSED (fail-closed).
host : ${host}
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
reason : ${reason}
EOF
if [ -n "$ident" ]; then
cat >&2 <<EOF
expected : ${idtok}
EOF
fi
cat >&2 <<EOF
No per-identity credential resolved. This helper does NOT fall back to the shared
account: that fallback makes every record it creates attributable to one
principal, which is unrecoverable once a pull request has merged under it.
Fix (pick one):
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
Then provision that identity's credential at the path named above. An identity
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
seat and is read ONLY from its own secrets/ slot; any other identity is read from
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
If this identity legitimately needs git access and has none, ask the orchestrator
to provision one.
EOF
if [ -n "$spool_record" ]; then
echo " record: ${spool_record}" >&2
else
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
fi
exit 1
argv = [BASH, IMPL] + sys.argv[1:]
if BASH is None:
sys.stderr.write("git-credential-mosaic: no executable bash at " + " or ".join(BASH_CANDIDATES) + "\n")
sys.exit(127)
try:
os.execve(BASH, argv, env)
except OSError as exc:
sys.stderr.write(f"git-credential-mosaic: entrypoint exec failed: {exc}\n")
sys.exit(127)
@@ -0,0 +1,480 @@
#!/bin/bash
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
# Mosaic credential store at runtime so remote URLs never embed secrets.
#
# Install (one-time, per clone or globally):
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
#
# Per-agent identity (Gate-16 author != reviewer separation):
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
#
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
# This helper used to end by emitting the shared account's token for any request
# it could not resolve to an identity. A seat with no identity, or with an
# identity whose token was never provisioned, therefore received the most
# privileged credential configured on the host — silently, and indistinguishably
# from correct operation. Every record it then created (commit, push, PR, review)
# was attributed to that shared account, so author != reviewer separation was
# unenforceable and the true actor was unrecoverable after the fact.
#
# Under-provisioning must fail loudly, not impersonate. A refused git operation
# is recoverable in one command; a merged pull request attributed to the wrong
# principal is not.
#
# ── CONTRACT ───────────────────────────────────────────────────────────────────
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
# username git supplies on stdin
# ownership: a FLEET SEAT caller may resolve ONLY its own identity, where
# the CALLER is established by process ANCESTRY, not by the
# current environment: every ancestor's /proc/<pid>/environ is
# frozen at exec, so a child can rewrite its own MOSAIC_AGENT_NAME
# but can never make an ancestor disagree with what the launcher
# gave it (P5-RM-006; the dual-variable override was measured by
# rev-code-02 F1). An anonymous caller (no lineage, no consensus)
# may resolve NOTHING on a fleet host — seat or service
# (rev-code-02 F2). Non-fleet hosts keep the documented legacy
# paths below.
# perms : a slot whose mode lets group or other read it (anything but
# ?00) is refused — a loose slot is provisioning drift, and
# serving from it silently widens every seat's exposure on a
# single-account host.
# store : chosen by what the identity IS, with no precedence and no
# cross-store fallback (see "Credential store selection" below)
# hit : emit username + password, exit 0
# miss : emit NOTHING, spool a durable escalation record, explain on
# stderr, exit 1 — git surfaces the failure and nothing is attributed
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
# remotes handled by another helper)
#
# Backward compatibility is preserved for exactly one case: a host with no fleet
# and no identity requested still gets the shared account, because on such a host
# the shared account is the operator's own and there is no attribution to lose.
# A host that HAS a fleet has agents whose records must be distinguishable, so
# the shared fallback is refused there.
#
# A token is never written to stderr, to the escalation record, or to any log.
[ "$1" = "get" ] || exit 0
# ── The shared refusal path ──────────────────────────────────────────────────
# Every fail-closed exit funnels through refuse(): a durable escalation record
# (deduped, JSON-escaped), a stderr diagnostic naming host/identity/reason,
# caller-supplied guidance when the refusing site has specific advice, exit 1.
# Defined here because the ownership gate below must be able to reach it.
refuse() {
local guidance="${1:-}"
local seat ts spool spool_record spoolfile dedupe
seat="${MOSAIC_AGENT_NAME:-unknown}"
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# A record field is arbitrary operator-supplied text: an identity comes from git
# config or the environment, and cwd is whatever directory git ran in. Either can
# contain a quote or a backslash, which would make the line unparseable JSON --
# and a spool that silently stops parsing is worse than no spool, because the
# operator only discovers it while reading the record that explains an outage.
json_escape() {
local s=$1
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\t'/\\t}
s=${s//$'\r'/\\r}
s=${s//$'\n'/\\n}
printf '%s' "$s"
}
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
spool_record=""
if mkdir -p "$spool" 2>/dev/null; then
chmod 700 "$spool" 2>/dev/null
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
if [ ! -e "$dedupe" ]; then
: > "$dedupe" 2>/dev/null
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
"$(json_escape "$ts")" "$(json_escape "$reason")" \
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
"$(json_escape "$host")" "$(json_escape "$PWD")" \
>> "$spoolfile" 2>/dev/null
chmod 600 "$spoolfile" 2>/dev/null
fi
# Name the record only if one is actually on disk. Printing the path
# unconditionally sends the operator to a file that does not exist on exactly
# the hosts where the spool could not be created.
[ -s "$spoolfile" ] && spool_record="$spoolfile"
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
fi
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
git-credential-mosaic: REFUSED (fail-closed).
host : ${host}
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
reason : ${reason}
EOF
if [ -n "$ident" ]; then
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
expected : ${idtok}
EOF
fi
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
${guidance}
EOF
if [ -n "$spool_record" ]; then
echo " record: ${spool_record}" >&2
else
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
fi
exit 1
}
# ── Bash environment injection guard (rev-code-02 R3, B1) ───────────────────
# Non-interactive bash sources $BASH_ENV at startup and imports exported
# functions from BASH_FUNC_* environment entries; either can define a read()
# or printf() that shadows the builtin the ancestry walker and diagnostics
# rely on — measured live by the reviewer's fixture (BASH_ENV read() rewrote
# every ancestry entry). A legitimate fleet seat environment carries neither
# (verified: zero BASH_FUNC_* in seat envs), so their presence in a helper
# request is an injection attempt: scrub the shadows first (so even the
# refusal machinery cannot be subverted), then refuse fail-closed.
# Imported functions are detected by ENUMERATION, not env-var names: bash
# consumes BASH_FUNC_* variables while importing the functions, so the
# environment no longer shows them (measured). At this point the script has
# defined exactly one function of its own (refuse); anything else in the
# function table arrived from the caller's environment. BASH_ENV is checked
# directly (it remains visible after sourcing).
_injected=0
_inj_names=""
while IFS=' ' builtin read -r _decl _kind _fn; do
[ -n "$_fn" ] || continue
case "$_fn" in
refuse) ;;
*) _injected=1; _inj_names="$_inj_names $_fn";;
esac
done < <(declare -F)
_inj_vars="${!BASH_FUNC_@}"
if [ -n "$_inj_vars" ]; then
_injected=1
for _iv in $_inj_vars; do
case "$_iv" in
BASH_FUNC_*%%) _ifn="${_iv#BASH_FUNC_}"; _ifn="${_ifn%%%}";;
BASH_FUNC_*) _ifn="${_iv#BASH_FUNC_}";;
*) _ifn="";;
esac
[ -n "$_ifn" ] && { unset -f "$_ifn" 2>/dev/null; _inj_names="$_inj_names $_ifn"; }
done
fi
if [ "$_injected" = 1 ] || [ -n "${BASH_ENV:-}" ]; then
while IFS=' ' builtin read -r _decl _kind _fn; do
[ "$_fn" = refuse ] || unset -f "$_fn" 2>/dev/null
done < <(declare -F)
unset BASH_ENV 2>/dev/null
reason="bash-environment-injection-refused"
refuse "The helper's bash startup state was externally shaped: BASH_ENV is
set and/or exported BASH_FUNC_* functions are present in the request
environment. Non-interactive bash sources BASH_ENV and imports those
functions BEFORE any script line runs, so builtins this helper's security
decisions rely on could be shadowed. Nothing resolves from a shaped request
environment. If this surprised a legitimate workflow, the caller environment
must be cleaned (no BASH_ENV, no exported functions) before invoking git."
fi
host=""; username_in=""
while IFS= builtin read -r line; do
[ -z "$line" ] && break
case "$line" in
host=*) host=${line#host=};;
username=*) username_in=${line#username=};;
esac
done
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
# declined quietly — another helper owns it, and refusing would break it.
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) exit 0;;
esac
# ── Clean-entrypoint assert (P0-SEC R4) ─────────────────────────────────────
# This implementation only runs behind the python entrypoint
# (git-credential-mosaic), which execve's it with an allowlist environment:
# no BASH_ENV, no imported functions, nothing shapable at bash startup. A
# direct invocation without the marker is a bypass attempt on that boundary
# and refuses. Placed after refuse() and the host parse so the refusal path
# exists when it fires (an earlier placement died on 'refuse: command not
# found' — the failure mode is real, keep this after every definition it
# calls).
if [ "${_MOSAIC_HELPER_CLEAN:-}" != "1" ]; then
reason="direct-entrypoint-refused"
refuse "This implementation refuses to run outside the production
entrypoint. git-credential-mosaic (the python wrapper in this directory)
execve's it with a hand-built, unshapable environment; invoking the .impl
directly bypasses that boundary. Credential requests go through git, which
invokes the wrapper named in gitconfig."
fi
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
if [ -z "$ident" ]; then
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
ident_src="git config mosaic.gitIdentity"
fi
if [ -z "$ident" ]; then
ident="$username_in"
ident_src="the username git supplied"
fi
# ── Credential store selection ────────────────────────────────────────────────
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
# its credential lives. There is no precedence rule between the two stores and no
# fallback from one to the other: a seat whose slot is empty fails closed rather
# than reading a service credential that happens to share its name.
#
# seat — <brain>/fleet/agents/<ident>/ exists
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
# service — it does not
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
#
# One credential, one location. Two copies of one credential diverge, and the
# stale copy fails in a way that reads as a revoked token rather than as drift.
#
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
# ── Caller-identity ownership (P5-RM-006) ──────────────────────────────────────
# A credential request is honourable only when the CALLER owns the identity it
# asks for. On a fleet host every seat shares one unix account, so the
# launcher-established MOSAIC_AGENT_NAME is the only attribution signal the
# helper has. Two measured paths made the old contract unsafe:
#
# - a seat exporting MOSAIC_GIT_IDENTITY=<another-seat> resolved that seat's
# token through the normal precedence chain (T97 G2, jarvis V2 probe), and
# - an anonymous caller (no seat name) inherited the host gitconfig's
# username=jarvis line and resolved jarvis's slot (T94: five watcher units
# flapping on exactly this class).
#
# Ownership rules, fail-closed on fleet hosts only; a host with no fleet keeps
# the legacy contract unchanged:
# 1. a SEAT caller may resolve only its own identity;
# 2. an anonymous caller may not resolve any SEAT identity (service
# identities remain available to non-seat automation such as CI).
# [P5-RM-006r1 ancestry binding begin]
# ── Caller identity from exec-frozen ancestry (rev-code-02 F1/F2) ───────────
# Walk /proc self->root collecting MOSAIC_AGENT_NAME from each ancestor's
# frozen environ. Rules:
# - any DISAGREEMENT (an ancestor value != the current value, or ancestors
# disagreeing among themselves) is a rewrite -> spoof-refused, nothing
# resolves. A child can inject variables downward but cannot alter an
# ancestor's exec-frozen environ, so the launcher-established value always
# participates in the comparison.
# - consensus (all ancestors that carry the var agree with the current env,
# or with each other when the current env is empty) -> caller = that value.
# - no ancestor carries it -> the current claim is unlineaged: caller is
# anonymous regardless of what the environment says. A name with no
# lineage is a claim, not an identity.
# The walk stops at PID 1, at a missing /proc entry, or INCLUSIVE at an
# ancestor that carries MOSAIC_CREDENTIAL_LINEAGE_FENCE with an EMPTY agent
# name — the test-suite lineage root. A fence beside a non-empty name is
# IGNORED and the walk continues, so an attacker cannot fence off the true
# ancestry by planting the marker next to a victim name.
trusted_caller() {
# PATH-HARDENED (rev-code-02 R1 F1): every /proc read below uses ONLY bash
# builtins (read/case/parameter expansion). The first implementation piped
# through PATH-resolved tr/sed/head/grep, and a caller that prepends hostile
# utilities to PATH in the same invocation that overrides the identity
# variables could forge the ancestry itself. Builtins cannot be shadowed.
local pid ppid v entry line fence
local -a vals=()
pid=$$
while :; do
v=""
fence=0
if [ -r "/proc/$pid/environ" ]; then
# Read inside a captured subshell whose stderr is closed: opening
# /proc/<pid>/environ can fail with EACCES on ancestors that are
# readable-by-mode but not openable (session managers), and that open
# failure prints from the SHELL, immune to loop-level 2>/dev/null
# (measured). The subshell makes the skip silent; NUL separators are
# converted to newlines for the parent's builtin parse.
_env_text=$( { while IFS= builtin read -r -d '' _e; do builtin printf '%s\n' "$_e"; done < "/proc/$pid/environ"; } 2>/dev/null )
while IFS= builtin read -r entry; do
[ -n "$entry" ] || continue
case "$entry" in
MOSAIC_AGENT_NAME=*) v="${entry#MOSAIC_AGENT_NAME=}";;
MOSAIC_CREDENTIAL_LINEAGE_FENCE=*) fence=1;;
esac
done <<EOF_ENV
$_env_text
EOF_ENV
fi
if [ "$pid" != "$$" ]; then
[ -n "$v" ] && vals+=("$v")
if [ "$fence" = 1 ] && [ -z "$v" ]; then
break
fi
fi
ppid=""
if [ -r "/proc/$pid/status" ]; then
while IFS= builtin read -r line; do
case "$line" in
PPid:*) ppid="${line#PPid:}"; ppid="${ppid//[[:space:]]/}";;
esac
done < "/proc/$pid/status"
fi
case "$ppid" in ''|0|1) break;; esac
pid=$ppid
done
local self="${MOSAIC_AGENT_NAME:-}" i consensus=""
if [ "${#vals[@]}" -gt 0 ]; then
consensus="${vals[0]}"
for i in "${vals[@]}"; do
if [ "$i" != "$consensus" ]; then
printf 'SPOOF'
return
fi
done
if [ -n "$self" ] && [ "$self" != "$consensus" ]; then
printf 'SPOOF'
return
fi
fi
printf '%s' "$consensus"
}
if [ -d "$brain_home/fleet/agents" ]; then
caller="$(trusted_caller)"
if [ "$caller" = "SPOOF" ]; then
reason="caller-identity-spoof-refused"
refuse "The MOSAIC_AGENT_NAME lineage disagrees within this process tree:
an ancestor established by exec carries a different value than the request.
A child process can rewrite its own environment but never an ancestor's
frozen environ, so disagreement is a rewrite, not a race. Nothing resolves
under a rewritten caller identity. If this surprised a legitimate workflow,
run git from the seat's own session, not from a rewritten environment."
fi
if [ -n "$caller" ] && [ -d "$brain_home/fleet/agents/$caller" ]; then
if [ -n "$ident" ] && [ "$ident" != "$caller" ]; then
reason="cross-seat-identity-refused"
refuse "A seat may resolve only its own credential slot. Caller seat is
'$caller' (ancestry-established); the request names '$ident'. Overriding
MOSAIC_GIT_IDENTITY (or a git config / URL username) to another seat's name is
exactly the path this refusal exists to close. If '$ident' auth is genuinely
required, that seat runs the operation itself or the orchestrator provisions
an explicit grant."
fi
else
# Anonymous caller on a fleet host (no lineage, or the lineage root is not
# a seat): NOTHING resolves — seat slots (T94 jarvis@ class) or legacy
# service credentials (rev-code-02 F2: credentialed services are seats;
# the legacy store is vestigial and not anonymously reachable).
if [ -n "$ident" ]; then
ident_kind="${ident_kind:-}"
[ -d "$brain_home/fleet/agents/$ident" ] && ident_kind="seat" || ident_kind="service identity"
reason="anonymous-credential-refused"
refuse "This caller has no seat lineage on a fleet host and asked for
'$ident' (a ${ident_kind}). Anonymous callers resolve nothing on fleet hosts:
seat credentials must never serve an unattributable caller, and credentialed
services are seats with their own sessions (the legacy service store is
vestigial). Run from the owning seat's session."
fi
fi
fi
# [P5-RM-006r1 ancestry binding end]
idtok=""; ident_kind=""
if [ -n "$ident" ]; then
if [ -d "$brain_home/fleet/agents/$ident" ]; then
ident_kind="seat"
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
else
ident_kind="service identity"
idtok="$svc_store/${idpfx}-${ident}.token"
fi
if [ -r "$idtok" ]; then
# P5-RM-006 seat permissions: a SEAT slot readable by group or other is
# provisioning drift, and on a single-account fleet host it widens every
# seat's exposure at once. Refuse rather than serve from a loose slot; the
# record names the path so the provisioning fix is one chmod away.
# Scoped to seat slots: the framework service store is operator-managed
# and outside this work unit's permission surface.
if [ "${ident_kind:-}" = "seat" ]; then
# command -p resolves stat from the POSIX default PATH (system
# directories), never the caller's PATH (rev-code-02 R3 B2: a shadowed
# stat reported a 0644 slot as 600 and the helper served it). Output is
# shape-validated: anything that is not 3-4 octal digits refuses.
slot_mode="$(command -p stat -c '%a' "$idtok" 2>/dev/null || true)"
case "$slot_mode" in
[0-7][0-7][0-7]|[0-7][0-7][0-7][0-7]) ;;
*) slot_mode="unverifiable";;
esac
if [ "${slot_mode:1:2}" != "00" ]; then
reason="slot-permission-violation"
refuse "Slot $idtok has mode ${slot_mode:-unknown}; expected owner-only
(0600 or stricter). Tighten it: chmod 600 '$idtok'. This refusal is the seat
permissions half of P5-RM-006: a loose slot on a shared-account host is every
seat's exposure, so the helper declines to serve from it. Mode inspection uses
command -p (trusted PATH) and fails closed on unverifiable output."
fi
fi
echo "username=${ident}"
echo "password=$(<"$idtok")"
exit 0
fi
fi
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
# is active. Where there are seats, records must be attributable, so an
# unresolvable request is refused instead of borrowing the shared account.
fleet_present=0
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh"
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
# the password field, not from the username string, so any non-empty
# placeholder works — deliberately NOT a real account name, since framework
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
echo "username=${GITEA_USER:-git}"
echo "password=$GITEA_TOKEN"
exit 0
fi
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
# The escalation RECORD is durable and unconditional; any notification built on
# top of it is best-effort (see refuse()). Record and alert are deduplicated
# separately — a cap on the alert alone lets the spool grow without bound
# exactly while the operator is being told nothing, so the louder the failure
# the quieter it gets.
if [ -z "$ident" ]; then
reason="no-identity"
else
reason="no-token-for-identity"
fi
refuse "No per-identity credential resolved. This helper does NOT fall back to the shared
account: that fallback makes every record it creates attributable to one
principal, which is unrecoverable once a pull request has merged under it.
Fix (pick one):
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
Then provision that identity's credential at the path named above. An identity
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
seat and is read ONLY from its own secrets/ slot; any other identity is read from
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
If this identity legitimately needs git access and has none, ask the orchestrator
to provision one."
@@ -48,6 +48,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Credential resolution and all consumers execute inside this private boundary.
# Never restore xtrace while credentials remain in scope.
mosaic_private_forge_main() (
set +x
usage() {
echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
echo ""
@@ -170,12 +176,7 @@ ORG="${REPO_SLUG%%/*}"
REPO_NAME="${REPO_SLUG#*/}"
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$RESPONSE_FILE"
echo "Error: could not stage Gitea credential for reviewer grant" >&2
exit 1
}
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT
trap 'rm -f "$RESPONSE_FILE"' EXIT
# gitea_api <step> <method> <path> [json-payload]
# Runs one API call with the staged credential (token never in argv). Sets
@@ -189,9 +190,8 @@ gitea_api() {
if [[ -n "$payload" ]]; then
payload_args=(-H 'Content-Type: application/json' -d "$payload")
fi
if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
if ! GITEA_API_STATUS=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
-X "$method" \
--config "$AUTH_CONFIG" \
"${payload_args[@]}" \
"$GITEA_API_ROOT$path"); then
echo "Error: Gitea transport failed during $step" >&2
@@ -341,3 +341,6 @@ fi
echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
)
mosaic_private_forge_main "$@"
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more