From 59e49cfd154aa9f92a38087f1863233f07f62a0d Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 18:09:54 +0000 Subject: [PATCH 01/16] docs(tess): define Pi-native interaction agent mission (#712) --- docs/MISSION-MANIFEST.md | 5 +- docs/PRD.md | 96 +++++++++++++++++++++++++++++++ docs/TASKS.md | 3 +- docs/scratchpads/tess-20260712.md | 58 +++++++++++++++++++ docs/tess/ARCHITECTURE.md | 71 +++++++++++++++++++++++ docs/tess/MIGRATION-INVENTORY.md | 34 +++++++++++ docs/tess/MISSION-MANIFEST.md | 46 +++++++++++++++ docs/tess/TASKS.md | 34 +++++++++++ docs/tess/THREAT-MODEL.md | 46 +++++++++++++++ docs/tess/VERIFICATION-MATRIX.md | 30 ++++++++++ 10 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 docs/scratchpads/tess-20260712.md create mode 100644 docs/tess/ARCHITECTURE.md create mode 100644 docs/tess/MIGRATION-INVENTORY.md create mode 100644 docs/tess/MISSION-MANIFEST.md create mode 100644 docs/tess/TASKS.md create mode 100644 docs/tess/THREAT-MODEL.md create mode 100644 docs/tess/VERIFICATION-MATRIX.md diff --git a/docs/MISSION-MANIFEST.md b/docs/MISSION-MANIFEST.md index 812cd3a..72b1edd 100644 --- a/docs/MISSION-MANIFEST.md +++ b/docs/MISSION-MANIFEST.md @@ -69,8 +69,9 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c | # | ID | Name | Status | Manifest | Notes | | --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | -| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | -| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | +| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | +| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | +| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | ### Likely Additional Workstreams (Not Yet Declared) diff --git a/docs/PRD.md b/docs/PRD.md index 9d0db59..55666f6 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -79,6 +79,102 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and --- +## Tess Interaction Agent Workstream (TESS) + +### Problem and Objective + +Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent. + +The objective is to ship **Tess** (from *tessera*, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency. + +### Scope + +#### In Scope + +1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors. +2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery. +3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs. +4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery. +5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery. +6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority. +7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations. +8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence. +9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction. +10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery. +11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details. +12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution. +13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval. +14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority. +15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields. +16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay. +17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows. +18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest. +19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay. +20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job. +21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics. +22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates. + +#### Out of Scope + +1. Replacing Mos as coding/general fleet orchestrator. +2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas. +3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated. +4. Unrestricted shell execution from Discord. +5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable. +6. Replacing tmux before Matrix/native transport reaches operational parity. + +### Stakeholder and User Requirements + +- Jason must be able to converse with the same Tess session from Discord and CLI. +- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session. +- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls. +- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority. +- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale. + +### Non-Functional Requirements + +1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals. +2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart. +3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions. +4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events. +5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests. +6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy. +7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations. + +### Acceptance Criteria + +1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally. +2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors. +3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials. +4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work. +5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed. +6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects. +7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited. +8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass. +9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green. +10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence. +11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap. + +### Constraints, Dependencies, Risks, and Assumptions + +- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required. +- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control. +- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests. +- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation. +- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes. +- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication. +- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification. +- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys. +- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation. +- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification. +- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities. + +### Testing and Delivery Intent + +Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure. + +--- + ## Architecture ### High-Level System Diagram diff --git a/docs/TASKS.md b/docs/TASKS.md index 8d85fa0..1f2015b 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -16,7 +16,8 @@ | id | status | workstream | progress | tasks file | notes | | --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | -| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | ## Cross-Cutting Tracking diff --git a/docs/scratchpads/tess-20260712.md b/docs/scratchpads/tess-20260712.md new file mode 100644 index 0000000..819d5c5 --- /dev/null +++ b/docs/scratchpads/tess-20260712.md @@ -0,0 +1,58 @@ +# Scratchpad — Tess Interaction Agent + +## 2026-07-12 — Mission intake + +**Objective:** Build a Pi-native GPT-5.6 Sol high-reasoning Mosaic interaction agent, named Tess, as Jason's primary Discord/CLI access point for Mosaic fleet and transitional Hermes capabilities. Tess complements Mos and must not become a competing orchestrator. + +**Issue:** #706 + +**Budget:** No explicit cap provided. Original working estimate was 290K implementation/review tokens. That estimate is superseded after six security prerequisite tasks were added; revised arithmetic total is pending because the calculation tool was blocked by runtime consent. Run at most two workers; prefer one implementation lane plus one independent review/discovery lane. Re-estimate after planning approval and each milestone. + +**Evidence gathered:** +- Mosaic already provides Pi lifecycle hooks, fleet/tmux sessions, Matrix connector/controller pieces, typed chat events, Discord/Telegram channel plugins, and command/plugin registries. +- Current `IProviderAdapter` is an LLM model/completion abstraction, not an external agent/session provider. +- Required new seam is `AgentRuntimeProvider`: sessions, stream, message, terminate, hierarchy, attach, health, capabilities. +- Recurring cross-runtime needs: unified memory/retrieval, Discord routing/approvals, agent state/inbox/compaction recovery, runtime bootstrap, fleet/incident controls, and GitOps workflow. +- Project truth must remain in canonical project/Mosaic stores; semantic memory is retrieval/mirror. + +**Decisions:** +1. Name: Tess (tessera). Stable machine key `tess`; display name configurable. +2. Mos owns orchestration; Tess delegates Mos-owned work through an explicit coordination contract. +3. Gateway owns ingress/auth/routing; Discord and CLI remain thin clients. +4. tmux/fleet ships first behind an adapter; Matrix/native Mosaic is the forward transport. +5. Hermes integration is transitional and capability-negotiated; unsupported operations fail closed. +6. No unrestricted Discord shell. Privileged/destructive/customer-visible actions require authorization and approval. + +**Plan:** +1. Land requirements/architecture/task graph. +2. Deliver runtime contracts and security model. +3. Deliver durable Pi service/state. +4. Deliver Discord and CLI. +5. Deliver fleet/Mos/Hermes/memory/tool plugins. +6. Deliver Matrix/native transport, migration matrix, recovery, docs, and qualification. + +**Progress:** Issue #706 created. PRD/manifest/tasks initialized on clean branch `feat/tess-interaction-agent` from `origin/main`. + +**Risks:** 14 GB root filesystem headroom; active fleet lanes; broad migration scope; Discord privilege boundary; possible duplicate orchestration authority. + +## 2026-07-12 — Independent planning and threat review + +**Verdict received:** BLOCK TESS-PLAN-001. Coding remains stopped. + +**Blocking findings:** formal threat model absent; verification matrix absent; migration inventory implied but absent; non-existent task paths; AC-TESS-03 lacked a crisp test. Security review also identified command scope bypass, cross-tenant session attachment, MCP actor impersonation, unsafe Discord service ingress, pre-persistence/egress secret leakage, non-durable replay, and globally scoped GC. + +**Remediation applied:** +- Added `docs/tess/ARCHITECTURE.md`. +- Added `docs/tess/THREAT-MODEL.md` with TM-01..12. +- Added `docs/tess/VERIFICATION-MATRIX.md` mapping AC-TESS-01..11. +- Added `docs/tess/MIGRATION-INVENTORY.md`. +- Added hard requirements TESS-SEC-002..009. +- Added six prerequisite security tasks before provider/ingress implementation. +- Corrected task paths to existing package surfaces. +- Added explicit GPT-5.6 Sol/high/tool-policy status verification for AC-TESS-03. + +**Re-review 1:** BLOCK only on composite `repo` values that looked like nonexistent paths. Remediated by declaring comma-separated roots and validating every root. + +**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit. + +**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md new file mode 100644 index 0000000..841a467 --- /dev/null +++ b/docs/tess/ARCHITECTURE.md @@ -0,0 +1,71 @@ +# Tess Architecture + +## Purpose + +Tess is the Mosaic operator interaction plane. Mos remains the coding/general fleet orchestration authority. Tess receives authorized operator intent, presents fleet/session state, delegates Mos-owned work to Mos, and exposes native Mosaic plus transitional external-agent capabilities through normalized providers. + +## Component Boundaries + +```text +Discord plugin ─┐ + ├─ authenticated ingress envelope ─> Mosaic Gateway +mosaic tess CLI ┘ │ + ├─ policy/approval/audit + ├─ Tess durable session service (Pi GPT-5.6 Sol high) + ├─ AgentRuntimeProvider registry + │ ├─ native Pi provider + │ ├─ fleet/tmux provider + │ ├─ Hermes adapter + │ └─ Matrix/native transport provider + ├─ memory/state/inbox plugins + └─ Mos coordination adapter ─> Mos / fleet queue +``` + +## Core Contract + +`AgentRuntimeProvider` is separate from the existing model-completion `IProviderAdapter`. It normalizes external and native agent runtimes without leaking provider-specific schemas. + +Required operations: + +- `capabilities()` and `health()` +- `listSessions(scope)` +- `getSessionTree(scope)` +- `streamSession(sessionRef, cursor, scope)` +- `sendMessage(sessionRef, message, idempotencyKey, scope)` +- `attach(sessionRef, mode, scope)` / `detach()` +- `terminate(sessionRef, approvalRef, scope)` + +Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors. + +## Authority Model + +| Intent | Owner | Tess behavior | +| --- | --- | --- | +| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy | +| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result | +| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | +| Provider-specific unsupported action | None | Fail closed; never emulate silently | + +## Session and State Model + +A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation. + +Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durable session bindings, approvals, audit, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth. + +## Transport Strategy + +- **Initial:** fleet/tmux provider, including exact target, socket, identity, heartbeat, and safe attach semantics. +- **Forward:** Matrix/native Mosaic provider using authenticated identity, idempotent transaction IDs, replay cursors, and the same contract suite. +- Discord/CLI never call tmux or Matrix directly. + +## Plugin Families + +1. Channel: Discord now; other channels later. +2. Runtime: Pi, fleet/tmux, Hermes, Matrix/native. +3. Operator tools: fleet health, Mos handoff, GitOps wrappers, incident-safe diagnostics. +4. Memory/state: search/recent/capture, durable inbox, checkpoint, handoff, compaction recovery. +5. Migration: capability inventory, adapters, cutover, rollback, telemetry. + +## Deployment + +Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material. diff --git a/docs/tess/MIGRATION-INVENTORY.md b/docs/tess/MIGRATION-INVENTORY.md new file mode 100644 index 0000000..eb46a6e --- /dev/null +++ b/docs/tess/MIGRATION-INVENTORY.md @@ -0,0 +1,34 @@ +# Tess Capability Migration Inventory + +Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover. + +| Capability | Current source | Target | Initial status | Cutover/rollback intent | +| --- | --- | --- | --- | --- | +| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway | +| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak | +| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback | +| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration | +| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport | +| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit | +| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated | +| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time | +| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field | +| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors | +| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot | +| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover | +| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes | +| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback | +| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval | +| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations | +| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy | +| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release | +| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback | + +## Cutover Gates + +1. Capability contract and security tests pass. +2. Data mapping/provenance and retention are documented. +3. Shadow or dual-run shows no unauthorized access, loss, or duplicate effects. +4. Operator runbook and rollback are exercised. +5. Channel/provider binding changes are reversible without schema rollback. +6. Legacy capability is disabled only after a defined soak period and evidence review. diff --git a/docs/tess/MISSION-MANIFEST.md b/docs/tess/MISSION-MANIFEST.md new file mode 100644 index 0000000..4e22d5a --- /dev/null +++ b/docs/tess/MISSION-MANIFEST.md @@ -0,0 +1,46 @@ +# Mission Manifest — Tess Interaction Agent + +## Mission + +- **ID:** tess-20260712 +- **Issue:** #706 +- **Branch:** `feat/tess-interaction-agent` +- **Phase:** Execution +- **Current Milestone:** TESS-M1 — Runtime contracts and security foundation +- **Progress:** 0 / 5 delivery milestones complete +- **Status:** active +- **Owner:** Mosaic orchestrator; Mos is coordinating fleet authority +- **Source PRD:** `docs/PRD.md` — `TESS-*` requirements +- **Scratchpad:** `docs/scratchpads/tess-20260712.md` + +## Mission Statement + +Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction agent for Discord and CLI, with safe visibility/control of Mosaic fleet and transitional Hermes capabilities, while Mos remains the coding/general orchestration authority. + +## Invariants + +1. Mosaic is the enterprise AI hub; Hermes is a reference migration adapter. +2. Gateway is the single API surface. +3. Mos owns coding/general fleet orchestration; Tess owns human interaction, visibility, mediation, and migration access. +4. Runtime, transport, channel, memory, and external-agent integrations are replaceable adapters. +5. No source task completes before merged PR, terminal-green CI, independent review, and linked task/issue closure. + +## Milestones + +| ID | Issue | Name | Status | Exit gate | +| --- | --- | --- | --- | --- | +| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | +| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | +| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | +| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | +| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete | + +## Success Criteria + +All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. The final operational test must prove Discord + CLI session continuity, fleet/Mos coordination, authorized Hermes transition capabilities, denial/audit paths, restart recovery, and rollback. + +## Session History + +| Session | Date | Runtime | Outcome | +| --- | --- | --- | --- | +| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. | diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md new file mode 100644 index 0000000..b536e96 --- /dev/null +++ b/docs/tess/TASKS.md @@ -0,0 +1,34 @@ +# Tasks — Tess Interaction Agent + +> Mission: `tess-20260712` · Issue: #706 · PRD requirements: `TESS-*` +> Orchestrator is sole writer. Workers must not modify this file. +> `repo` contains one or more comma-separated repository-relative roots; every listed root must exist before dispatch. + +| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| TESS-PLAN-001 | done | Finalize PRD, architecture, authority boundary, threat model, migration inventory, and verification matrix | #706 | sonnet | docs, packages/types, apps/gateway | feat/tess-interaction-agent | — | 22K | Independent gate PASS after two remediation rounds; completion effective when planning PR merges | +| TESS-M1-SEC-001 | not-started | Enforce command scopes/roles and durable exact-action approval for privileged/destructive commands | #707 | codex | apps/gateway | fix/tess-command-authz | TESS-PLAN-001 | 25K | TESS-SEC-002; security TDD | +| TESS-M1-SEC-002 | not-started | Enforce owner/tenant binding on session list/read/attach/send/terminate across REST and WS | #707 | codex | apps/gateway | fix/tess-session-ownership | TESS-PLAN-001 | 30K | TESS-SEC-003; security TDD | +| TESS-M1-SEC-003 | not-started | Bind MCP actor/tenant to authenticated context and add per-tool scopes | #707 | codex | apps/gateway | fix/tess-mcp-identity | TESS-PLAN-001 | 22K | TESS-SEC-004; security TDD | +| TESS-M1-SEC-004 | not-started | Add authenticated Discord service ingress, allowlists, correlation and replay protection | #707 | codex | plugins/discord, apps/gateway | fix/tess-discord-ingress | TESS-PLAN-001 | 28K | TESS-SEC-005; security TDD | +| TESS-M1-SEC-005 | not-started | Redact/classify secret and PII before persistence/egress; harden provider login flow | #707 | codex | apps/gateway, packages/log | fix/tess-redaction | TESS-PLAN-001 | 28K | TESS-SEC-006; seeded canary tests | +| TESS-M1-SEC-006 | not-started | Scope session GC/retention or separate authorized global retention job | #707 | codex | apps/gateway, packages/log | fix/tess-session-gc-scope | TESS-PLAN-001 | 18K | TESS-SEC-009; isolation TDD | +| TESS-M1-001 | not-started | Define AgentRuntimeProvider, capabilities, session tree, normalized stream events/errors, attach semantics | #707 | codex | packages/types, packages/agent | feat/tess-runtime-contract | TESS-PLAN-001 | 25K | TESS-ARP-001, TESS-TRN-001; contract TDD | +| TESS-M1-002 | not-started | Implement provider registry/service with immutable actor scope, approval, audit and correlation boundaries | #707 | codex | apps/gateway, packages/agent | feat/tess-provider-registry | TESS-M1-001,TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003 | 30K | TESS-SEC-001..004,007; security TDD | +| TESS-M1-003 | not-started | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; exact target/identity tests | +| TESS-M1-OBS-001 | not-started | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability | TESS-M1-002 | 24K | TESS-OBS-001; no credential material | +| TESS-M1-V | not-started | Independent architecture/security review and complete contract/abuse-suite verification | #707 | sonnet | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 | +| TESS-M2-001 | not-started | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | codex | packages/mosaic/framework | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; explicit AC-TESS-03 test | +| TESS-M2-002 | not-started | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | codex | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD | +| TESS-M2-V | not-started | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | sonnet | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3; AC-TESS-03/06 | +| TESS-M3-001 | not-started | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | codex | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001 | +| TESS-M3-002 | not-started | Implement `mosaic tess` chat/status/sessions/tree/attach/send/stop/health/recover CLI | #709 | codex | packages/mosaic | feat/tess-cli | TESS-M2-V | 30K | TESS-CLI-001 | +| TESS-M3-V | not-started | Discord+CLI same-session E2E, denial/approval tests, and operator-flow review | #709 | sonnet | apps/gateway/src/__tests__/integration, plugins/discord, packages/mosaic/src | review/tess-m3 | TESS-M3-001,TESS-M3-002 | 20K | Gate M4 | +| TESS-M4-001 | not-started | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | codex | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | TESS-MOS-001 | +| TESS-M4-002 | not-started | Implement transitional Hermes runtime/capability adapter | #710 | codex | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | TESS-HRM-001; no legacy schema in core contracts | +| TESS-M4-003 | not-started | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | codex | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | TESS-MEM-001, TESS-PLG-001 | +| TESS-M4-V | not-started | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003 | 22K | Gate M5 | +| TESS-M5-001 | not-started | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | codex | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001 | +| TESS-M5-002 | not-started | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | sonnet | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001 | +| TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate | +| TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence | diff --git a/docs/tess/THREAT-MODEL.md b/docs/tess/THREAT-MODEL.md new file mode 100644 index 0000000..c30b109 --- /dev/null +++ b/docs/tess/THREAT-MODEL.md @@ -0,0 +1,46 @@ +# Tess Threat Model + +## Assets and Trust Boundaries + +Assets: operator identity, tenant/project data, agent sessions, fleet control, approvals, credentials, memories, tool outputs, audit evidence, and provider transports. + +Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service identity, gateway→Pi/provider, Tess→Mos/fleet, Tess→Hermes, MCP→gateway, persistence, and tmux/Matrix transports. + +## Threat Matrix + +| ID | Severity | Threat | Required control | Required verification | +| --- | --- | --- | --- | --- | +| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit | +| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods | +| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned | +| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny | +| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel | +| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority | +| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit | +| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe | +| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged | +| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach | +| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed | +| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim | + +## Security Invariants + +1. Authentication is not authorization; every command/tool/provider operation is authorized server-side. +2. Actor, tenant, roles, and channel bindings come only from authenticated gateway context. +3. No client-provided session ID grants ownership or attachment. +4. No privileged action executes without a matching, unexpired, one-time approval when policy requires it. +5. Redaction occurs before persistence and before channel egress. +6. Every externally caused operation is replay-safe and correlated. +7. Provider capability absence is a denial, not an invitation to shell around it. + +## Existing Findings That Block Tess + +- Command executor lacks server-side enforcement for declared scopes. +- Session list/reuse/destroy surfaces are not owner-filtered consistently. +- MCP schemas accept caller-supplied user identity. +- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists. +- Chat/tool persistence lacks mandatory redaction. +- Sessions/pending Discord output are in-memory and not restart-safe. +- Session GC currently performs globally scoped promotion. + +These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled. diff --git a/docs/tess/VERIFICATION-MATRIX.md b/docs/tess/VERIFICATION-MATRIX.md new file mode 100644 index 0000000..e8c1202 --- /dev/null +++ b/docs/tess/VERIFICATION-MATRIX.md @@ -0,0 +1,30 @@ +# Tess Verification Matrix + +| Acceptance criterion | Requirements | Planned evidence | Gate | +| --- | --- | --- | --- | +| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | +| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | +| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | +| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | +| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | +| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | +| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | +| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | +| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | +| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | +| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | + +## Security Abuse Suite Minimum + +- Role/scope matrix for every command and provider capability. +- Cross-tenant and cross-user session ID matrix across REST, WS, Discord, CLI, MCP, and providers. +- Discord service identity, guild/channel/user allowlist, replay, attachment, and mention/DM policy cases. +- Prompt/tool injection corpus and structured-proposal enforcement. +- Approval action-digest mutation, replay, expiry, tenant, and actor mismatch cases. +- Secret/PII canaries through message, attachment, tool args/output, logs, memory, audit, and error paths. +- Restart fault injection before/after enqueue, provider send, side effect, response persistence, and acknowledgement. +- Wrong tmux socket/target and Matrix identity/room/replay cases. + +## Evidence Rules + +Evidence must include command/test name, terminal result, CI run URL, PR/merge reference, environment, and artifact/log location. A worker self-report is not evidence until independently verified. -- 2.49.1 From b580d37d51bf3fc570dce27f70123fea2f2b2559 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 20:49:43 +0000 Subject: [PATCH 02/16] Fixes #703 (#705) --- .../703-wrapper-interactive-auth.md | 49 +++++++++++ .../framework/tools/git/detect-platform.sh | 27 ++++++ .../framework/tools/git/issue-create.sh | 19 ++++ .../mosaic/framework/tools/git/pr-create.sh | 5 ++ .../tools/git/test-gitea-login-resolution.sh | 5 ++ .../git/test-issue-create-body-safety.sh | 5 ++ .../git/test-issue-create-interactive-auth.sh | 88 +++++++++++++++++++ 7 files changed, 198 insertions(+) create mode 100644 docs/scratchpads/703-wrapper-interactive-auth.md create mode 100755 packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh diff --git a/docs/scratchpads/703-wrapper-interactive-auth.md b/docs/scratchpads/703-wrapper-interactive-auth.md new file mode 100644 index 0000000..cbc3720 --- /dev/null +++ b/docs/scratchpads/703-wrapper-interactive-auth.md @@ -0,0 +1,49 @@ +# #703 Git Wrapper Interactive and Auth Resilience + +## Objective + +Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback. + +## Scope + +- `packages/mosaic/framework/tools/git/issue-create.sh` +- `packages/mosaic/framework/tools/git/detect-platform.sh` +- Git wrapper regression harnesses +- This scratchpad + +## Requirements / acceptance evidence + +1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials. +2. Explicit command-line fields retain precedence and do not trigger prompt input. +3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity. +4. A Tea `GetUserByName` failure falls back to authenticated API creation. +5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green. +6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end. + +## Plan + +1. Add failing shell regression harness for interactive input and stale Tea user fallback. +2. Implement minimal helper and parser changes. +3. Run wrapper harnesses, syntax checks, and repository baseline checks. +4. Re-seed deployed framework and run live wrapper verification. +5. Commit, queue guard, push, open PR, and stop for independent review. + +## Progress + +- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger. +- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1). +- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition. +- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation. +- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched. +- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`. + +## Verification + +- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh` +- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh` +- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh` +- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` +- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` +- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh` +- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh` +- PASS: Prettier check for this scratchpad diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 626e1a0..7519f11 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -240,6 +240,33 @@ get_gitea_login_for_host() { return 1 } +# Validate the current authenticated Gitea user for a resolved Tea login. +# Tea stores a user name with each login which can become stale after user rename, +# token rotation, or server migration. Querying /user derives the identity from the +# active credential instead of trusting that saved name. Callers fall back to the +# host-scoped API path when this validation fails. +get_gitea_authenticated_user() { + local login_name="$1" response + + 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' +import json +import os + +try: + user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"]) +except (KeyError, json.JSONDecodeError): + raise SystemExit(1) + +login = user.get("login") if isinstance(user, dict) else None +if isinstance(login, str) and login: + print(login) + raise SystemExit(0) +raise SystemExit(1) +PY +} + get_default_tea_login() { local logins_json diff --git a/packages/mosaic/framework/tools/git/issue-create.sh b/packages/mosaic/framework/tools/git/issue-create.sh index f92f976..96f890c 100755 --- a/packages/mosaic/framework/tools/git/issue-create.sh +++ b/packages/mosaic/framework/tools/git/issue-create.sh @@ -12,6 +12,7 @@ TITLE="" BODY="" LABELS="" MILESTONE="" +INTERACTIVE=false # get_remote_host and get_gitea_token are provided by detect-platform.sh @@ -66,11 +67,13 @@ Options: -b, --body BODY Issue body/description -l, --labels LABELS Comma-separated labels (e.g., "bug,feature") -m, --milestone NAME Milestone name to assign + -i, --interactive Prompt for missing issue fields -h, --help Show this help message Examples: $(basename "$0") -t "Fix login bug" -l "bug,priority-high" $(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0" + $(basename "$0") -i EOF exit "${1:-1}" } @@ -94,6 +97,10 @@ while [[ $# -gt 0 ]]; do MILESTONE="$2" shift 2 ;; + -i|--interactive) + INTERACTIVE=true + shift + ;; -h|--help) usage 0 ;; @@ -104,6 +111,13 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$INTERACTIVE" == true ]]; then + [[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE + [[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true + [[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true + [[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true +fi + if [[ -z "$TITLE" ]]; then echo "Error: Title is required (-t)" >&2 usage @@ -127,6 +141,11 @@ case "$PLATFORM" in gitea_issue_create_api exit $? } + if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then + echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2 + gitea_issue_create_api + exit $? + fi REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE") [[ -n "$BODY" ]] && CMD+=(--description "$BODY") diff --git a/packages/mosaic/framework/tools/git/pr-create.sh b/packages/mosaic/framework/tools/git/pr-create.sh index 9560f1d..46b82a6 100755 --- a/packages/mosaic/framework/tools/git/pr-create.sh +++ b/packages/mosaic/framework/tools/git/pr-create.sh @@ -183,6 +183,11 @@ case "$PLATFORM" in gitea_pr_create_api exit $? } + if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then + echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2 + gitea_pr_create_api + exit $? + fi REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE") [[ -n "$BODY" ]] && CMD+=(--description "$BODY") diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index b767084..4eb495a 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -45,6 +45,11 @@ JSON exit 0 fi +if [[ "${1:-}" == "api" ]]; then + printf '%s\n' '{"login":"ci-bot"}' + exit 0 +fi + printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG" if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then echo 'GetUserByName: simulated stale login failure' >&2 diff --git a/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh b/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh index 4366020..1c95c3c 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh @@ -55,6 +55,11 @@ JSON exit 0 fi +if [[ "${1:-}" == "api" ]]; then + printf '%s\n' '{"login":"ci-bot"}' + exit 0 +fi + if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then desc="" while [[ $# -gt 0 ]]; do diff --git a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh new file mode 100755 index 0000000..496e2c4 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Regression harness for #703: interactive issue creation and stale Tea-user fallback. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}" +REPO_DIR="$WORK_DIR/repo" +BIN_DIR="$WORK_DIR/bin" +LOG_FILE="$WORK_DIR/calls.log" +CREDENTIALS_FILE="$WORK_DIR/credentials.json" + +rm -rf "$WORK_DIR" +mkdir -p "$REPO_DIR" "$BIN_DIR" +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git + +cat > "$CREDENTIALS_FILE" <<'JSON' +{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}} +JSON + +cat > "$BIN_DIR/tea" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "login list --output json" ]]; then + printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]' + exit 0 +fi +if [[ "${1:-}" == "api" ]]; then + if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then + echo 'GetUserByName: stale configured user' >&2 + exit 1 + fi + printf '%s\n' '{"login":"current-user"}' + exit 0 +fi +printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG" +exit 0 +SH + +cat > "$BIN_DIR/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG" +printf '%s\n' '{"number":703}' +SH +chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" + +run_wrapper() { + ( + cd "$REPO_DIR" + PATH="$BIN_DIR:$PATH" \ + MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ + MOSAIC_TEST_LOG="$LOG_FILE" \ + "$@" + ) +} + +: > "$LOG_FILE" +printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null + +grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE" + +# Explicit values take precedence in interactive mode: no title input is +# supplied, but the wrapper still creates the issue with the explicit title. +: > "$LOG_FILE" +printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null +grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE" + +: > "$LOG_FILE" +run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr" +grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE" +grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr" +if grep -q -- 'tea issue create' "$LOG_FILE"; then + echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2 + exit 1 +fi + +: > "$LOG_FILE" +run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr" +grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE" +grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr" +if grep -q -- 'tea pr create' "$LOG_FILE"; then + echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2 + exit 1 +fi + +echo 'issue-create interactive/auth regression harness passed' -- 2.49.1 From ca9c2b5c23cdb5873f5b693efe00384715fee278 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 21:32:16 +0000 Subject: [PATCH 03/16] restore Tess markdown formatting gate (v2, non-author) (#714) --- .prettierignore | 2 ++ docs/MISSION-MANIFEST.md | 8 +++--- docs/PRD.md | 2 +- docs/TASKS.md | 8 +++--- docs/tess/ARCHITECTURE.md | 12 ++++----- docs/tess/MIGRATION-INVENTORY.md | 42 ++++++++++++++++---------------- docs/tess/MISSION-MANIFEST.md | 20 +++++++-------- docs/tess/THREAT-MODEL.md | 28 ++++++++++----------- docs/tess/VERIFICATION-MATRIX.md | 26 ++++++++++---------- 9 files changed, 75 insertions(+), 73 deletions(-) diff --git a/.prettierignore b/.prettierignore index dddb198..feca27f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,5 @@ pnpm-lock.yaml **/drizzle **/.next .claude/ +docs/tess/TASKS.md +docs/scratchpads/ diff --git a/docs/MISSION-MANIFEST.md b/docs/MISSION-MANIFEST.md index 72b1edd..ce80c48 100644 --- a/docs/MISSION-MANIFEST.md +++ b/docs/MISSION-MANIFEST.md @@ -67,10 +67,10 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c ## Workstreams -| # | ID | Name | Status | Manifest | Notes | -| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | -| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | -| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | +| # | ID | Name | Status | Manifest | Notes | +| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | +| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | +| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | | W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | ### Likely Additional Workstreams (Not Yet Declared) diff --git a/docs/PRD.md b/docs/PRD.md index 55666f6..6589f52 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -85,7 +85,7 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent. -The objective is to ship **Tess** (from *tessera*, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency. +The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency. ### Scope diff --git a/docs/TASKS.md b/docs/TASKS.md index 1f2015b..abe7fe0 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -14,10 +14,10 @@ ## Workstream Rollup -| id | status | workstream | progress | tasks file | notes | -| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | -| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | -| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | +| id | status | workstream | progress | tasks file | notes | +| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | +| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | ## Cross-Cutting Tracking diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 841a467..180cf0a 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -39,12 +39,12 @@ Every call receives an immutable, server-derived actor/tenant/channel scope and ## Authority Model -| Intent | Owner | Tess behavior | -| --- | --- | --- | -| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy | -| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result | -| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | -| Provider-specific unsupported action | None | Fail closed; never emulate silently | +| Intent | Owner | Tess behavior | +| --------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------- | +| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy | +| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result | +| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | +| Provider-specific unsupported action | None | Fail closed; never emulate silently | ## Session and State Model diff --git a/docs/tess/MIGRATION-INVENTORY.md b/docs/tess/MIGRATION-INVENTORY.md index eb46a6e..f6d75fb 100644 --- a/docs/tess/MIGRATION-INVENTORY.md +++ b/docs/tess/MIGRATION-INVENTORY.md @@ -2,27 +2,27 @@ Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover. -| Capability | Current source | Target | Initial status | Cutover/rollback intent | -| --- | --- | --- | --- | --- | -| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway | -| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak | -| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback | -| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration | -| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport | -| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit | -| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated | -| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time | -| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field | -| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors | -| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot | -| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover | -| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes | -| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback | -| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval | -| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations | -| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy | -| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release | -| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback | +| Capability | Current source | Target | Initial status | Cutover/rollback intent | +| ---------------------------------------- | ---------------------------------------- | ------------------------------------------ | -------------- | ----------------------------------------------------------------------- | +| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway | +| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak | +| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback | +| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration | +| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport | +| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit | +| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated | +| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time | +| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field | +| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors | +| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot | +| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover | +| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes | +| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback | +| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval | +| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations | +| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy | +| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release | +| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback | ## Cutover Gates diff --git a/docs/tess/MISSION-MANIFEST.md b/docs/tess/MISSION-MANIFEST.md index 4e22d5a..19c8bd6 100644 --- a/docs/tess/MISSION-MANIFEST.md +++ b/docs/tess/MISSION-MANIFEST.md @@ -27,13 +27,13 @@ Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction ag ## Milestones -| ID | Issue | Name | Status | Exit gate | -| --- | --- | --- | --- | --- | -| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | -| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | -| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | -| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | -| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete | +| ID | Issue | Name | Status | Exit gate | +| ------- | ----- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- | +| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | +| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | +| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | +| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | +| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete | ## Success Criteria @@ -41,6 +41,6 @@ All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. T ## Session History -| Session | Date | Runtime | Outcome | -| --- | --- | --- | --- | -| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. | +| Session | Date | Runtime | Outcome | +| ------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. | diff --git a/docs/tess/THREAT-MODEL.md b/docs/tess/THREAT-MODEL.md index c30b109..8e6a79c 100644 --- a/docs/tess/THREAT-MODEL.md +++ b/docs/tess/THREAT-MODEL.md @@ -8,20 +8,20 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden ## Threat Matrix -| ID | Severity | Threat | Required control | Required verification | -| --- | --- | --- | --- | --- | -| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit | -| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods | -| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned | -| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny | -| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel | -| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority | -| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit | -| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe | -| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged | -| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach | -| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed | -| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim | +| ID | Severity | Threat | Required control | Required verification | +| ----- | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit | +| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods | +| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned | +| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny | +| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel | +| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority | +| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit | +| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe | +| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged | +| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach | +| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed | +| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim | ## Security Invariants diff --git a/docs/tess/VERIFICATION-MATRIX.md b/docs/tess/VERIFICATION-MATRIX.md index e8c1202..82864aa 100644 --- a/docs/tess/VERIFICATION-MATRIX.md +++ b/docs/tess/VERIFICATION-MATRIX.md @@ -1,18 +1,18 @@ # Tess Verification Matrix -| Acceptance criterion | Requirements | Planned evidence | Gate | -| --- | --- | --- | --- | -| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | -| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | -| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | -| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | -| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | -| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | -| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | -| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | -| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | -| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | -| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | +| Acceptance criterion | Requirements | Planned evidence | Gate | +| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | +| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | +| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | +| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | +| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | +| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | +| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | +| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | +| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | +| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | +| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | ## Security Abuse Suite Minimum -- 2.49.1 From 353e43c94779234e82242199293971511bfb47a7 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 22:14:17 +0000 Subject: [PATCH 04/16] fix: enforce Tess session ownership scope (#715) --- .../src/admin/admin-health.controller.ts | 2 +- .../__tests__/agent-service-ownership.test.ts | 142 ++++++++++ .../agent/__tests__/session-ownership.test.ts | 262 ++++++++++++++++++ apps/gateway/src/agent/agent.service.ts | 159 ++++++++--- apps/gateway/src/agent/sessions.controller.ts | 17 +- apps/gateway/src/auth/session-scope.ts | 24 ++ .../src/chat/__tests__/chat-security.test.ts | 3 +- apps/gateway/src/chat/chat.controller.ts | 45 ++- apps/gateway/src/chat/chat.gateway.ts | 118 ++++++-- .../commands/command-executor-p8012.spec.ts | 29 +- .../src/commands/command-executor.service.ts | 35 ++- .../src/commands/commands.integration.spec.ts | 25 +- .../preferences/system-override.service.ts | 34 ++- 13 files changed, 750 insertions(+), 145 deletions(-) create mode 100644 apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts create mode 100644 apps/gateway/src/agent/__tests__/session-ownership.test.ts create mode 100644 apps/gateway/src/auth/session-scope.ts diff --git a/apps/gateway/src/admin/admin-health.controller.ts b/apps/gateway/src/admin/admin-health.controller.ts index 2331143..b20c760 100644 --- a/apps/gateway/src/admin/admin-health.controller.ts +++ b/apps/gateway/src/admin/admin-health.controller.ts @@ -20,7 +20,7 @@ export class AdminHealthController { async check(): Promise { const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]); - const sessions = this.agentService.listSessions(); + const sessions = this.agentService.listAllSessionsForSystem(); const providers = this.providerService.listProviders(); const allOk = database.status === 'ok' && cache.status === 'ok'; diff --git a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts new file mode 100644 index 0000000..b015155 --- /dev/null +++ b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts @@ -0,0 +1,142 @@ +import { ForbiddenException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { AgentService, type AgentSession } from '../agent.service.js'; +import type { ActorTenantScope } from '../../auth/session-scope.js'; + +const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222'; +const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' }; +const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' }; + +type AgentServiceInternals = { + sessions: Map; + creating: Map>; +}; + +function makeService(): AgentService { + return new AgentService( + {} as never, + {} as never, + {} as never, + { available: false } as never, + {} as never, + {} as never, + {} as never, + null, + null, + { collect: vi.fn().mockResolvedValue(undefined) } as never, + ); +} + +function internals(service: AgentService): AgentServiceInternals { + return service as unknown as AgentServiceInternals; +} + +function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'off', + getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']), + setThinkingLevel: vi.fn(), + abort: vi.fn().mockResolvedValue(undefined), + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + getSessionStats: vi.fn(), + getContextUsage: vi.fn(), + } as unknown as AgentSession['piSession'], + listeners: new Set(), + unsubscribe: vi.fn(), + createdAt: Date.now(), + promptCount: 0, + channels: new Set(), + skillPromptAdditions: [], + sandboxDir: '/tmp/tess-session-ownership-test', + allowedTools: null, + userId: scope.userId, + tenantId: scope.tenantId, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + }; +} + +describe('AgentService owner/tenant scope enforcement', () => { + it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => { + const service = makeService(); + const session = makeSession(); + internals(service).sessions.set(CONVERSATION_ID, session); + + expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session); + expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined(); + expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined(); + expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1); + expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]); + + service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE); + expect(session.channels.has('websocket:owner')).toBe(true); + expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + + expect(() => + service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE), + ).toThrow(ForbiddenException); + service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE); + expect(session.modelId).toBe('owner-model'); + + expect(() => + service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE), + ).toThrow(ForbiddenException); + service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE); + expect(session.agentConfigId).toBe('agent-owner'); + + expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE); + cleanup(); + + await expect( + service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE), + ).rejects.toBeInstanceOf(ForbiddenException); + await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); + expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); + + await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true); + + await service.destroySession(CONVERSATION_ID, OWNER_SCOPE); + expect(session.piSession.dispose).toHaveBeenCalled(); + expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false); + }); + + it('checks owner/tenant scope before returning an in-flight session creation', async () => { + const service = makeService(); + const session = makeSession(); + internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session)); + + await expect( + service.createSession(CONVERSATION_ID, { + userId: FOREIGN_SCOPE.userId, + tenantId: FOREIGN_SCOPE.tenantId, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + + await expect( + service.createSession(CONVERSATION_ID, { + userId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + }), + ).resolves.toBe(session); + }); +}); diff --git a/apps/gateway/src/agent/__tests__/session-ownership.test.ts b/apps/gateway/src/agent/__tests__/session-ownership.test.ts new file mode 100644 index 0000000..4fc0a28 --- /dev/null +++ b/apps/gateway/src/agent/__tests__/session-ownership.test.ts @@ -0,0 +1,262 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} })); +vi.mock('../../commands/command-executor.service.js', () => ({ + CommandExecutorService: class CommandExecutorService {}, +})); +vi.mock('../routing/routing-engine.service.js', () => ({ + RoutingEngineService: class RoutingEngineService {}, +})); + +import { SessionsController } from '../sessions.controller.js'; +import { ChatController } from '../../chat/chat.controller.js'; +import { ChatGateway } from '../../chat/chat.gateway.js'; +import type { AgentSession } from '../agent.service.js'; +import type { SessionInfoDto } from '../session.dto.js'; + +const USER_A = { id: 'user-a', tenantId: 'tenant-a' }; +const USER_B = { id: 'user-b', tenantId: 'tenant-b' }; +const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111'; + +function makeSessionInfo(overrides?: Partial): SessionInfoDto { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + createdAt: new Date('2026-07-12T00:00:00Z').toISOString(), + promptCount: 0, + channels: [], + durationMs: 0, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + ...overrides, + }; +} + +function makeAgentSession(owner = USER_A): AgentSession { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'off', + getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']), + setThinkingLevel: vi.fn(), + abort: vi.fn().mockResolvedValue(undefined), + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + getSessionStats: vi.fn(), + getContextUsage: vi.fn(), + } as unknown as AgentSession['piSession'], + listeners: new Set(), + unsubscribe: vi.fn(), + createdAt: Date.now(), + promptCount: 0, + channels: new Set(), + skillPromptAdditions: [], + sandboxDir: '/tmp', + allowedTools: null, + userId: owner.id, + tenantId: owner.tenantId, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + }; +} + +function makeScopedAgentService() { + const foreign = makeAgentSession(USER_A); + return { + listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })], + ), + getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }), + ), + destroySession: vi.fn(), + getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? undefined : foreign, + ), + createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')), + onEvent: vi.fn(() => vi.fn()), + addChannel: vi.fn(), + removeChannel: vi.fn(), + recordMessage: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('TESS-M1-SEC-002 AgentService ownership boundary', () => { + it('requires explicit owner+tenant scope on protected session operations', () => { + const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8'); + + expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)'); + expect(source).toContain('listSessions(scope: ActorTenantScope)'); + expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)'); + expect(source).toContain( + 'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)', + ); + expect(source).toContain( + 'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)', + ); + expect(source).toContain( + 'async prompt(sessionId: string, message: string, scope: ActorTenantScope)', + ); + expect(source).toContain('scope: ActorTenantScope,'); + expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)'); + expect(source).not.toContain('scope?: ActorTenantScope'); + }); +}); + +describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => { + it('lists only sessions owned by the authenticated owner+tenant scope', () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 }); + expect(agentService.listSessions).toHaveBeenCalledWith({ + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + }); + + it('does not reveal another owner/tenant session by guessed id', () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException); + expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + }); + + it('does not terminate another owner/tenant session by guessed id', async () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(agentService.destroySession).not.toHaveBeenCalled(); + }); +}); + +describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => { + it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => { + const agentService = makeScopedAgentService(); + const controller = new ChatController(agentService as never); + + await expect( + controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B), + ).rejects.toMatchObject({ status: 404 }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(agentService.prompt).not.toHaveBeenCalled(); + }); +}); + +describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => { + function makeGateway(agentService = makeScopedAgentService()) { + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) }; + const commandExecutor = { execute: vi.fn() }; + const routingEngine = { + resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }), + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + commandRegistry as never, + commandExecutor as never, + routingEngine as never, + ); + return { gateway, agentService }; + } + + function makeSocket() { + return { + id: 'socket-b', + connected: true, + data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } }, + emit: vi.fn(), + disconnect: vi.fn(), + }; + } + + it('does not attach or send to another owner/tenant session by guessed conversationId', async () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + await gateway.handleMessage(socket as never, { + conversationId: CONVERSATION_ID, + content: 'attach to foreign session', + }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(agentService.onEvent).not.toHaveBeenCalled(); + expect(agentService.addChannel).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); + + it('does not mutate thinking level on another owner/tenant session', () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); + + it('does not terminate another owner/tenant session over WebSocket abort', async () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); +}); diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 83cb057..f5b2347 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -1,4 +1,11 @@ -import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common'; +import { + ForbiddenException, + Inject, + Injectable, + Logger, + Optional, + type OnModuleDestroy, +} from '@nestjs/common'; import { createAgentSession, DefaultResourceLoader, @@ -28,6 +35,7 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; import { PreferencesService } from '../preferences/preferences.service.js'; import { SessionGCService } from '../gc/session-gc.service.js'; +import type { ActorTenantScope } from '../auth/session-scope.js'; /** A single message from DB conversation history, used for context injection. */ export interface ConversationHistoryMessage { @@ -68,6 +76,8 @@ export interface AgentSessionOptions { agentConfigId?: string; /** ID of the user who owns this session. Used for preferences and system override lookups. */ userId?: string; + /** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */ + tenantId?: string; /** * Prior conversation messages to inject as context when resuming a session. * These messages are formatted and prepended to the system prompt so the @@ -94,6 +104,8 @@ export interface AgentSession { allowedTools: string[] | null; /** User ID that owns this session, used for preference lookups. */ userId?: string; + /** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */ + tenantId?: string; /** Agent config ID applied to this session, if any (M5-001). */ agentConfigId?: string; /** Human-readable agent name applied to this session, if any (M5-001). */ @@ -174,12 +186,20 @@ export class AgentService implements OnModuleDestroy { .filter((t) => t.length > 0); } - async createSession(sessionId: string, options?: AgentSessionOptions): Promise { + async createSession(sessionId: string, options: AgentSessionOptions): Promise { + const scope = this.scopeFromOptions(options); const existing = this.sessions.get(sessionId); - if (existing) return existing; + if (existing) { + this.assertSessionScope(existing, scope); + return existing; + } const inflight = this.creating.get(sessionId); - if (inflight) return inflight; + if (inflight) { + const session = await inflight; + this.assertSessionScope(session, scope); + return session; + } const promise = this.doCreateSession(sessionId, options).finally(() => { this.creating.delete(sessionId); @@ -342,6 +362,7 @@ export class AgentService implements OnModuleDestroy { sandboxDir, allowedTools, userId: mergedOptions?.userId, + tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId), agentConfigId: mergedOptions?.agentConfigId, agentName: resolvedAgentName, metrics: { @@ -473,38 +494,70 @@ export class AgentService implements OnModuleDestroy { return this.providerService.getDefaultModel() ?? null; } - getSession(sessionId: string): AgentSession | undefined { - return this.sessions.get(sessionId); + getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined { + const session = this.sessions.get(sessionId); + if (!session || !this.sessionMatchesScope(session, scope)) return undefined; + return session; } - listSessions(): SessionInfoDto[] { + listSessions(scope: ActorTenantScope): SessionInfoDto[] { const now = Date.now(); - return Array.from(this.sessions.values()).map((s) => ({ - id: s.id, - provider: s.provider, - modelId: s.modelId, - ...(s.agentName ? { agentName: s.agentName } : {}), - createdAt: new Date(s.createdAt).toISOString(), - promptCount: s.promptCount, - channels: Array.from(s.channels), - durationMs: now - s.createdAt, - metrics: { ...s.metrics }, - })); + return Array.from(this.sessions.values()) + .filter((s) => this.sessionMatchesScope(s, scope)) + .map((s) => this.toSessionInfo(s, now)); } - getSessionInfo(sessionId: string): SessionInfoDto | undefined { + listAllSessionsForSystem(): SessionInfoDto[] { + const now = Date.now(); + return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now)); + } + + getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined { const s = this.sessions.get(sessionId); - if (!s) return undefined; + if (!s || !this.sessionMatchesScope(s, scope)) return undefined; + return this.toSessionInfo(s); + } + + private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope { + if (!options.userId) { + throw new ForbiddenException('Session owner scope is required'); + } return { - id: s.id, - provider: s.provider, - modelId: s.modelId, - ...(s.agentName ? { agentName: s.agentName } : {}), - createdAt: new Date(s.createdAt).toISOString(), - promptCount: s.promptCount, - channels: Array.from(s.channels), - durationMs: Date.now() - s.createdAt, - metrics: { ...s.metrics }, + userId: options.userId, + tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId, + }; + } + + private tenantIdFor( + userId: string | undefined, + tenantId: string | undefined, + ): string | undefined { + return tenantId ?? userId; + } + + private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean { + return ( + session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId + ); + } + + private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void { + if (!this.sessionMatchesScope(session, scope)) { + throw new ForbiddenException('Session does not belong to the current owner/tenant scope'); + } + } + + private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto { + return { + id: session.id, + provider: session.provider, + modelId: session.modelId, + ...(session.agentName ? { agentName: session.agentName } : {}), + createdAt: new Date(session.createdAt).toISOString(), + promptCount: session.promptCount, + channels: Array.from(session.channels), + durationMs: now - session.createdAt, + metrics: { ...session.metrics }, }; } @@ -553,9 +606,10 @@ export class AgentService implements OnModuleDestroy { * not reconstructed — the model is used on the next createSession call for * the same conversationId when the session is torn down or a new one is created. */ - updateSessionModel(sessionId: string, modelId: string): void { + updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); if (!session) return; + this.assertSessionScope(session, scope); const prev = session.modelId; session.modelId = modelId; this.recordModelSwitch(sessionId); @@ -572,48 +626,51 @@ export class AgentService implements OnModuleDestroy { sessionId: string, agentConfigId: string, agentName: string, + scope: ActorTenantScope, modelId?: string, ): void { const session = this.sessions.get(sessionId); if (!session) return; + this.assertSessionScope(session, scope); session.agentConfigId = agentConfigId; session.agentName = agentName; if (modelId) { - this.updateSessionModel(sessionId, modelId); + this.updateSessionModel(sessionId, modelId, scope); } this.logger.log( `Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`, ); } - addChannel(sessionId: string, channel: string): void { + addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); - if (session) { - session.channels.add(channel); - } + if (!session) return; + this.assertSessionScope(session, scope); + session.channels.add(channel); } - removeChannel(sessionId: string, channel: string): void { + removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); - if (session) { - session.channels.delete(channel); - } + if (!session) return; + this.assertSessionScope(session, scope); + session.channels.delete(channel); } - async prompt(sessionId: string, message: string): Promise { + async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); } + this.assertSessionScope(session, scope); session.promptCount += 1; // Prepend session-scoped system override if present (renew TTL on each turn) let effectiveMessage = message; if (this.systemOverride) { - const override = await this.systemOverride.get(sessionId); + const override = await this.systemOverride.get(sessionId, scope); if (override) { effectiveMessage = `[System Override]\n${override}\n\n${message}`; - await this.systemOverride.renew(sessionId); + await this.systemOverride.renew(sessionId, scope); this.logger.debug(`Applied system override for session ${sessionId}`); } } @@ -629,16 +686,28 @@ export class AgentService implements OnModuleDestroy { } } - onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void { + onEvent( + sessionId: string, + listener: (event: AgentSessionEvent) => void, + scope: ActorTenantScope, + ): () => void { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); } + this.assertSessionScope(session, scope); session.listeners.add(listener); return () => session.listeners.delete(listener); } - async destroySession(sessionId: string): Promise { + async destroySession(sessionId: string, scope: ActorTenantScope): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + this.assertSessionScope(session, scope); + await this.destroySessionForSystem(sessionId); + } + + private async destroySessionForSystem(sessionId: string): Promise { const session = this.sessions.get(sessionId); if (!session) return; this.logger.log(`Destroying agent session ${sessionId}`); @@ -667,7 +736,7 @@ export class AgentService implements OnModuleDestroy { async onModuleDestroy(): Promise { this.logger.log('Shutting down all agent sessions'); - const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id)); + const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id)); const results = await Promise.allSettled(stops); for (const result of results) { if (result.status === 'rejected') { diff --git a/apps/gateway/src/agent/sessions.controller.ts b/apps/gateway/src/agent/sessions.controller.ts index be6fd37..efd1fe8 100644 --- a/apps/gateway/src/agent/sessions.controller.ts +++ b/apps/gateway/src/agent/sessions.controller.ts @@ -10,6 +10,8 @@ import { UseGuards, } from '@nestjs/common'; import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import { AgentService } from './agent.service.js'; @Controller('api/sessions') @@ -18,23 +20,24 @@ export class SessionsController { constructor(@Inject(AgentService) private readonly agentService: AgentService) {} @Get() - list() { - const sessions = this.agentService.listSessions(); + list(@CurrentUser() user: AuthenticatedUserLike) { + const sessions = this.agentService.listSessions(scopeFromUser(user)); return { sessions, total: sessions.length }; } @Get(':id') - findOne(@Param('id') id: string) { - const info = this.agentService.getSessionInfo(id); + findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) { + const info = this.agentService.getSessionInfo(id, scopeFromUser(user)); if (!info) throw new NotFoundException('Session not found'); return info; } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - async destroy(@Param('id') id: string) { - const info = this.agentService.getSessionInfo(id); + async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) { + const scope = scopeFromUser(user); + const info = this.agentService.getSessionInfo(id, scope); if (!info) throw new NotFoundException('Session not found'); - await this.agentService.destroySession(id); + await this.agentService.destroySession(id, scope); } } diff --git a/apps/gateway/src/auth/session-scope.ts b/apps/gateway/src/auth/session-scope.ts new file mode 100644 index 0000000..9e975d9 --- /dev/null +++ b/apps/gateway/src/auth/session-scope.ts @@ -0,0 +1,24 @@ +export interface AuthenticatedUserLike { + id: string; + tenantId?: string | null; + teamId?: string | null; + organizationId?: string | null; + orgId?: string | null; +} + +export interface ActorTenantScope { + userId: string; + tenantId: string; +} + +/** + * Build the immutable server-derived scope used for Tess session operations. + * Current Mosaic auth is user-scoped; future org/team claims can populate one + * of the tenant fields without allowing clients to choose another tenant. + */ +export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope { + return { + userId: user.id, + tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id, + }; +} diff --git a/apps/gateway/src/chat/__tests__/chat-security.test.ts b/apps/gateway/src/chat/__tests__/chat-security.test.ts index 0871000..45bd1f7 100644 --- a/apps/gateway/src/chat/__tests__/chat-security.test.ts +++ b/apps/gateway/src/chat/__tests__/chat-security.test.ts @@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => { const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); expect(source).toContain('@UseGuards(AuthGuard)'); - expect(source).toContain('@CurrentUser() user: { id: string }'); + expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike'); + expect(source).toContain('const scope = scopeFromUser(user);'); }); }); diff --git a/apps/gateway/src/chat/chat.controller.ts b/apps/gateway/src/chat/chat.controller.ts index bcfe915..7cd0bab 100644 --- a/apps/gateway/src/chat/chat.controller.ts +++ b/apps/gateway/src/chat/chat.controller.ts @@ -3,8 +3,10 @@ import { Post, Body, Logger, + ForbiddenException, HttpException, HttpStatus, + NotFoundException, Inject, UseGuards, } from '@nestjs/common'; @@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler'; import { AgentService } from '../agent/agent.service.js'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import { v4 as uuid } from 'uuid'; import { ChatRequestDto } from './chat.dto.js'; @@ -32,16 +35,23 @@ export class ChatController { @Throttle({ default: { limit: 10, ttl: 60_000 } }) async chat( @Body() body: ChatRequestDto, - @CurrentUser() user: { id: string }, + @CurrentUser() user: AuthenticatedUserLike, ): Promise { const conversationId = body.conversationId ?? uuid(); + const scope = scopeFromUser(user); try { - let agentSession = this.agentService.getSession(conversationId); + let agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) { - agentSession = await this.agentService.createSession(conversationId); + agentSession = await this.agentService.createSession(conversationId, { + userId: scope.userId, + tenantId: scope.tenantId, + }); } } catch (err) { + if (err instanceof ForbiddenException) { + throw new NotFoundException('Session not found'); + } this.logger.error( `Session creation failed for conversation=${conversationId}`, err instanceof Error ? err.stack : String(err), @@ -60,20 +70,27 @@ export class ChatController { reject(new Error('Agent response timed out')); }, 120_000); - const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => { - if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') { - responseText += event.assistantMessageEvent.delta; - } - if (event.type === 'agent_end') { - clearTimeout(timer); - cleanup(); - resolve(); - } - }); + const cleanup = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + if ( + event.type === 'message_update' && + event.assistantMessageEvent.type === 'text_delta' + ) { + responseText += event.assistantMessageEvent.delta; + } + if (event.type === 'agent_end') { + clearTimeout(timer); + cleanup(); + resolve(); + } + }, + scope, + ); }); try { - await this.agentService.prompt(conversationId, body.content); + await this.agentService.prompt(conversationId, body.content, scope); await done; } catch (err) { if (err instanceof HttpException) throw err; diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index fe0758c..5528e32 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -22,6 +22,11 @@ import type { } from '@mosaicstack/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AUTH } from '../auth/auth.tokens.js'; +import { + scopeFromUser, + type ActorTenantScope, + type AuthenticatedUserLike, +} from '../auth/session-scope.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { CommandRegistryService } from '../commands/command-registry.service.js'; import { CommandExecutorService } from '../commands/command-executor.service.js'; @@ -40,6 +45,8 @@ interface ClientSession { toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>; /** Tool calls in-flight (started but not ended yet). */ pendingToolCalls: Map; + /** Server-derived owner/tenant scope for this socket's conversation attachment. */ + scope: ActorTenantScope; /** Last routing decision made for this session (M4-008) */ lastRoutingDecision?: RoutingDecisionInfo; } @@ -97,25 +104,48 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const session = this.clientSessions.get(client.id); if (session) { session.cleanup(); - this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`); + this.agentService.removeChannel( + session.conversationId, + `websocket:${client.id}`, + session.scope, + ); this.clientSessions.delete(client.id); } } + private getClientScope(client: Socket): ActorTenantScope | null { + const user = client.data.user as AuthenticatedUserLike | undefined; + if (!user?.id) return null; + return scopeFromUser(user); + } + + private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string { + return `${scope.tenantId}:${scope.userId}:${conversationId}`; + } + + private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean { + return a.userId === b.userId && a.tenantId === b.tenantId; + } + @SubscribeMessage('message') async handleMessage( @ConnectedSocket() client: Socket, @MessageBody() data: ChatSocketMessageDto, ): Promise { const conversationId = data.conversationId ?? uuid(); - const userId = (client.data.user as { id: string } | undefined)?.id; + const scope = this.getClientScope(client); + if (!scope) { + client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); + return; + } + const userId = scope.userId; this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); // Ensure agent session exists for this conversation let sessionRoutingDecision: RoutingDecisionInfo | undefined; try { - let agentSession = this.agentService.getSession(conversationId); + let agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) { // When resuming an existing conversation, load prior messages to inject as context (M1-004) const conversationHistory = await this.loadConversationHistory(conversationId, userId); @@ -135,7 +165,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa let resolvedProvider = data.provider; let resolvedModelId = data.modelId; - const modelOverride = modelOverrides.get(conversationId); + const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope)); if (modelOverride) { // /model override bypasses routing engine (M4-007) resolvedModelId = modelOverride; @@ -172,6 +202,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa modelId: resolvedModelId, agentConfigId: data.agentId, userId, + tenantId: scope.tenantId, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, }); @@ -232,9 +263,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Subscribe to agent events and relay to client - const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => { - this.relayEvent(client, conversationId, event); - }); + const cleanup = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + this.relayEvent(client, conversationId, event); + }, + scope, + ); // Preserve routing decision from the existing client session if we didn't get a new one const prevClientSession = this.clientSessions.get(client.id); @@ -246,16 +281,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa assistantText: '', toolCalls: [], pendingToolCalls: new Map(), + scope, lastRoutingDecision: routingDecisionToStore, }); // Track channel connection - this.agentService.addChannel(conversationId, `websocket:${client.id}`); + this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope); // Send session info so the client knows the model/provider (M4-008: include routing decision) // Include agentName when a named agent config is active (M5-001) { - const agentSession = this.agentService.getSession(conversationId); + const agentSession = this.agentService.getSession(conversationId, scope); if (agentSession) { const piSession = agentSession.piSession; client.emit('session:info', { @@ -275,7 +311,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // Dispatch to agent try { - await this.agentService.prompt(conversationId, data.content); + await this.agentService.prompt(conversationId, data.content, scope); } catch (err) { this.logger.error( `Agent prompt failed for client=${client.id}, conversation=${conversationId}`, @@ -293,7 +329,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @ConnectedSocket() client: Socket, @MessageBody() data: SetThinkingPayload, ): void { - const session = this.agentService.getSession(data.conversationId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('error', { + conversationId: data.conversationId, + error: 'Authenticated user scope is required.', + }); + return; + } + + const session = this.agentService.getSession(data.conversationId, scope); if (!session) { client.emit('error', { conversationId: data.conversationId, @@ -334,7 +379,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const conversationId = data.conversationId; this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`); - const session = this.agentService.getSession(conversationId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); + return; + } + + const session = this.agentService.getSession(conversationId, scope); if (!session) { client.emit('error', { conversationId, @@ -363,8 +414,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @ConnectedSocket() client: Socket, @MessageBody() payload: SlashCommandPayload, ): Promise { - const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown'; - const result = await this.commandExecutor.execute(payload, userId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('command:result', { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Authenticated user scope is required.', + }); + return; + } + + const result = await this.commandExecutor.execute(payload, scope); client.emit('command:result', result); } @@ -380,18 +441,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * M5-005: Emits session:info to clients subscribed to this conversation when a model is set. * M5-007: Records a model switch in session metrics. */ - setModelOverride(conversationId: string, modelName: string | null): void { + setModelOverride( + conversationId: string, + modelName: string | null, + scope: ActorTenantScope, + ): void { + const key = this.modelOverrideKey(conversationId, scope); if (modelName) { - modelOverrides.set(conversationId, modelName); + modelOverrides.set(key, modelName); this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`); // M5-002: Update the live session's modelId so session:info reflects the new model immediately - this.agentService.updateSessionModel(conversationId, modelName); + this.agentService.updateSessionModel(conversationId, modelName, scope); // M5-005: Broadcast session:info to all clients subscribed to this conversation - this.broadcastSessionInfo(conversationId); + this.broadcastSessionInfo(conversationId, scope); } else { - modelOverrides.delete(conversationId); + modelOverrides.delete(key); this.logger.log(`Model override cleared: conversation=${conversationId}`); } } @@ -399,8 +465,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa /** * Return the active model override for a conversation, or undefined if none. */ - getModelOverride(conversationId: string): string | undefined { - return modelOverrides.get(conversationId); + getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined { + return modelOverrides.get(this.modelOverrideKey(conversationId, scope)); } /** @@ -409,9 +475,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa */ broadcastSessionInfo( conversationId: string, + scope: ActorTenantScope, extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo }, ): void { - const agentSession = this.agentService.getSession(conversationId); + const agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) return; const piSession = agentSession.piSession; @@ -428,7 +495,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // Emit to all clients currently subscribed to this conversation for (const [clientId, session] of this.clientSessions) { - if (session.conversationId === conversationId) { + if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { const socket = this.server.sockets.sockets.get(clientId); if (socket?.connected) { socket.emit('session:info', payload); @@ -550,7 +617,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'agent_end': { // Gather usage stats from the Pi session - const agentSession = this.agentService.getSession(conversationId); + const activeClientSession = this.clientSessions.get(client.id); + const agentSession = activeClientSession + ? this.agentService.getSession(conversationId, activeClientSession.scope) + : undefined; const piSession = agentSession?.piSession; const stats = piSession?.getSessionStats(); const contextUsage = piSession?.getContextUsage(); diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index d098682..242d2ac 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -89,6 +89,7 @@ function buildService(): CommandExecutorService { describe('CommandExecutorService — P8-012 commands', () => { let service: CommandExecutorService; const userId = 'user-123'; + const userScope = { userId, tenantId: userId }; const conversationId = 'conv-456'; beforeEach(() => { @@ -99,7 +100,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider login — missing provider name it('/provider login with no provider name returns usage error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Usage: /provider login'); expect(result.command).toBe('provider'); @@ -112,7 +113,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'login anthropic', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('provider'); expect(result.message).toContain('anthropic'); @@ -138,7 +139,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider with no args — returns usage it('/provider with no args returns usage message', async () => { const payload: SlashCommandPayload = { command: 'provider', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage: /provider'); }); @@ -146,7 +147,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider list it('/provider list returns success', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('provider'); }); @@ -154,7 +155,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider logout with no name — usage error it('/provider logout with no name returns error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Usage: /provider logout'); }); @@ -166,7 +167,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'unknown', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Unknown subcommand'); }); @@ -174,7 +175,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /mission status it('/mission status returns stub message', async () => { const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('mission'); expect(result.message).toContain('Mission status'); @@ -183,7 +184,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /mission with no args it('/mission with no args returns status stub', async () => { const payload: SlashCommandPayload = { command: 'mission', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Mission status'); }); @@ -195,7 +196,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'set my-mission-123', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('my-mission-123'); }); @@ -203,7 +204,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /agent list it('/agent list returns stub message', async () => { const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('agent'); expect(result.message).toContain('agent'); @@ -212,7 +213,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /agent with no args it('/agent with no args returns usage', async () => { const payload: SlashCommandPayload = { command: 'agent', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage: /agent'); }); @@ -224,7 +225,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'my-agent-id', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('my-agent-id'); }); @@ -232,7 +233,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /prdy it('/prdy returns PRD wizard message', async () => { const payload: SlashCommandPayload = { command: 'prdy', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('prdy'); expect(result.message).toContain('mosaic prdy'); @@ -241,7 +242,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /tools it('/tools returns tools stub message', async () => { const payload: SlashCommandPayload = { command: 'tools', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('tools'); expect(result.message).toContain('tools'); diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 678f0c1..f7fc0cc 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue'; import type { Brain } from '@mosaicstack/brain'; import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types'; import { AgentService } from '../agent/agent.service.js'; +import type { ActorTenantScope } from '../auth/session-scope.js'; import { ChatGateway } from '../chat/chat.gateway.js'; import { SessionGCService } from '../gc/session-gc.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; @@ -34,8 +35,12 @@ export class CommandExecutorService { private readonly mcpClient: McpClientService | null, ) {} - async execute(payload: SlashCommandPayload, userId: string): Promise { + async execute( + payload: SlashCommandPayload, + scope: ActorTenantScope, + ): Promise { const { command, args, conversationId } = payload; + const userId = scope.userId; const def = this.registry.getManifest().commands.find((c) => c.name === command); if (!def) { @@ -50,11 +55,11 @@ export class CommandExecutorService { try { switch (command) { case 'model': - return await this.handleModel(args ?? null, conversationId); + return await this.handleModel(args ?? null, conversationId, scope); case 'thinking': return await this.handleThinking(args ?? null, conversationId); case 'system': - return await this.handleSystem(args ?? null, conversationId); + return await this.handleSystem(args ?? null, conversationId, scope); case 'new': return { command, @@ -94,7 +99,7 @@ export class CommandExecutorService { }; } case 'agent': - return await this.handleAgent(args ?? null, conversationId, userId); + return await this.handleAgent(args ?? null, conversationId, scope); case 'provider': return await this.handleProvider(args ?? null, userId, conversationId); case 'mission': @@ -146,10 +151,11 @@ export class CommandExecutorService { private async handleModel( args: string | null, conversationId: string, + scope: ActorTenantScope, ): Promise { if (!args || args.trim().length === 0) { // Show current override or usage hint - const currentOverride = this.chatGateway?.getModelOverride(conversationId); + const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope); if (currentOverride) { return { command: 'model', @@ -171,7 +177,7 @@ export class CommandExecutorService { // /model clear removes the override and re-enables automatic routing if (modelName === 'clear') { - this.chatGateway?.setModelOverride(conversationId, null); + this.chatGateway?.setModelOverride(conversationId, null, scope); return { command: 'model', conversationId, @@ -181,9 +187,9 @@ export class CommandExecutorService { } // Set the sticky per-session override (M4-007) - this.chatGateway?.setModelOverride(conversationId, modelName); + this.chatGateway?.setModelOverride(conversationId, modelName, scope); - const session = this.agentService.getSession(conversationId); + const session = this.agentService.getSession(conversationId, scope); if (!session) { return { command: 'model', @@ -224,10 +230,11 @@ export class CommandExecutorService { private async handleSystem( args: string | null, conversationId: string, + scope: ActorTenantScope, ): Promise { if (!args || args.trim().length === 0) { // Clear the override when called with no args - await this.systemOverride.clear(conversationId); + await this.systemOverride.clear(conversationId, scope); return { command: 'system', conversationId, @@ -236,7 +243,7 @@ export class CommandExecutorService { }; } - await this.systemOverride.set(conversationId, args.trim()); + await this.systemOverride.set(conversationId, args.trim(), scope); return { command: 'system', conversationId, @@ -248,8 +255,9 @@ export class CommandExecutorService { private async handleAgent( args: string | null, conversationId: string, - userId: string, + scope: ActorTenantScope, ): Promise { + const userId = scope.userId; if (!args) { return { command: 'agent', @@ -338,11 +346,14 @@ export class CommandExecutorService { conversationId, agentConfig.id, agentConfig.name, + scope, agentConfig.model ?? undefined, ); // Broadcast updated session:info so TUI TopBar reflects new agent/model - this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name }); + this.chatGateway?.broadcastSessionInfo(conversationId, scope, { + agentName: agentConfig.name, + }); this.logger.log( `Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`, diff --git a/apps/gateway/src/commands/commands.integration.spec.ts b/apps/gateway/src/commands/commands.integration.spec.ts index 6571319..afa3cfa 100644 --- a/apps/gateway/src/commands/commands.integration.spec.ts +++ b/apps/gateway/src/commands/commands.integration.spec.ts @@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => { let registry: CommandRegistryService; let executor: CommandExecutorService; const userId = 'user-integ-001'; + const userScope = { userId, tenantId: userId }; const conversationId = 'conv-integ-001'; beforeEach(() => { @@ -170,7 +171,7 @@ describe('CommandExecutorService — integration', () => { // Unknown command returns error it('unknown command returns success:false with descriptive message', async () => { const payload: SlashCommandPayload = { command: 'nonexistent', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('nonexistent'); expect(result.command).toBe('nonexistent'); @@ -179,7 +180,7 @@ describe('CommandExecutorService — integration', () => { // /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg) it('/gc calls SessionGCService.sweepOrphans without arguments', async () => { const payload: SlashCommandPayload = { command: 'gc', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith(); expect(result.success).toBe(true); expect(result.message).toContain('GC sweep complete'); @@ -190,8 +191,8 @@ describe('CommandExecutorService — integration', () => { it('/system with text calls SystemOverrideService.set', async () => { const override = 'You are a helpful assistant.'; const payload: SlashCommandPayload = { command: 'system', args: override, conversationId }; - const result = await executor.execute(payload, userId); - expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override); + const result = await executor.execute(payload, userScope); + expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope); expect(result.success).toBe(true); expect(result.message).toContain('override set'); }); @@ -199,8 +200,8 @@ describe('CommandExecutorService — integration', () => { // /system with no args clears the override it('/system with no args calls SystemOverrideService.clear', async () => { const payload: SlashCommandPayload = { command: 'system', conversationId }; - const result = await executor.execute(payload, userId); - expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId); + const result = await executor.execute(payload, userScope); + expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope); expect(result.success).toBe(true); expect(result.message).toContain('cleared'); }); @@ -212,7 +213,7 @@ describe('CommandExecutorService — integration', () => { args: 'claude-3-opus', conversationId, }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('model'); expect(result.message).toContain('claude-3-opus'); @@ -221,7 +222,7 @@ describe('CommandExecutorService — integration', () => { // /thinking with valid level returns success it('/thinking with valid level returns success', async () => { const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('high'); }); @@ -229,7 +230,7 @@ describe('CommandExecutorService — integration', () => { // /thinking with invalid level returns usage message it('/thinking with invalid level returns usage message', async () => { const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage:'); }); @@ -237,7 +238,7 @@ describe('CommandExecutorService — integration', () => { // /new command returns success it('/new returns success', async () => { const payload: SlashCommandPayload = { command: 'new', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('new'); }); @@ -245,7 +246,7 @@ describe('CommandExecutorService — integration', () => { // /reload without reloadService returns failure it('/reload without ReloadService returns failure', async () => { const payload: SlashCommandPayload = { command: 'reload', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('ReloadService'); }); @@ -255,7 +256,7 @@ describe('CommandExecutorService — integration', () => { for (const cmd of stubCommands) { it(`/${cmd} returns success (stub)`, async () => { const payload: SlashCommandPayload = { command: cmd, conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe(cmd); }); diff --git a/apps/gateway/src/preferences/system-override.service.ts b/apps/gateway/src/preferences/system-override.service.ts index 5fa48da..2d64e81 100644 --- a/apps/gateway/src/preferences/system-override.service.ts +++ b/apps/gateway/src/preferences/system-override.service.ts @@ -1,9 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { createQueue, type QueueHandle } from '@mosaicstack/queue'; +import type { ActorTenantScope } from '../auth/session-scope.js'; -const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`; -const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) => - `mosaic:session:${sessionId}:system:fragments`; +const scopedSessionId = (sessionId: string, scope: ActorTenantScope) => + `${scope.tenantId}:${scope.userId}:${sessionId}`; +const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) => + `mosaic:session:${scopedSessionId(sessionId, scope)}:system`; +const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) => + `mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`; const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days interface OverrideFragment { @@ -20,9 +24,9 @@ export class SystemOverrideService { this.handle = createQueue(); } - async set(sessionId: string, override: string): Promise { + async set(sessionId: string, override: string, scope: ActorTenantScope): Promise { // Load existing fragments - const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId)); + const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope)); const fragments: OverrideFragment[] = existing ? (JSON.parse(existing) as OverrideFragment[]) : []; @@ -37,11 +41,11 @@ export class SystemOverrideService { // Store both: fragments array and condensed result const pipeline = this.handle.redis.pipeline(); pipeline.setex( - SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), + SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, JSON.stringify(fragments), ); - pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed); + pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed); await pipeline.exec(); this.logger.debug( @@ -49,21 +53,21 @@ export class SystemOverrideService { ); } - async get(sessionId: string): Promise { - return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId)); + async get(sessionId: string, scope: ActorTenantScope): Promise { + return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope)); } - async renew(sessionId: string): Promise { + async renew(sessionId: string, scope: ActorTenantScope): Promise { const pipeline = this.handle.redis.pipeline(); - pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); - pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); + pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); + pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); await pipeline.exec(); } - async clear(sessionId: string): Promise { + async clear(sessionId: string, scope: ActorTenantScope): Promise { await this.handle.redis.del( - SESSION_SYSTEM_KEY(sessionId), - SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), + SESSION_SYSTEM_KEY(sessionId, scope), + SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), ); this.logger.debug(`Cleared system override for session ${sessionId}`); } -- 2.49.1 From 62f817780633c3743dd3e393a58ef2f1f7b91ad8 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 22:29:21 +0000 Subject: [PATCH 05/16] feat(tess): define runtime provider contract (#719) --- .../src/agent/agent-runtime-provider.spec.ts | 44 +++++++ .../types/src/agent/agent-runtime-provider.ts | 110 ++++++++++++++++++ packages/types/src/agent/index.ts | 2 + 3 files changed, 156 insertions(+) create mode 100644 packages/types/src/agent/agent-runtime-provider.spec.ts create mode 100644 packages/types/src/agent/agent-runtime-provider.ts diff --git a/packages/types/src/agent/agent-runtime-provider.spec.ts b/packages/types/src/agent/agent-runtime-provider.spec.ts new file mode 100644 index 0000000..71b2ae7 --- /dev/null +++ b/packages/types/src/agent/agent-runtime-provider.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeCapability, + RuntimeError, + RuntimeSession, + RuntimeStreamEvent, +} from './agent-runtime-provider.js'; + +describe('AgentRuntimeProvider contract', (): void => { + it('exposes stable, normalized runtime-only contracts', (): void => { + const capability: RuntimeCapability = 'session.attach'; + const session: RuntimeSession = { + id: 'session-1', + providerId: 'fleet', + runtimeId: 'tmux', + state: 'active', + createdAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:00.000Z', + }; + const event: RuntimeStreamEvent = { + type: 'message.delta', + sessionId: session.id, + cursor: '2', + occurredAt: session.updatedAt, + content: 'hello', + }; + const error: RuntimeError = { + code: 'capability_unsupported', + message: 'Denied', + retryable: false, + }; + const attach: RuntimeAttachHandle = { + attachmentId: 'attach-1', + sessionId: session.id, + mode: 'read', + expiresAt: session.updatedAt, + }; + const provider: Pick = + {} as Pick; + expect([capability, session.id, event.type, error.code, attach.mode, provider]).toHaveLength(6); + }); +}); diff --git a/packages/types/src/agent/agent-runtime-provider.ts b/packages/types/src/agent/agent-runtime-provider.ts new file mode 100644 index 0000000..4a5fb56 --- /dev/null +++ b/packages/types/src/agent/agent-runtime-provider.ts @@ -0,0 +1,110 @@ +export type RuntimeCapability = + | 'session.list' + | 'session.tree' + | 'session.stream' + | 'session.send' + | 'session.attach' + | 'session.terminate'; +export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed'; +export type RuntimeAttachMode = 'read' | 'control'; + +/** Server-derived immutable authority context. Client identity fields are intentionally absent. */ +export interface RuntimeScope { + readonly actorId: string; + readonly tenantId: string; + readonly channelId: string; + readonly correlationId: string; +} +export interface RuntimeSession { + id: string; + providerId: string; + runtimeId: string; + parentSessionId?: string; + state: RuntimeSessionState; + createdAt: string; + updatedAt: string; +} +export interface RuntimeSessionTree { + session: RuntimeSession; + children: RuntimeSessionTree[]; +} +export interface RuntimeCapabilitySet { + supported: RuntimeCapability[]; +} +export interface RuntimeHealth { + status: 'healthy' | 'degraded' | 'down'; + checkedAt: string; + detail?: string; +} +export interface RuntimeMessage { + content: string; + idempotencyKey: string; +} +export interface RuntimeAttachHandle { + attachmentId: string; + sessionId: string; + mode: RuntimeAttachMode; + expiresAt: string; +} +export interface RuntimeError { + code: + | 'capability_unsupported' + | 'not_found' + | 'forbidden' + | 'conflict' + | 'unavailable' + | 'invalid_request'; + message: string; + retryable: boolean; +} +export type RuntimeStreamEvent = + | { + type: 'session.state'; + sessionId: string; + cursor: string; + occurredAt: string; + state: RuntimeSessionState; + } + | { + type: 'message.delta'; + sessionId: string; + cursor: string; + occurredAt: string; + content: string; + } + | { + type: 'message.complete'; + sessionId: string; + cursor: string; + occurredAt: string; + messageId: string; + } + | { + type: 'runtime.error'; + sessionId: string; + cursor: string; + occurredAt: string; + error: RuntimeError; + }; + +/** Runtime-neutral boundary; implementations fail closed for unsupported operations. */ +export interface AgentRuntimeProvider { + readonly id: string; + capabilities(scope: RuntimeScope): Promise; + health(scope: RuntimeScope): Promise; + listSessions(scope: RuntimeScope): Promise; + getSessionTree(scope: RuntimeScope): Promise; + streamSession( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable; + sendMessage(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise; + attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise; + detach(attachmentId: string, scope: RuntimeScope): Promise; + terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise; +} diff --git a/packages/types/src/agent/index.ts b/packages/types/src/agent/index.ts index 8d303d2..1803cd3 100644 --- a/packages/types/src/agent/index.ts +++ b/packages/types/src/agent/index.ts @@ -2,3 +2,5 @@ export interface AgentSessionHandle { readonly id: string; } + +export * from './agent-runtime-provider.js'; -- 2.49.1 From a959b1d6b47589586440f1b21ff638017c9620df Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 22:48:58 +0000 Subject: [PATCH 06/16] ci: restrict push-event CI to protected branches (halve feature-branch load) (#721) --- .woodpecker/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index ce4d4da..1789aa8 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -7,7 +7,14 @@ variables: - &enable_pnpm 'corepack enable' when: - - event: [push, pull_request, manual] + # PR + manual CI run on any branch — the pull_request pipeline is the merge gate. + # push CI is restricted to protected branches (main) so a feature-branch push no + # longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves + # CI load on the storage-constrained runner with zero loss of gating (branch + # protection requires no push/ci status context; main still gets full push CI). + - event: [pull_request, manual] + - event: push + branch: main # Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker # repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN). -- 2.49.1 From 227b73fcdf0489ee9ffe2de4d17bd5b17d4c59b3 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 22:49:00 +0000 Subject: [PATCH 07/16] fix(security): enforce Tess MCP server identity (#717) --- apps/gateway/src/mcp/mcp.controller.ts | 27 +- apps/gateway/src/mcp/mcp.service.spec.ts | 461 ++++++++++++++++++++++ apps/gateway/src/mcp/mcp.service.ts | 477 +++++++++++++++++++---- 3 files changed, 882 insertions(+), 83 deletions(-) create mode 100644 apps/gateway/src/mcp/mcp.service.spec.ts diff --git a/apps/gateway/src/mcp/mcp.controller.ts b/apps/gateway/src/mcp/mcp.controller.ts index 55ad75e..cfdbb3e 100644 --- a/apps/gateway/src/mcp/mcp.controller.ts +++ b/apps/gateway/src/mcp/mcp.controller.ts @@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common'; import { fromNodeHeaders } from 'better-auth/node'; import type { Auth } from '@mosaicstack/auth'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; -import type { McpService } from './mcp.service.js'; +import { + createMcpActorContext, + deriveMcpToolScopesForUser, + type McpService, +} from './mcp.service.js'; import { AUTH } from '../auth/auth.tokens.js'; /** @@ -67,14 +71,25 @@ async function handleMcpRequest( return; } - const userId = result.user.id; + const authUser = result.user as { + id: string; + role?: string | null; + tenantId?: string | null; + organizationId?: string | null; + }; + const actor = createMcpActorContext({ + userId: authUser.id, + role: authUser.role, + tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined, + scopes: deriveMcpToolScopesForUser({ role: authUser.role }), + }); // ─── Session routing ───────────────────────────────────────────────────── const sessionId = req.raw.headers['mcp-session-id']; if (typeof sessionId === 'string' && sessionId.length > 0) { // Existing session request - const transport = mcpService.getSession(sessionId); + const transport = mcpService.getSession(sessionId, actor); if (!transport) { logger.warn(`MCP session not found: ${sessionId}`); reply.raw.writeHead(404, { 'Content-Type': 'application/json' }); @@ -112,8 +127,10 @@ async function handleMcpRequest( } // Create new session and handle this initializing request - const { transport } = mcpService.createSession(userId); - logger.log(`New MCP session created for user ${userId}`); + const { transport } = mcpService.createSession(actor); + logger.log( + `New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`, + ); await transport.handleRequest(req.raw, reply.raw, body); } diff --git a/apps/gateway/src/mcp/mcp.service.spec.ts b/apps/gateway/src/mcp/mcp.service.spec.ts new file mode 100644 index 0000000..9842fee --- /dev/null +++ b/apps/gateway/src/mcp/mcp.service.spec.ts @@ -0,0 +1,461 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Brain } from '@mosaicstack/brain'; +import type { Memory } from '@mosaicstack/memory'; +import type { EmbeddingService } from '../memory/embedding.service.js'; +import type { CoordService } from '../coord/coord.service.js'; +import { + assertMcpToolAuthorized, + createMcpActorContext, + deriveMcpToolScopesForUser, + MCP_TOOL_SCOPES, + McpService, + type McpToolName, +} from './mcp.service.js'; + +type ToolResult = { content: Array<{ type: 'text'; text: string }> }; +type ToolHandler = (params: Record) => Promise; + +interface CapturedTool { + inputSchema: z.ZodType; + handler: ToolHandler; +} + +function makeCapturingServer(): { server: McpServer; tools: Map } { + const tools = new Map(); + const server = { + registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void { + tools.set(name, { inputSchema: config.inputSchema, handler }); + }, + }; + return { server: server as unknown as McpServer, tools }; +} + +function makeService(opts?: { + projects?: Array & { id: string; ownerId?: string | null }>; + missions?: Array< + Record & { id: string; projectId?: string | null; userId?: string | null } + >; + tasks?: Array< + Record & { + id: string; + projectId?: string | null; + missionId?: string | null; + status?: string; + } + >; +}) { + const projects = opts?.projects ?? []; + const missions = opts?.missions ?? []; + const tasks = opts?.tasks ?? []; + const brain = { + projects: { + findAll: vi.fn(async () => projects), + findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null), + }, + tasks: { + findAll: vi.fn(async () => tasks), + findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null), + findByProject: vi.fn(async (projectId: string) => + tasks.filter((task) => task.projectId === projectId), + ), + findByMission: vi.fn(async (missionId: string) => + tasks.filter((task) => task.missionId === missionId), + ), + findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)), + create: vi.fn(async (task: Record) => ({ id: 'task-1', ...task })), + update: vi.fn(async (id: string, updates: Record) => ({ id, ...updates })), + }, + missions: { + findAll: vi.fn(async () => missions), + findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null), + findByProject: vi.fn(async (projectId: string) => + missions.filter((mission) => mission.projectId === projectId), + ), + }, + conversations: { + findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]), + }, + } as unknown as Brain; + + const memory = { + insights: { + searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]), + create: vi.fn(async (insight: Record) => ({ id: 'insight-2', ...insight })), + }, + preferences: { + findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]), + findByUserAndCategory: vi.fn(async (userId: string, category: string) => [ + { key: 'theme', userId, category }, + ]), + upsert: vi.fn(async (preference: Record) => ({ + id: 'pref-1', + ...preference, + })), + }, + } as unknown as Memory; + + const embeddings = { + available: true, + embed: vi.fn(async () => [0.1, 0.2, 0.3]), + } as unknown as EmbeddingService; + + const coord = { + getMissionStatus: vi.fn(async () => null), + listTasks: vi.fn(async () => []), + getTaskStatus: vi.fn(async () => null), + } as unknown as CoordService; + + return { + service: new McpService(brain, memory, embeddings, coord), + brain: brain as unknown as { + conversations: { findAll: ReturnType }; + tasks: { + create: ReturnType; + update: ReturnType; + }; + }, + memory: memory as unknown as { + insights: { searchByEmbedding: ReturnType }; + }, + coord: coord as unknown as { + listTasks: ReturnType; + }, + }; +} + +function getTool(tools: Map, name: McpToolName): CapturedTool { + const tool = tools.get(name); + if (!tool) throw new Error(`Missing captured tool ${name}`); + return tool; +} + +function makeMemberActor(userId = 'authenticated-user') { + return createMcpActorContext({ + userId, + role: 'member', + scopes: deriveMcpToolScopesForUser({ role: 'member' }), + }); +} + +function makeAdminActor(userId = 'admin-user', tenantId?: string) { + return createMcpActorContext({ + userId, + tenantId, + role: 'admin', + scopes: deriveMcpToolScopesForUser({ role: 'admin' }), + }); +} + +function makePlatformAdminActor(userId = 'platform-admin-user') { + return createMcpActorContext({ + userId, + role: 'platform-admin', + scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }), + }); +} + +describe('MCP actor identity and tool scope enforcement', () => { + it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => { + const actor = createMcpActorContext({ + userId: ' user-authenticated ', + role: 'member', + scopes: deriveMcpToolScopesForUser({ role: 'member' }), + }); + + expect(actor.userId).toBe('user-authenticated'); + expect(actor.tenantId).toBe('user:user-authenticated'); + expect(actor.role).toBe('member'); + expect(actor.channel).toBe('mcp'); + expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i); + expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true); + expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false); + expect( + deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks), + ).toBe(false); + expect( + deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks), + ).toBe(true); + }); + + it('fails closed when scopes are not supplied by the authenticated context policy', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated' }); + + expect(actor.scopes.size).toBe(0); + expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow( + 'MCP tool scope denied: memory:insight:read', + ); + }); + + it('fails closed when a tool caller supplies actor or tenant identity fields', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated' }); + + expect(() => + assertMcpToolAuthorized(actor, 'memory_search', { + userId: 'victim-user', + query: 'private data', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: userId'); + + expect(() => + assertMcpToolAuthorized(actor, 'coord_list_tasks', { + tenantId: 'victim-tenant', + projectPath: '/tmp/project', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: tenantId'); + + expect(() => + assertMcpToolAuthorized(actor, 'brain_create_task', { + title: 'forged org', + organizationId: 'victim-org', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: organizationId'); + + expect(() => + assertMcpToolAuthorized(actor, 'brain_update_task', { + title: 'forged team', + teamId: 'victim-team', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: teamId'); + }); + + it('fails closed when the server-derived actor lacks the required per-tool scope', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] }); + + expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow( + 'MCP tool scope denied: memory:insight:read', + ); + }); + + it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => { + const { service, memory } = makeService(); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + const tool = getTool(tools, 'memory_search'); + + expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe( + false, + ); + await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow( + 'MCP caller-controlled identity field is forbidden: userId', + ); + expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled(); + + await tool.handler({ query: 'only my notes' }); + expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith( + 'authenticated-user', + [0.1, 0.2, 0.3], + 5, + ); + }); + + it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => { + const { service, brain } = makeService(); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + const tool = getTool(tools, 'brain_list_conversations'); + + expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false); + await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow( + 'MCP caller-controlled identity field is forbidden: userId', + ); + expect(brain.conversations.findAll).not.toHaveBeenCalled(); + + await tool.handler({}); + expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user'); + }); + + it('scopes brain project, mission, and task reads to the authenticated actor', async () => { + const { service } = makeService({ + projects: [ + { id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' }, + { id: 'project-victim', ownerId: 'victim-user', name: 'victim' }, + ], + missions: [ + { id: 'mission-owned', projectId: 'project-owned' }, + { id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' }, + ], + tasks: [ + { id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' }, + { id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' }, + { id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' }, + { id: 'task-unowned', status: 'not-started' }, + ], + }); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + + 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 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-project', + 'task-owned-mission', + ]); + }); + + it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => { + const { service } = makeService({ + projects: [ + { + id: 'project-tenant-a', + ownerId: 'other-user-a', + teamId: 'tenant-a', + name: 'same tenant', + }, + { + id: 'project-tenant-b', + ownerId: 'other-user-b', + teamId: 'tenant-b', + name: 'other tenant', + }, + ], + missions: [ + { 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-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 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']); + + 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 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']); + }); + + 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' }, + { id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' }, + ], + missions: [ + { 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-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); + + await expect( + getTool(tools, 'brain_create_task').handler({ + title: 'unscoped tenant write', + }), + ).rejects.toThrow('MCP task scope denied'); + expect(brain.tasks.create).not.toHaveBeenCalled(); + + await expect( + getTool(tools, 'brain_create_task').handler({ + title: 'cross-tenant write', + projectId: 'project-tenant-b', + }), + ).rejects.toThrow('MCP task project scope denied'); + expect(brain.tasks.create).not.toHaveBeenCalled(); + + await expect( + getTool(tools, 'brain_update_task').handler({ + id: 'task-tenant-a', + projectId: 'project-tenant-b', + }), + ).rejects.toThrow('MCP task project scope denied'); + expect(brain.tasks.update).not.toHaveBeenCalled(); + + const updateResult = await getTool(tools, 'brain_update_task').handler({ + id: 'task-tenant-b', + title: 'cross-tenant update', + }); + expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b'); + expect(brain.tasks.update).not.toHaveBeenCalled(); + + await getTool(tools, 'brain_create_task').handler({ + title: 'same-tenant write', + projectId: 'project-tenant-a', + }); + expect(brain.tasks.create).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }), + ); + }); + + it('keeps admin-only coordination tools on server-derived paths', async () => { + const { service, coord } = makeService(); + const { server, tools } = makeCapturingServer(); + const member = makeMemberActor('authenticated-user'); + const tenantAdmin = makeAdminActor('admin-user'); + const platformAdmin = makePlatformAdminActor('platform-admin-user'); + + service.registerTools(server, member); + const memberTool = getTool(tools, 'coord_list_tasks'); + expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false); + await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read'); + + tools.clear(); + service.registerTools(server, tenantAdmin); + const tenantAdminTool = getTool(tools, 'coord_list_tasks'); + await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read'); + + tools.clear(); + service.registerTools(server, platformAdmin); + const platformAdminTool = getTool(tools, 'coord_list_tasks'); + await platformAdminTool.handler({ projectPath: '/tmp/victim' }); + expect(coord.listTasks).toHaveBeenCalledWith(process.cwd()); + }); + + it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => { + const { service } = makeService(); + const owner = makeMemberActor('owner-user'); + const attacker = makeMemberActor('attacker-user'); + const admin = makeAdminActor('admin-user'); + const downgradedAdmin = makeMemberActor('admin-user'); + + const { sessionId, transport } = service.createSession(owner); + const staleSession = service.createSession(admin); + + expect(service.getSession(sessionId, owner)).toBe(transport); + expect(service.getSession(sessionId, attacker)).toBeNull(); + expect(service.getSession(sessionId, owner)).toBe(transport); + expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull(); + expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport); + + await service.onModuleDestroy(); + }); +}); diff --git a/apps/gateway/src/mcp/mcp.service.ts b/apps/gateway/src/mcp/mcp.service.ts index c32dfc0..e5fb6b9 100644 --- a/apps/gateway/src/mcp/mcp.service.ts +++ b/apps/gateway/src/mcp/mcp.service.ts @@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js'; import { EmbeddingService } from '../memory/embedding.service.js'; import { CoordService } from '../coord/coord.service.js'; +export const MCP_CALLER_IDENTITY_FIELDS = [ + 'actorId', + 'actor', + 'authenticatedUserId', + 'channel', + 'organizationId', + 'ownerId', + 'sessionUserId', + 'teamId', + 'tenant', + 'tenantId', + 'user', + 'userId', +] as const; + +type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number]; + +export const MCP_TOOL_SCOPES = { + brain_list_projects: 'brain:project:read', + brain_get_project: 'brain:project:read', + brain_list_tasks: 'brain:task:read', + brain_create_task: 'brain:task:write', + brain_update_task: 'brain:task:write', + brain_list_missions: 'brain:mission:read', + brain_list_conversations: 'brain:conversation:read', + memory_search: 'memory:insight:read', + memory_get_preferences: 'memory:preference:read', + memory_save_preference: 'memory:preference:write', + memory_save_insight: 'memory:insight:write', + coord_mission_status: 'coord:read', + coord_list_tasks: 'coord:read', + coord_task_detail: 'coord:read', +} as const; + +export type McpToolName = keyof typeof MCP_TOOL_SCOPES; +export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName]; + +export interface McpActorContext { + userId: string; + tenantId: string; + role: string; + channel: 'mcp'; + correlationId: string; + scopes: ReadonlySet; +} + interface SessionEntry { server: McpServer; transport: StreamableHTTPServerTransport; createdAt: Date; + actor: McpActorContext; +} + +const GLOBAL_ADMIN_MCP_SCOPES = new Set(Object.values(MCP_TOOL_SCOPES)); +const TENANT_ADMIN_MCP_SCOPES = new Set([ + 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([ + MCP_TOOL_SCOPES.brain_list_projects, + MCP_TOOL_SCOPES.brain_get_project, + MCP_TOOL_SCOPES.brain_list_tasks, + 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, +]); + +export function deriveMcpToolScopesForUser(input: { + role?: string | null; +}): ReadonlySet { + 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); +} + +export function createMcpActorContext(input: { userId: string; + tenantId?: string; + role?: string | null; + scopes?: Iterable; + correlationId?: string; +}): McpActorContext { + const userId = input.userId.trim(); + if (userId.length === 0) { + throw new Error('MCP authenticated user is required'); + } + + return { + userId, + tenantId: input.tenantId?.trim() || `user:${userId}`, + role: input.role ?? 'member', + channel: 'mcp', + correlationId: input.correlationId ?? randomUUID(), + scopes: new Set(input.scopes ?? []), + }; +} + +export function assertNoCallerControlledIdentity(params: unknown): void { + if (params === null || typeof params !== 'object') return; + + const keys = new Set(Object.keys(params)); + const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) => + keys.has(field), + ); + if (forbidden) { + throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`); + } +} + +export function assertMcpToolAuthorized( + actor: McpActorContext, + toolName: McpToolName, + params: unknown, +): void { + assertNoCallerControlledIdentity(params); + const requiredScope = MCP_TOOL_SCOPES[toolName]; + if (!actor.scopes.has(requiredScope)) { + throw new Error(`MCP tool scope denied: ${requiredScope}`); + } +} + +function strictObject(shape: T): z.ZodObject { + return z.object(shape).strict(); +} + +type TenantScopedLike = { + tenantId?: string | null; + organizationId?: string | null; + teamId?: string | null; +}; +type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null }; +type MissionLike = TenantScopedLike & { + id: string; + projectId?: string | null; + userId?: string | null; +}; +type TaskLike = TenantScopedLike & { + projectId?: string | null; + missionId?: string | null; + 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 + ); +} + +function filterProjectsForActor(actor: McpActorContext, projects: T[]): T[] { + if (isGlobalAdminActor(actor)) return projects; + return projects.filter( + (project) => + project.ownerId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, project)), + ); +} + +function filterMissionsByDirectActorScope( + actor: McpActorContext, + missions: T[], +): T[] { + if (isGlobalAdminActor(actor)) return missions; + return missions.filter( + (mission) => + mission.userId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, mission)), + ); +} + +function scopesEqual(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) return false; + for (const scope of left) { + if (!right.has(scope)) return false; + } + return true; +} + +function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean { + return ( + stored.userId === current.userId && + stored.tenantId === current.tenantId && + stored.role === current.role && + scopesEqual(stored.scopes, current.scopes) + ); } @Injectable() @@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy { * Creates a new MCP session with its own server + transport pair. * Returns the transport for use by the controller. */ - createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } { + createSession(actor: McpActorContext): { + sessionId: string; + transport: StreamableHTTPServerTransport; + } { const sessionId = randomUUID(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, onsessioninitialized: (id) => { - this.logger.log(`MCP session initialized: ${id} for user ${userId}`); + this.logger.log( + `MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`, + ); }, }); @@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy { { capabilities: { tools: {} } }, ); - this.registerTools(server, userId); + this.registerTools(server, actor); transport.onclose = () => { this.logger.log(`MCP session closed: ${sessionId}`); @@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy { ); }); - this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId }); + this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor }); return { sessionId, transport }; } /** - * Returns the transport for an existing session, or null if not found. + * Returns the transport for an existing session only when it belongs to the + * currently authenticated MCP actor. Guessed or cross-tenant session IDs grant + * no authority. */ - getSession(sessionId: string): StreamableHTTPServerTransport | null { - return this.sessions.get(sessionId)?.transport ?? null; + getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null { + const entry = this.sessions.get(sessionId); + if (!entry) return null; + if (!sameActorAuthorization(entry.actor, actor)) { + this.logger.warn( + `MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`, + ); + return null; + } + return entry.transport; + } + + private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise { + if (isGlobalAdminActor(actor)) return true; + const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined; + return project ? filterProjectsForActor(actor, [project]).length === 1 : false; + } + + private async filterMissionsForActor( + actor: McpActorContext, + missions: T[], + ): Promise { + 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), + ); + + return missions.filter( + (mission) => + filterMissionsByDirectActorScope(actor, [mission]).length === 1 || + (typeof mission.projectId === 'string' && projectIds.has(mission.projectId)), + ); + } + + private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise { + 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; + } + + private async assertTaskReferencesAuthorized( + actor: McpActorContext, + refs: { projectId?: string | null; missionId?: string | null }, + ): Promise { + if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) { + throw new Error('MCP task project scope denied'); + } + if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) { + throw new Error('MCP task mission scope denied'); + } + } + + private async assertTaskCreateScopeAuthorized( + actor: McpActorContext, + refs: { projectId?: string | null; missionId?: string | null }, + ): Promise { + if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) { + throw new Error('MCP task scope denied'); + } + await this.assertTaskReferencesAuthorized(actor, refs); + } + + private async filterTasksForActor( + actor: McpActorContext, + tasks: T[], + ): Promise { + if (isGlobalAdminActor(actor)) return tasks; + + const [projects, missions] = await Promise.all([ + this.brain.projects.findAll(), + this.brain.missions.findAll(), + ]); + const projectIds = new Set( + filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id), + ); + const missionIds = new Set( + (await this.filterMissionsForActor(actor, missions as MissionLike[])).map( + (mission) => mission.id, + ), + ); + + 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)), + ); } /** * Registers all platform tools on the given McpServer instance. */ - private registerTools(server: McpServer, _userId: string): void { + registerTools(server: McpServer, actor: McpActorContext): void { // ─── Brain: Project tools ──────────────────────────────────────────── server.registerTool( 'brain_list_projects', { description: 'List all projects in the brain.', - inputSchema: z.object({}), + inputSchema: strictObject({}), }, - async () => { - const projects = await this.brain.projects.findAll(); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_projects', params); + const projects = filterProjectsForActor( + actor, + (await this.brain.projects.findAll()) as ProjectLike[], + ); return { content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }], }; @@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy { 'brain_get_project', { description: 'Get a project by ID.', - inputSchema: z.object({ + inputSchema: strictObject({ id: z.string().describe('Project ID (UUID)'), }), }, - async ({ id }) => { - const project = await this.brain.projects.findById(id); + async ({ id, ...params }) => { + assertMcpToolAuthorized(actor, 'brain_get_project', params); + const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined; + const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined; return { content: [ { type: 'text' as const, - text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`, + text: authorizedProject + ? JSON.stringify(authorizedProject, null, 2) + : `Project not found: ${id}`, }, ], }; @@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy { 'brain_list_tasks', { description: 'List tasks, optionally filtered by project, mission, or status.', - inputSchema: z.object({ + inputSchema: strictObject({ projectId: z.string().optional().describe('Filter by project ID'), missionId: z.string().optional().describe('Filter by mission ID'), status: z.string().optional().describe('Filter by status'), }), }, - async ({ projectId, missionId, status }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_tasks', params); + const { projectId, missionId, status } = params; type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; let tasks; if (projectId) tasks = await this.brain.tasks.findByProject(projectId); else if (missionId) tasks = await this.brain.tasks.findByMission(missionId); else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus); else tasks = await this.brain.tasks.findAll(); - return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; + const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]); + return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] }; }, ); @@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy { 'brain_create_task', { description: 'Create a new task in the brain.', - inputSchema: z.object({ + inputSchema: strictObject({ title: z.string().describe('Task title'), description: z.string().optional().describe('Task description'), projectId: z.string().optional().describe('Project ID'), @@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy { }), }, async (params) => { + assertMcpToolAuthorized(actor, 'brain_create_task', params); + await this.assertTaskCreateScopeAuthorized(actor, params); type Priority = 'low' | 'medium' | 'high' | 'critical'; const task = await this.brain.tasks.create({ ...params, @@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy { 'brain_update_task', { description: 'Update an existing task.', - inputSchema: z.object({ + inputSchema: strictObject({ id: z.string().describe('Task ID'), title: z.string().optional(), description: z.string().optional(), @@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy { .optional() .describe('not-started, in-progress, blocked, done, cancelled'), priority: z.string().optional(), + projectId: z.string().optional().describe('Project ID'), + missionId: z.string().optional().describe('Mission ID'), }), }, async ({ id, ...updates }) => { + assertMcpToolAuthorized(actor, 'brain_update_task', updates); + const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined; + if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) { + return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] }; + } + await this.assertTaskReferencesAuthorized(actor, updates); type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; type Priority = 'low' | 'medium' | 'high' | 'critical'; const task = await this.brain.tasks.update(id, { @@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy { 'brain_list_missions', { description: 'List all missions, optionally filtered by project.', - inputSchema: z.object({ + inputSchema: strictObject({ projectId: z.string().optional().describe('Filter by project ID'), }), }, - async ({ projectId }) => { - const missions = projectId - ? await this.brain.missions.findByProject(projectId) - : await this.brain.missions.findAll(); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_missions', params); + const { projectId } = params; + const missions = await this.filterMissionsForActor( + actor, + (projectId + ? await this.brain.missions.findByProject(projectId) + : await this.brain.missions.findAll()) as MissionLike[], + ); return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] }; }, ); @@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy { server.registerTool( 'brain_list_conversations', { - description: 'List conversations for a user.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), - }), + description: 'List conversations for the authenticated MCP actor.', + inputSchema: strictObject({}), }, - async ({ userId }) => { - const conversations = await this.brain.conversations.findAll(userId); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_conversations', params); + const conversations = await this.brain.conversations.findAll(actor.userId); return { content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }], }; @@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy { 'memory_search', { description: - 'Search across stored insights and knowledge using natural language. Returns semantically similar results.', - inputSchema: z.object({ - userId: z.string().describe('User ID to search memory for'), + 'Search stored insights and knowledge for the authenticated MCP actor using natural language.', + inputSchema: strictObject({ query: z.string().describe('Natural language search query'), limit: z.number().optional().describe('Max results (default 5)'), }), }, - async ({ userId, query, limit }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_search', params); + const { query, limit } = params; if (!this.embeddings.available) { return { content: [ @@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy { }; } const embedding = await this.embeddings.embed(query); - const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5); + const results = await this.memory.insights.searchByEmbedding( + actor.userId, + embedding, + limit ?? 5, + ); return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] }; }, ); @@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy { server.registerTool( 'memory_get_preferences', { - description: 'Retrieve stored preferences for a user.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + description: 'Retrieve stored preferences for the authenticated MCP actor.', + inputSchema: strictObject({ category: z .string() .optional() .describe('Filter by category: communication, coding, workflow, appearance, general'), }), }, - async ({ userId, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_get_preferences', params); + const { category } = params; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; const prefs = category - ? await this.memory.preferences.findByUserAndCategory(userId, category as Cat) - : await this.memory.preferences.findByUser(userId); + ? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat) + : await this.memory.preferences.findByUser(actor.userId); return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] }; }, ); @@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy { 'memory_save_preference', { description: - 'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + 'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").', + inputSchema: strictObject({ key: z.string().describe('Preference key'), value: z.string().describe('Preference value (JSON string)'), category: z @@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy { .describe('Category: communication, coding, workflow, appearance, general'), }), }, - async ({ userId, key, value, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_save_preference', params); + const { key, value, category } = params; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; let parsedValue: unknown; try { @@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy { parsedValue = value; } const pref = await this.memory.preferences.upsert({ - userId, + userId: actor.userId, key, value: parsedValue, category: (category as Cat) ?? 'general', @@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy { 'memory_save_insight', { description: - 'Store a learned insight, decision, or knowledge extracted from the current interaction.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + 'Store a learned insight, decision, or knowledge for the authenticated MCP actor.', + inputSchema: strictObject({ content: z.string().describe('The insight or knowledge to store'), category: z .string() @@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy { .describe('Category: decision, learning, preference, fact, pattern, general'), }), }, - async ({ userId, content, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_save_insight', params); + const { content, category } = params; type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general'; const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null; const insight = await this.memory.insights.create({ - userId, + userId: actor.userId, content, embedding, source: 'agent', @@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy { { description: 'Get the current orchestration mission status including milestones, tasks, and active session.', - inputSchema: z.object({ - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), - }), + inputSchema: strictObject({}), }, - async ({ projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const status = await this.coordService.getMissionStatus(resolvedPath); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_mission_status', params); + const status = await this.coordService.getMissionStatus(process.cwd()); return { content: [ { @@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy { 'coord_list_tasks', { description: 'List all tasks from the orchestration TASKS.md file.', - inputSchema: z.object({ - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), - }), + inputSchema: strictObject({}), }, - async ({ projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const tasks = await this.coordService.listTasks(resolvedPath); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_list_tasks', params); + const tasks = await this.coordService.listTasks(process.cwd()); return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; }, ); @@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy { 'coord_task_detail', { description: 'Get detailed status for a specific orchestration task.', - inputSchema: z.object({ + inputSchema: strictObject({ taskId: z.string().describe('Task ID (e.g. P2-005)'), - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), }), }, - async ({ taskId, projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const detail = await this.coordService.getTaskStatus(resolvedPath, taskId); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_task_detail', params); + const { taskId } = params; + const detail = await this.coordService.getTaskStatus(process.cwd(), taskId); return { content: [ { -- 2.49.1 From 46ca3ce742f4c0daa569e900b9f06bc9e95adf4c Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 23:18:01 +0000 Subject: [PATCH 08/16] fix(tess): enforce command authorization approvals (#718) --- .../chat.gateway-command-approval.spec.ts | 74 ++++++++++ apps/gateway/src/chat/chat.gateway.ts | 25 ++++ .../command-authorization.service.spec.ts | 61 ++++++++ .../commands/command-authorization.service.ts | 138 ++++++++++++++++++ .../command-executor-tess-security.spec.ts | 109 ++++++++++++++ .../src/commands/command-executor.service.ts | 22 +++ apps/gateway/src/commands/commands.module.ts | 2 + docs/scratchpads/tess-m1-sec-001.md | 27 ++++ packages/types/src/chat/events.ts | 3 + packages/types/src/commands/index.ts | 12 ++ 10 files changed, 473 insertions(+) create mode 100644 apps/gateway/src/chat/chat.gateway-command-approval.spec.ts create mode 100644 apps/gateway/src/commands/command-authorization.service.spec.ts create mode 100644 apps/gateway/src/commands/command-authorization.service.ts create mode 100644 apps/gateway/src/commands/command-executor-tess-security.spec.ts create mode 100644 docs/scratchpads/tess-m1-sec-001.md diff --git a/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts new file mode 100644 index 0000000..ac29759 --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SlashCommandPayload } from '@mosaicstack/types'; +import { ChatGateway } from './chat.gateway.js'; + +const payload: SlashCommandPayload = { + command: 'gc', + conversationId: 'conversation-1', + approvalId: 'approval-1', +}; + +function buildGateway(commandExecutor: { + execute: ReturnType; + createApproval: ReturnType; +}): ChatGateway { + return new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + commandExecutor as never, + {} as never, + ); +} + +describe('ChatGateway command approval ingress', () => { + it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise => { + const commandExecutor = { + execute: vi.fn().mockResolvedValue({ ...payload, success: true }), + createApproval: vi.fn(), + }; + const gateway = buildGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandExecute(client as never, payload); + + expect(commandExecutor.execute).toHaveBeenCalledWith(payload, { + userId: 'admin-1', + tenantId: 'admin-1', + }); + expect(client.emit).toHaveBeenCalledWith( + 'command:result', + expect.objectContaining({ success: true }), + ); + }); + + it('issues a durable approval only for the authenticated actor', async (): Promise => { + const commandExecutor = { + execute: vi.fn(), + createApproval: vi.fn().mockResolvedValue({ + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }), + }; + const gateway = buildGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandApproval(client as never, { + command: 'gc', + conversationId: 'conversation-1', + }); + + expect(commandExecutor.createApproval).toHaveBeenCalledWith( + { command: 'gc', conversationId: 'conversation-1' }, + { userId: 'admin-1', tenantId: 'admin-1' }, + ); + expect(client.emit).toHaveBeenCalledWith('command:approval', { + command: 'gc', + conversationId: 'conversation-1', + success: true, + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }); + }); +}); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 5528e32..c552ffe 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -15,6 +15,7 @@ import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; import type { SetThinkingPayload, + SlashCommandApprovalResultPayload, SlashCommandPayload, SystemReloadPayload, RoutingDecisionInfo, @@ -429,6 +430,30 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.emit('command:result', result); } + @SubscribeMessage('command:approve') + async handleCommandApproval( + @ConnectedSocket() client: Socket, + @MessageBody() payload: SlashCommandPayload, + ): Promise { + const scope = this.getClientScope(client); + const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null; + const result: SlashCommandApprovalResultPayload = approval + ? { + command: payload.command, + conversationId: payload.conversationId, + success: true, + approvalId: approval.approvalId, + expiresAt: approval.expiresAt, + } + : { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Not authorized to approve this command.', + }; + client.emit('command:approval', result); + } + broadcastReload(payload: SystemReloadPayload): void { this.server.emit('system:reload', payload); this.logger.log('Broadcasted system:reload to all connected clients'); diff --git a/apps/gateway/src/commands/command-authorization.service.spec.ts b/apps/gateway/src/commands/command-authorization.service.spec.ts new file mode 100644 index 0000000..7eb7034 --- /dev/null +++ b/apps/gateway/src/commands/command-authorization.service.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; +import { CommandAuthorizationService } from './command-authorization.service.js'; + +const adminCommand: CommandDef = { + name: 'gc', + description: 'GC', + aliases: [], + scope: 'admin', + execution: 'socket', + available: true, +}; +const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; + +function createService(role: string): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => { + entries.set(key, value); + }, + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +describe('CommandAuthorizationService', () => { + it('consumes one exact actor-bound approval once', async (): Promise => { + const service = createService('admin'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); + expect(approval).not.toBeNull(); + expect( + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, + ).toBe(true); + expect( + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, + ).toBe(false); + }); + + it('rejects an approval when the structured action is mutated', async (): Promise => { + const service = createService('admin'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); + const mutated = { ...payload, conversationId: 'other-conversation' }; + expect(approval).not.toBeNull(); + expect( + (await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed, + ).toBe(false); + }); + + it('denies an admin command to a member before approval is considered', async (): Promise => { + const service = createService('member'); + const approval = await service.createApproval(adminCommand, payload, 'member-1'); + expect(approval).toBeNull(); + expect( + (await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed, + ).toBe(false); + }); +}); diff --git a/apps/gateway/src/commands/command-authorization.service.ts b/apps/gateway/src/commands/command-authorization.service.ts new file mode 100644 index 0000000..ce7f499 --- /dev/null +++ b/apps/gateway/src/commands/command-authorization.service.ts @@ -0,0 +1,138 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { eq, users as usersTable, type Db } from '@mosaicstack/db'; +import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; +import { DB } from '../database/database.module.js'; +import { COMMANDS_REDIS } from './commands.tokens.js'; + +export type CommandRole = 'admin' | 'member' | 'viewer'; + +export interface CommandApproval { + approvalId: string; + actionDigest: string; + actorId: string; + command: string; + expiresAt: string; +} + +export interface CommandAuthorizationResult { + allowed: boolean; + reason?: string; +} + +@Injectable() +export class CommandAuthorizationService { + constructor( + @Inject(DB) private readonly db: Db, + @Inject(COMMANDS_REDIS) + private readonly redis: { + get(key: string): Promise; + set(key: string, value: string, ...args: string[]): Promise; + del(key: string): Promise; + }, + ) {} + + async authorize( + command: CommandDef, + payload: SlashCommandPayload, + actorId: string, + approvalId?: string, + ): Promise { + const role = await this.resolveRole(actorId); + if (!role || !this.hasScope(role, command.scope)) { + return { allowed: false, reason: 'not authorized for this command scope' }; + } + if (command.scope !== 'admin') return { allowed: true }; + if (!approvalId) return { allowed: false, reason: 'durable approval is required' }; + const actionDigest = this.actionDigest(command.name, payload); + const approved = await this.consumeApproval(approvalId, actorId, actionDigest); + return approved + ? { allowed: true } + : { + allowed: false, + reason: 'approval is invalid, expired, replayed, or does not match this action', + }; + } + + async createApproval( + command: CommandDef, + payload: SlashCommandPayload, + actorId: string, + ): Promise { + const role = await this.resolveRole(actorId); + if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null; + + const approvalId = randomUUID(); + const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString(); + const approval: CommandApproval = { + approvalId, + actionDigest: this.actionDigest(command.name, payload), + actorId, + command: command.name, + expiresAt, + }; + await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300'); + return approval; + } + + private async resolveRole(actorId: string): Promise { + const [user] = await this.db + .select({ role: usersTable.role }) + .from(usersTable) + .where(eq(usersTable.id, actorId)) + .limit(1); + const role = user?.role; + return role === 'admin' || role === 'member' || role === 'viewer' ? role : null; + } + + private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean { + if (role === 'admin') return true; + return role === 'member' && (scope === 'core' || scope === 'agent'); + } + + private async consumeApproval( + approvalId: string, + actorId: string, + actionDigest: string, + ): Promise { + const key = this.key(approvalId); + const encoded = await this.redis.get(key); + if (!encoded) return false; + const parsed: unknown = JSON.parse(encoded); + if ( + !this.isApproval(parsed) || + parsed.actorId !== actorId || + parsed.actionDigest !== actionDigest || + Date.parse(parsed.expiresAt) <= Date.now() + ) + return false; + return (await this.redis.del(key)) === 1; + } + + private actionDigest(command: string, payload: SlashCommandPayload): string { + return createHash('sha256') + .update( + JSON.stringify({ + command, + args: payload.args?.trim() ?? '', + conversationId: payload.conversationId, + }), + ) + .digest('hex'); + } + + private isApproval(value: unknown): value is CommandApproval { + return ( + typeof value === 'object' && + value !== null && + 'approvalId' in value && + 'actionDigest' in value && + 'actorId' in value && + 'expiresAt' in value + ); + } + + private key(approvalId: string): string { + return `tess:command-approval:${approvalId}`; + } +} diff --git a/apps/gateway/src/commands/command-executor-tess-security.spec.ts b/apps/gateway/src/commands/command-executor-tess-security.spec.ts new file mode 100644 index 0000000..2fe5550 --- /dev/null +++ b/apps/gateway/src/commands/command-executor-tess-security.spec.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SlashCommandPayload } from '@mosaicstack/types'; +import { CommandAuthorizationService } from './command-authorization.service.js'; +import { CommandExecutorService } from './command-executor.service.js'; + +const registry = { + getManifest: vi.fn(() => ({ + version: 1, + commands: [ + { + name: 'gc', + description: 'System-wide garbage collection', + aliases: [], + scope: 'admin' as const, + execution: 'socket' as const, + available: true, + }, + ], + skills: [], + })), +}; + +const sessionGc = { + sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }), +}; + +const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' }); + +const authorization = { + authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) => + Promise.resolve( + actorId === 'member-1' + ? { allowed: false, reason: 'durable approval is required' } + : { allowed: false, reason: 'not authorized for this command scope' }, + ), + ), +}; + +function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService { + return new CommandExecutorService( + registry as never, + { getSession: vi.fn() } as never, + { clear: vi.fn(), set: vi.fn() } as never, + sessionGc as never, + { set: vi.fn() } as never, + { agents: {} } as never, + null, + null, + null, + authorizationService as never, + ); +} + +function createDurableAuthorization(): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => { + entries.set(key, value); + }, + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +describe('TESS-M1-SEC-001 command authorization abuse cases', () => { + const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; + + beforeEach((): void => { + vi.clearAllMocks(); + }); + + it('denies a forged admin identity and does not execute a system-wide command', async (): Promise => { + const result = await buildExecutor().execute(payload, scope('admin-forged-by-client')); + + expect(result.success).toBe(false); + expect(result.message).toContain('not authorized'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); + }); + + it('denies a privileged command without a server-bound durable approval', async (): Promise => { + const result = await buildExecutor().execute(payload, scope('member-1')); + + expect(result.success).toBe(false); + expect(result.message).toContain('approval'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); + }); + + it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise => { + const executor = buildExecutor(createDurableAuthorization()); + + const adminScope = scope('admin-1'); + const denied = await executor.execute(payload, adminScope); + const approval = await executor.createApproval(payload, adminScope); + const approved = await executor.execute( + { ...payload, approvalId: approval?.approvalId }, + adminScope, + ); + + expect(denied.success).toBe(false); + expect(denied.message).toContain('approval'); + expect(approval).not.toBeNull(); + expect(approved.success).toBe(true); + expect(sessionGc.sweepOrphans).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index f7fc0cc..a7a455a 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -11,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js'; import { McpClientService } from '../mcp-client/mcp-client.service.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandRegistryService } from './command-registry.service.js'; @Injectable() @@ -33,6 +34,9 @@ export class CommandExecutorService { @Optional() @Inject(McpClientService) private readonly mcpClient: McpClientService | null, + @Optional() + @Inject(CommandAuthorizationService) + private readonly authorization: CommandAuthorizationService | null = null, ) {} async execute( @@ -52,6 +56,16 @@ export class CommandExecutorService { }; } + const authorization = await this.authorization?.authorize( + def, + payload, + userId, + payload.approvalId, + ); + if (authorization && !authorization.allowed) { + return { command, conversationId, success: false, message: authorization.reason }; + } + try { switch (command) { case 'model': @@ -148,6 +162,14 @@ export class CommandExecutorService { } } + async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) { + const def = this.registry + .getManifest() + .commands.find((command) => command.name === payload.command); + if (!def || !this.authorization) return null; + return this.authorization.createApproval(def, payload, scope.userId); + } + private async handleModel( args: string | null, conversationId: string, diff --git a/apps/gateway/src/commands/commands.module.ts b/apps/gateway/src/commands/commands.module.ts index 1c3a82c..0d6c30d 100644 --- a/apps/gateway/src/commands/commands.module.ts +++ b/apps/gateway/src/commands/commands.module.ts @@ -3,6 +3,7 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { ChatModule } from '../chat/chat.module.js'; import { GCModule } from '../gc/gc.module.js'; import { ReloadModule } from '../reload/reload.module.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandExecutorService } from './command-executor.service.js'; import { CommandRegistryService } from './command-registry.service.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; @@ -24,6 +25,7 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; inject: [COMMANDS_QUEUE_HANDLE], }, CommandRegistryService, + CommandAuthorizationService, CommandExecutorService, ], exports: [CommandRegistryService, CommandExecutorService], diff --git a/docs/scratchpads/tess-m1-sec-001.md b/docs/scratchpads/tess-m1-sec-001.md new file mode 100644 index 0000000..b297170 --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-001.md @@ -0,0 +1,27 @@ +# TESS-M1-SEC-001 — Command authorization and exact-action approval + +- Issue/milestone: #707 / M1 +- Branch: `fix/tess-command-authz` +- Requirement: `TESS-SEC-002`, with approval binding controls from `TESS-SEC-007` +- Scope: `apps/gateway` only, plus required in-repo security/developer documentation. + +## Plan + +1. Locate the gateway command executor, command metadata, authorization context, and existing test conventions. +2. Write abuse/authz tests before production changes. Expected red cases: non-admin blocked from admin/system command; forged caller scope cannot authorize; privileged/destructive action requires durable exact-action approval; expired/replayed/mutated approvals deny. +3. Implement server-derived role/scope enforcement and durable approval validation/consumption with audit results. +4. Run focused security tests, then repository baseline gates: typecheck, lint, format-check, test. +5. Run independent security/code review, commit, queue-guard, push, and open the PR to `main` through the stated Gitea API fallback. Stop after PR creation. + +## Assumptions + +- The existing gateway persistence interface is the available durable approval boundary. If no persistence abstraction exists, a minimal injectable repository interface will be introduced rather than an in-memory approval implementation, because TESS-SEC-002/007 require durable enforcement. +- “Exact action” is a canonical digest over structured command identity and normalized arguments; role/scope checks always use authenticated server context, not client-declared claims. + +## TDD evidence + +- Pending: abuse/authz test written and observed red before implementation. + +## Verification evidence + +- Pending. diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 6b12ecd..432a53d 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -1,5 +1,6 @@ import type { CommandManifestPayload, + SlashCommandApprovalResultPayload, SlashCommandPayload, SlashCommandResultPayload, SystemReloadPayload, @@ -116,6 +117,7 @@ export interface ServerToClientEvents { 'session:info': (payload: SessionInfoPayload) => void; 'commands:manifest': (payload: CommandManifestPayload) => void; 'command:result': (payload: SlashCommandResultPayload) => void; + 'command:approval': (payload: SlashCommandApprovalResultPayload) => void; 'system:reload': (payload: SystemReloadPayload) => void; error: (payload: ErrorPayload) => void; } @@ -125,5 +127,6 @@ export interface ClientToServerEvents { message: (data: ChatMessagePayload) => void; 'set:thinking': (data: SetThinkingPayload) => void; 'command:execute': (data: SlashCommandPayload) => void; + 'command:approve': (data: SlashCommandPayload) => void; abort: (data: AbortPayload) => void; } diff --git a/packages/types/src/commands/index.ts b/packages/types/src/commands/index.ts index 5c984df..8b46aeb 100644 --- a/packages/types/src/commands/index.ts +++ b/packages/types/src/commands/index.ts @@ -63,6 +63,18 @@ export interface SlashCommandPayload { conversationId: string; command: string; args?: string; + /** One-time server-issued approval for a privileged command execution. */ + approvalId?: string; +} + +/** Server response to a request to approve a privileged slash command. */ +export interface SlashCommandApprovalResultPayload { + conversationId: string; + command: string; + success: boolean; + approvalId?: string; + expiresAt?: string; + message?: string; } /** Server response to a slash command */ -- 2.49.1 From 119f64e69d109dc27338f51bc53452486825f199 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 23:18:29 +0000 Subject: [PATCH 09/16] feat(#707): secure Discord service ingress (#716) --- apps/gateway/src/chat/chat.gateway-auth.ts | 14 ++ apps/gateway/src/chat/chat.gateway.ts | 139 ++++++++++++- .../plugin/discord-ingress.security.spec.ts | 89 +++++++++ .../src/plugin/discord-replay-protector.ts | 40 ++++ apps/gateway/src/plugin/plugin.module.ts | 22 +++ docs/architecture/channel-protocol.md | 4 + docs/guides/admin-guide.md | 25 ++- .../tess-m1-sec-004-discord-ingress.md | 35 ++++ plugins/discord/src/index.ts | 184 +++++++++++++----- 9 files changed, 484 insertions(+), 68 deletions(-) create mode 100644 apps/gateway/src/plugin/discord-ingress.security.spec.ts create mode 100644 apps/gateway/src/plugin/discord-replay-protector.ts create mode 100644 docs/scratchpads/tess-m1-sec-004-discord-ingress.md diff --git a/apps/gateway/src/chat/chat.gateway-auth.ts b/apps/gateway/src/chat/chat.gateway-auth.ts index 16d034c..b1bf564 100644 --- a/apps/gateway/src/chat/chat.gateway-auth.ts +++ b/apps/gateway/src/chat/chat.gateway-auth.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import type { IncomingHttpHeaders } from 'node:http'; import { fromNodeHeaders } from 'better-auth/node'; @@ -12,6 +13,19 @@ export interface SessionAuth { }; } +export function validateDiscordServiceToken( + candidate: unknown, + expected: string | undefined, +): boolean { + if (typeof candidate !== 'string' || !expected) return false; + const candidateBuffer = Buffer.from(candidate); + const expectedBuffer = Buffer.from(expected); + return ( + candidateBuffer.length === expectedBuffer.length && + timingSafeEqual(candidateBuffer, expectedBuffer) + ); +} + export async function validateSocketSession( headers: IncomingHttpHeaders, auth: SessionAuth, diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index c552ffe..41fa81c 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -11,6 +11,11 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; +import { + verifyDiscordIngressEnvelope, + type DiscordIngressEnvelope, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; import type { @@ -34,7 +39,8 @@ import { CommandExecutorService } from '../commands/command-executor.service.js' import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; -import { validateSocketSession } from './chat.gateway-auth.js'; +import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js'; +import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; /** Per-client state tracking streaming accumulation for persistence. */ interface ClientSession { @@ -58,6 +64,37 @@ interface ClientSession { */ const modelOverrides = new Map(); +function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { + if (typeof value !== 'object' || value === null) return false; + const envelope = value as { payload?: unknown; signature?: unknown }; + if ( + typeof envelope.signature !== 'string' || + typeof envelope.payload !== 'object' || + envelope.payload === null + ) { + return false; + } + const payload = envelope.payload as Record; + return [ + payload['correlationId'], + payload['messageId'], + payload['guildId'], + payload['channelId'], + payload['userId'], + payload['conversationId'], + payload['content'], + ].every((field: unknown): boolean => typeof field === 'string'); +} + +function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { + if (typeof value !== 'object' || value === null) return false; + const payload = value as { content?: unknown; conversationId?: unknown }; + return ( + typeof payload.content === 'string' && + (payload.conversationId === undefined || typeof payload.conversationId === 'string') + ); +} + @WebSocketGateway({ cors: { origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000', @@ -70,6 +107,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly logger = new Logger(ChatGateway.name); private readonly clientSessions = new Map(); + private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( @Inject(AgentService) private readonly agentService: AgentService, @@ -85,6 +123,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } async handleConnection(client: Socket): Promise { + const serviceToken = client.handshake.auth['discordServiceToken']; + if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) { + client.data.discordService = true; + this.logger.log(`Authenticated Discord service connected: ${client.id}`); + return; + } + const session = await validateSocketSession(client.handshake.headers, this.auth); if (!session) { this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`); @@ -95,8 +140,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.data.user = session.user; client.data.session = session.session; this.logger.log(`Client connected: ${client.id}`); - - // Broadcast command manifest to the newly connected client client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() }); } @@ -131,17 +174,49 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @SubscribeMessage('message') async handleMessage( @ConnectedSocket() client: Socket, - @MessageBody() data: ChatSocketMessageDto, + @MessageBody() rawData: unknown, ): Promise { + let discordIngress: DiscordIngressPayload | null = null; + let data: ChatSocketMessageDto; + if (client.data.discordService) { + if (!isDiscordIngressEnvelope(rawData)) { + this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); + return; + } + discordIngress = this.resolveDiscordIngress(client, rawData); + if (!discordIngress) return; + data = { conversationId: discordIngress.conversationId, content: discordIngress.content }; + } else { + if (!isChatSocketMessage(rawData)) { + this.logger.warn(`Rejected malformed chat message from ${client.id}`); + return; + } + data = rawData; + } const conversationId = data.conversationId ?? uuid(); - const scope = this.getClientScope(client); + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; + if (discordIngress && !discordServiceUserId) { + this.logger.warn( + `Rejected Discord ingress without configured service owner from ${client.id}`, + ); + return; + } + const scope = discordIngress + ? { + userId: discordServiceUserId!, + tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!, + } + : this.getClientScope(client); if (!scope) { client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); return; } const userId = scope.userId; + const correlationId = discordIngress?.correlationId; - this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); + this.logger.log( + `Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`, + ); // Ensure agent session exists for this conversation let sessionRoutingDecision: RoutingDecisionInfo | undefined; @@ -245,6 +320,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa content: data.content, metadata: { timestamp: new Date().toISOString(), + ...(correlationId + ? { + correlationId, + discordMessageId: discordIngress?.messageId, + discordUserId: discordIngress?.userId, + } + : {}), }, }, userId, @@ -308,7 +390,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Send acknowledgment - client.emit('message:ack', { conversationId, messageId: uuid() }); + client.emit('message:ack', { + conversationId, + messageId: uuid(), + ...(correlationId + ? { + correlationId, + discordMessageId: discordIngress?.messageId, + discordUserId: discordIngress?.userId, + } + : {}), + }); // Dispatch to agent try { @@ -534,6 +626,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * Creates it if absent — safe to call concurrently since a duplicate insert * would fail on the PK constraint and be caught here. */ + private resolveDiscordIngress( + client: Socket, + envelope: DiscordIngressEnvelope, + ): DiscordIngressPayload | null { + const payload = verifyDiscordIngressEnvelope( + envelope, + process.env['DISCORD_SERVICE_TOKEN'] ?? '', + { + guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), + channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), + userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), + }, + ); + if (!payload) { + this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`); + return null; + } + if (!this.discordReplayProtector.claim(payload.messageId)) { + this.logger.warn( + `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, + ); + return null; + } + return payload; + } + + private readDiscordAllowlist(name: string): string[] { + return (process.env[name] ?? '') + .split(',') + .map((id: string): string => id.trim()) + .filter((id: string): boolean => id.length > 0); + } + private async ensureConversation(conversationId: string, userId: string): Promise { try { const existing = await this.brain.conversations.findById(conversationId, userId); diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts new file mode 100644 index 0000000..0530170 --- /dev/null +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + createDiscordIngressEnvelope, + verifyDiscordIngressEnvelope, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; +import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js'; +import { DiscordReplayProtector } from './discord-replay-protector.js'; + +const SERVICE_TOKEN = 'test-service-token'; + +function createPayload(overrides: Partial = {}): DiscordIngressPayload { + return { + correlationId: 'correlation-001', + messageId: 'discord-message-001', + guildId: 'guild-001', + channelId: 'channel-001', + userId: 'user-001', + conversationId: 'discord-channel-001', + content: 'hello Tess', + ...overrides, + }; +} + +describe('Discord ingress security', () => { + it('accepts only the configured Discord service identity', () => { + expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true); + expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false); + expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false); + }); + + it('rejects unauthenticated or tampered service envelopes', () => { + const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN); + + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload()); + expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull(); + expect( + verifyDiscordIngressEnvelope( + { ...envelope, payload: { ...envelope.payload, content: 'forged command' } }, + SERVICE_TOKEN, + ), + ).toBeNull(); + }); + + it.each([ + ['guild', { guildId: 'unlisted-guild' }], + ['channel', { channelId: 'unlisted-channel' }], + ['user', { userId: 'unlisted-user' }], + ])( + 'rejects an unallowlisted Discord %s', + (_kind: string, overrides: Partial) => { + const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN); + + expect( + verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, { + guildIds: ['guild-001'], + channelIds: ['channel-001'], + userIds: ['user-001'], + }), + ).toBeNull(); + }, + ); + + it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => { + const payload = createPayload({ + correlationId: 'correlation-trace-123', + messageId: 'discord-snowflake-987', + }); + const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN); + + expect( + verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, { + guildIds: ['guild-001'], + channelIds: ['channel-001'], + userIds: ['user-001'], + }), + ).toEqual(payload); + }); + + it('rejects a replayed Discord message ID while retaining bounded replay state', () => { + const replayProtector = new DiscordReplayProtector(60_000, 2); + + expect(replayProtector.claim('discord-message-001')).toBe(true); + expect(replayProtector.claim('discord-message-001')).toBe(false); + expect(replayProtector.claim('discord-message-002')).toBe(true); + expect(replayProtector.claim('discord-message-003')).toBe(true); + expect(replayProtector.size).toBe(2); + }); +}); diff --git a/apps/gateway/src/plugin/discord-replay-protector.ts b/apps/gateway/src/plugin/discord-replay-protector.ts new file mode 100644 index 0000000..626a193 --- /dev/null +++ b/apps/gateway/src/plugin/discord-replay-protector.ts @@ -0,0 +1,40 @@ +/** + * Bounded replay cache for Discord's globally unique native message IDs. + * Durable ingress idempotency is added with Tess's canonical inbox/outbox work. + */ +export class DiscordReplayProtector { + private readonly claimedAt = new Map(); + + constructor( + private readonly ttlMs = 15 * 60 * 1000, + private readonly maxEntries = 10_000, + ) {} + + get size(): number { + return this.claimedAt.size; + } + + /** Claims an ID exactly once within its bounded retention window. */ + claim(messageId: string, now = Date.now()): boolean { + this.prune(now); + if (this.claimedAt.has(messageId)) return false; + + this.claimedAt.set(messageId, now); + this.evictOverflow(); + return true; + } + + private prune(now: number): void { + for (const [messageId, claimedAt] of this.claimedAt) { + if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId); + } + } + + private evictOverflow(): void { + while (this.claimedAt.size > this.maxEntries) { + const oldestMessageId = this.claimedAt.keys().next().value; + if (oldestMessageId === undefined) return; + this.claimedAt.delete(oldestMessageId); + } + } +} diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index 3991c9b..dc059ef 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -50,19 +50,41 @@ class TelegramChannelPluginAdapter implements IChannelPlugin { const DEFAULT_GATEWAY_URL = 'http://localhost:14242'; +function requiredDiscordAllowlist(name: string): string[] { + const value = process.env[name] + ?.split(',') + .map((id: string): string => id.trim()) + .filter((id: string): boolean => id.length > 0); + if (!value || value.length === 0) { + throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`); + } + return value; +} + function createPluginRegistry(): IChannelPlugin[] { const plugins: IChannelPlugin[] = []; const discordToken = process.env['DISCORD_BOT_TOKEN']; const discordGuildId = process.env['DISCORD_GUILD_ID']; const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL; + const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN']; + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; if (discordToken) { + if (!discordServiceToken || !discordServiceUserId) { + throw new Error( + 'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured', + ); + } plugins.push( new DiscordChannelPluginAdapter( new DiscordPlugin({ token: discordToken, guildId: discordGuildId, gatewayUrl: discordGatewayUrl, + serviceToken: discordServiceToken, + allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), + allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), + allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), }), ), ); diff --git a/docs/architecture/channel-protocol.md b/docs/architecture/channel-protocol.md index ad96cbd..315252a 100644 --- a/docs/architecture/channel-protocol.md +++ b/docs/architecture/channel-protocol.md @@ -232,6 +232,10 @@ The following sections document how each supported channel maps its native messa **Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. +### Discord service ingress security + +The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads. + --- ### Telegram diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index 4f7c6a1..2430dbd 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -293,13 +293,24 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe ### Plugins -| Variable | Description | -| ---------------------- | -------------------------------------------------------------------------- | -| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | -| `DISCORD_GUILD_ID` | Discord guild/server ID | -| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | -| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | -| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | +| Variable | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | +| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | +| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | +| `DISCORD_GUILD_ID` | Discord guild/server ID | +| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | +| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | +| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | +| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | + +### Discord ingress security + +When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged. + +Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. ### Observability diff --git a/docs/scratchpads/tess-m1-sec-004-discord-ingress.md b/docs/scratchpads/tess-m1-sec-004-discord-ingress.md new file mode 100644 index 0000000..92e28d3 --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-004-discord-ingress.md @@ -0,0 +1,35 @@ +# Scratchpad — TESS-M1-SEC-004 Discord ingress + +- **Task / issue:** TESS-M1-SEC-004 / #707 +- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd` +- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs. +- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs. +- **Budget:** Task estimate 28K; no explicit hard cap supplied. +- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work. + +## Plan + +1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection. +2. Implement the signed Discord ingress envelope and allowlist validation in the plugin. +3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch. +4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review. + +## Progress + +- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed. +- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks. +- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved. + +## Risks / blockers + +- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work. + +## Verification evidence + +- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed. +- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped. +- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`. +- `pnpm typecheck` — passed. +- `pnpm lint` — passed. +- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification. +- Codex security review — no findings; final Codex code review — approved. diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index 72fb27f..d25e555 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -1,24 +1,109 @@ +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; import { io, type Socket } from 'socket.io-client'; export interface DiscordPluginConfig { token: string; gatewayUrl: string; - /** Which guild to bind to (single-guild only for v0.1.0) */ + /** Shared service credential injected by the approved secret mechanism. */ + serviceToken: string; + /** Which guild to bind to (single-guild only for v0.1.0). */ guildId?: string; + allowedGuildIds: readonly string[]; + allowedChannelIds: readonly string[]; + allowedUserIds: readonly string[]; +} + +export interface DiscordIngressPayload { + correlationId: string; + messageId: string; + guildId: string; + channelId: string; + userId: string; + conversationId: string; + content: string; +} + +export interface DiscordIngressEnvelope { + payload: DiscordIngressPayload; + signature: string; +} + +export interface DiscordIngressAllowlists { + guildIds: readonly string[]; + channelIds: readonly string[]; + userIds: readonly string[]; +} + +function signedPayload(payload: DiscordIngressPayload): string { + return [ + payload.correlationId, + payload.messageId, + payload.guildId, + payload.channelId, + payload.userId, + payload.conversationId, + payload.content, + ].join('\n'); +} + +function signPayload(payload: DiscordIngressPayload, serviceToken: string): string { + return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex'); +} + +function isSignatureValid(actual: string, expected: string): boolean { + const actualBuffer = Buffer.from(actual, 'hex'); + const expectedBuffer = Buffer.from(expected, 'hex'); + return ( + actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer) + ); +} + +function includesId(allowedIds: readonly string[], id: string): boolean { + return allowedIds.includes(id); +} + +/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */ +export function createDiscordIngressEnvelope( + payload: DiscordIngressPayload, + serviceToken: string, +): DiscordIngressEnvelope { + return { payload, signature: signPayload(payload, serviceToken) }; +} + +/** + * Verifies service-origin integrity and applies default-deny Discord identity allowlists. + * Returns null instead of a partially trusted payload on every failure path. + */ +export function verifyDiscordIngressEnvelope( + envelope: DiscordIngressEnvelope, + serviceToken: string, + allowlists?: DiscordIngressAllowlists, +): DiscordIngressPayload | null { + const expectedSignature = signPayload(envelope.payload, serviceToken); + if (!isSignatureValid(envelope.signature, expectedSignature)) return null; + + if ( + allowlists && + (!includesId(allowlists.guildIds, envelope.payload.guildId) || + !includesId(allowlists.channelIds, envelope.payload.channelId) || + !includesId(allowlists.userIds, envelope.payload.userId)) + ) { + return null; + } + + return envelope.payload; } export class DiscordPlugin { private client: Client; private socket: Socket | null = null; - private config: DiscordPluginConfig; - /** Map Discord channel ID → Mosaic conversation ID */ + /** Map Discord channel ID → Mosaic conversation ID. */ private channelConversations = new Map(); - /** Track in-flight responses to avoid duplicate streaming */ + /** Track in-flight responses to avoid duplicate streaming. */ private pendingResponses = new Map(); - constructor(config: DiscordPluginConfig) { - this.config = config; + constructor(private readonly config: DiscordPluginConfig) { this.client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -30,8 +115,8 @@ export class DiscordPlugin { } async start(): Promise { - // Connect to gateway WebSocket this.socket = io(`${this.config.gatewayUrl}/chat`, { + auth: { discordServiceToken: this.config.serviceToken }, transports: ['websocket'], }); @@ -48,7 +133,6 @@ export class DiscordPlugin { console.error(`[discord] Gateway connection error: ${err.message}`); }); - // Handle streaming text from gateway this.socket.on('agent:text', (data: { conversationId: string; text: string }) => { const pending = this.pendingResponses.get(data.conversationId); if (pending !== undefined) { @@ -56,12 +140,11 @@ export class DiscordPlugin { } }); - // When agent finishes, send the accumulated response this.socket.on('agent:end', (data: { conversationId: string }) => { const text = this.pendingResponses.get(data.conversationId); if (text) { this.pendingResponses.delete(data.conversationId); - this.sendToDiscord(data.conversationId, text).catch((err) => { + this.sendToDiscord(data.conversationId, text).catch((err: unknown) => { console.error(`[discord] Error sending response for ${data.conversationId}:`, err); }); } @@ -71,8 +154,9 @@ export class DiscordPlugin { this.pendingResponses.set(data.conversationId, ''); }); - // Set up Discord message handler - this.client.on('messageCreate', (message) => this.handleDiscordMessage(message)); + this.client.on('messageCreate', (message: DiscordMessage) => + this.handleDiscordMessage(message), + ); this.client.on('ready', () => { console.log(`[discord] Bot logged in as ${this.client.user?.tag}`); @@ -96,7 +180,6 @@ export class DiscordPlugin { const guild = this.client.guilds.cache.get(this.config.guildId); if (!guild) return null; - // Slugify project name for channel: lowercase, replace spaces/special chars with hyphens const channelName = `mosaic-${project.name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') @@ -108,58 +191,58 @@ export class DiscordPlugin { topic: project.description ?? `Mosaic project: ${project.name}`, }); - // Register the channel mapping so messages route correctly this.channelConversations.set(channel.id, `discord-${channel.id}`); - return { channelId: channel.id }; } private handleDiscordMessage(message: DiscordMessage): void { - // Ignore bot messages - if (message.author.bot) return; + if (message.author.bot || !this.client.user) return; + if (!message.guildId || !this.isAllowedMessage(message)) return; - // Not ready yet - if (!this.client.user) return; - - // Check guild binding - if (this.config.guildId && message.guildId !== this.config.guildId) return; - - // Respond to DMs always, or mentions in channels - const isDM = !message.guildId; const isMention = message.mentions.has(this.client.user); + if (!isMention) return; - if (!isDM && !isMention) return; - - // Strip bot mention from message content const content = message.content .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '') .trim(); - if (!content) return; - - // Get or create conversation for this Discord channel - const channelId = message.channelId; - let conversationId = this.channelConversations.get(channelId); - if (!conversationId) { - conversationId = `discord-${channelId}`; - this.channelConversations.set(channelId, conversationId); - } - - // Send to gateway if (!this.socket?.connected) { console.error( - `[discord] Cannot forward message: not connected to gateway. channel=${channelId}`, + `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`, ); return; } - this.socket.emit('message', { - conversationId, - content, - }); + + const channelId = message.channelId; + const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`; + this.channelConversations.set(channelId, conversationId); + + const envelope = createDiscordIngressEnvelope( + { + correlationId: randomUUID(), + messageId: message.id, + guildId: message.guildId, + channelId, + userId: message.author.id, + conversationId, + content, + }, + this.config.serviceToken, + ); + this.socket.emit('message', envelope); + } + + private isAllowedMessage(message: DiscordMessage): boolean { + const guildId = message.guildId; + return ( + guildId !== null && + includesId(this.config.allowedGuildIds, guildId) && + includesId(this.config.allowedChannelIds, message.channelId) && + includesId(this.config.allowedUserIds, message.author.id) + ); } private async sendToDiscord(conversationId: string, text: string): Promise { - // Find the Discord channel for this conversation const channelId = Array.from(this.channelConversations.entries()).find( ([, convId]) => convId === conversationId, )?.[0]; @@ -177,12 +260,10 @@ export class DiscordPlugin { return; } - // Chunk responses for Discord's 2000-char limit - const chunks = this.chunkText(text, 1900); - for (const chunk of chunks) { + for (const chunk of this.chunkText(text, 1900)) { try { await (channel as { send: (content: string) => Promise }).send(chunk); - } catch (err) { + } catch (err: unknown) { console.error(`[discord] Failed to send message to channel ${channelId}:`, err); } } @@ -193,21 +274,16 @@ export class DiscordPlugin { const chunks: string[] = []; let remaining = text; - while (remaining.length > 0) { if (remaining.length <= maxLength) { chunks.push(remaining); break; } - - // Try to break at a newline let breakPoint = remaining.lastIndexOf('\n', maxLength); if (breakPoint <= 0) breakPoint = maxLength; - chunks.push(remaining.slice(0, breakPoint)); remaining = remaining.slice(breakPoint).trimStart(); } - return chunks; } } -- 2.49.1 From e92186d768f236a3972d6abf0e349b71419bb1f4 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 23:53:54 +0000 Subject: [PATCH 10/16] feat: add Tess runtime provider registry (#722) --- apps/gateway/package.json | 1 + .../runtime-provider-registry.service.test.ts | 285 ++++++++++++ apps/gateway/src/agent/agent.module.ts | 26 ++ .../runtime-provider-registry.service.ts | 404 ++++++++++++++++++ .../tess-m1-002-provider-registry.md | 38 ++ docs/tess/ARCHITECTURE.md | 6 + packages/agent/src/index.ts | 2 + .../src/runtime-provider-registry.test.ts | 95 ++++ .../agent/src/runtime-provider-registry.ts | 40 ++ pnpm-lock.yaml | 3 + 10 files changed, 900 insertions(+) create mode 100644 apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts create mode 100644 apps/gateway/src/agent/runtime-provider-registry.service.ts create mode 100644 docs/scratchpads/tess-m1-002-provider-registry.md create mode 100644 packages/agent/src/runtime-provider-registry.test.ts create mode 100644 packages/agent/src/runtime-provider-registry.ts diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 1a2cb97..8e5819f 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -31,6 +31,7 @@ "@mariozechner/pi-ai": "^0.65.0", "@mariozechner/pi-coding-agent": "^0.65.0", "@modelcontextprotocol/sdk": "^1.27.1", + "@mosaicstack/agent": "workspace:^", "@mosaicstack/auth": "workspace:^", "@mosaicstack/brain": "workspace:^", "@mosaicstack/config": "workspace:^", diff --git a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts new file mode 100644 index 0000000..8541554 --- /dev/null +++ b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import type { ActorTenantScope } from '../../auth/session-scope.js'; +import { + RuntimeProviderService, + type RuntimeAuditEvent, + type RuntimeAuditSink, + type RuntimeApprovalVerifier, +} from '../runtime-provider-registry.service.js'; + +const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' }; +const CONTEXT = { + actorScope: OWNER_SCOPE, + channelId: 'cli', + correlationId: 'correlation-1', +}; + +class RecordingRuntimeProvider implements AgentRuntimeProvider { + readonly id = 'fleet'; + readonly receivedScopes: RuntimeScope[] = []; + readonly sentMessages: RuntimeMessage[] = []; + terminateCalls = 0; + throwAfterSend = false; + + constructor(private readonly supported: RuntimeCapability[]) {} + + async capabilities(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return { supported: this.supported }; + } + + async health(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' }; + } + + async listSessions(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return []; + } + + async getSessionTree(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return []; + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + this.receivedScopes.push(scope); + return; + } + + async sendMessage( + _sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + this.receivedScopes.push(scope); + this.sentMessages.push(message); + if (this.throwAfterSend) { + throw new Error('provider acknowledgement failed'); + } + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + this.receivedScopes.push(scope); + return { + attachmentId: 'attachment-1', + sessionId, + mode, + expiresAt: '2026-07-12T00:00:00.000Z', + }; + } + + async detach(_attachmentId: string, scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + } + + async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + this.terminateCalls += 1; + } +} + +class RecordingAuditSink implements RuntimeAuditSink { + readonly events: RuntimeAuditEvent[] = []; + + async record(event: RuntimeAuditEvent): Promise { + this.events.push(event); + } +} + +class DenyingApprovalVerifier implements RuntimeApprovalVerifier { + async consume(): Promise { + return false; + } +} + +class AcceptingApprovalVerifier implements RuntimeApprovalVerifier { + consumedAction: Parameters[1] | undefined; + + async consume( + _approvalRef: string, + action: Parameters[1], + ): Promise { + this.consumedAction = action; + return true; + } +} + +function makeService( + provider: RecordingRuntimeProvider, + audit: RuntimeAuditSink = new RecordingAuditSink(), + approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(), +): RuntimeProviderService { + const registry = new AgentRuntimeProviderRegistry(); + registry.register(provider); + return new RuntimeProviderService(registry, audit, approval); +} + +describe('RuntimeProviderService security boundary', (): void => { + it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ); + + const providerScope = provider.receivedScopes[0]; + expect(providerScope).toEqual({ + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + }); + expect(Object.isFrozen(providerScope)).toBe(true); + expect(audit.events).toContainEqual({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + resourceId: 'session-1', + }); + expect(JSON.stringify(audit.events)).not.toContain('hello'); + expect(JSON.stringify(audit.events)).not.toContain('key-1'); + }); + + it('fails closed before a provider side effect when a capability is missing', async (): Promise => { + const provider = new RecordingRuntimeProvider([]); + const service = makeService(provider); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/capability denied/); + expect(provider.sentMessages).toEqual([]); + }); + + it('requires a consumed exact-action approval before termination', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.terminate']); + const approval = new DenyingApprovalVerifier(); + const service = makeService(provider, new RecordingAuditSink(), approval); + + await expect( + service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT), + ).rejects.toThrow(/approval denied/); + expect(provider.terminateCalls).toBe(0); + }); + + it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.terminate']); + const approval = new AcceptingApprovalVerifier(); + const service = makeService(provider, new RecordingAuditSink(), approval); + + await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT); + + expect(approval.consumedAction).toEqual({ + providerId: 'fleet', + sessionId: 'session-1', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + }); + expect(provider.terminateCalls).toBe(1); + }); + + it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + const unavailableAudit: RuntimeAuditSink = { + async record(): Promise { + throw new Error('audit unavailable'); + }, + }; + const service = makeService(provider, unavailableAudit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/audit unavailable/); + expect(provider.sentMessages).toEqual([]); + }); + + it('records a provider error after invocation as failed rather than denied', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + provider.throwAfterSend = true; + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/provider acknowledgement failed/); + expect(provider.sentMessages).toHaveLength(1); + expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([ + 'requested', + 'failed', + ]); + }); + + it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + let auditCalls = 0; + const audit: RuntimeAuditSink = { + async record(): Promise { + auditCalls += 1; + if (auditCalls === 2) { + throw new Error('completion audit unavailable'); + } + }, + }; + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).resolves.toBeUndefined(); + expect(provider.sentMessages).toHaveLength(1); + expect(auditCalls).toBe(2); + }); +}); diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 94b9fa3..3d789ae 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js'; @@ -13,6 +14,14 @@ import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { SkillsModule } from '../skills/skills.module.js'; import { GCModule } from '../gc/gc.module.js'; +import { + AGENT_RUNTIME_PROVIDER_REGISTRY, + DenyRuntimeApprovalVerifier, + RUNTIME_APPROVAL_VERIFIER, + RUNTIME_PROVIDER_AUDIT_SINK, + RuntimeProviderAuditService, + RuntimeProviderService, +} from './runtime-provider-registry.service.js'; @Global() @Module({ @@ -23,6 +32,21 @@ import { GCModule } from '../gc/gc.module.js'; RoutingService, RoutingEngineService, SkillLoaderService, + { + provide: AGENT_RUNTIME_PROVIDER_REGISTRY, + useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(), + }, + RuntimeProviderAuditService, + { + provide: RUNTIME_PROVIDER_AUDIT_SINK, + useExisting: RuntimeProviderAuditService, + }, + DenyRuntimeApprovalVerifier, + { + provide: RUNTIME_APPROVAL_VERIFIER, + useExisting: DenyRuntimeApprovalVerifier, + }, + RuntimeProviderService, AgentService, ], controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController], @@ -33,6 +57,8 @@ import { GCModule } from '../gc/gc.module.js'; RoutingService, RoutingEngineService, SkillLoaderService, + RuntimeProviderService, + AGENT_RUNTIME_PROVIDER_REGISTRY, ], }) export class AgentModule {} diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts new file mode 100644 index 0000000..89617e5 --- /dev/null +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -0,0 +1,404 @@ +import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; + +export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY'); +export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK'); +export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER'); + +export type RuntimeProviderOperation = + | RuntimeCapability + | 'runtime.capabilities' + | 'runtime.health'; +export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; + +/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */ +export interface RuntimeProviderRequestContext { + actorScope: ActorTenantScope; + channelId: string; + correlationId: string; +} + +/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */ +export interface RuntimeAuditEvent { + providerId: string; + operation: RuntimeProviderOperation; + outcome: RuntimeProviderAuditOutcome; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + resourceId?: string; +} + +export interface RuntimeAuditSink { + record(event: RuntimeAuditEvent): Promise; +} + +/** Exact action shape that a durable approval implementation must consume once. */ +export interface RuntimeTerminationAction { + providerId: string; + sessionId: string; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; +} + +export interface RuntimeApprovalVerifier { + consume(approvalRef: string, action: RuntimeTerminationAction): Promise; +} + +class RuntimeApprovalDeniedError extends Error { + constructor() { + super('Runtime termination approval denied'); + } +} + +/** + * The default denies all runtime termination until a durable, exact-action + * approval implementation is configured. This is safer than a permissive stub. + */ +@Injectable() +export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier { + async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise { + return false; + } +} + +/** + * Temporary metadata-only audit sink. M1 observability can replace this token + * with a durable audit writer without changing provider call sites. + */ +@Injectable() +export class RuntimeProviderAuditService implements RuntimeAuditSink { + private readonly logger = new Logger(RuntimeProviderAuditService.name); + + async record(event: RuntimeAuditEvent): Promise { + this.logger.log(JSON.stringify(event)); + } +} + +@Injectable() +export class RuntimeProviderService { + private readonly logger = new Logger(RuntimeProviderService.name); + + constructor( + @Inject(AGENT_RUNTIME_PROVIDER_REGISTRY) + private readonly registry: AgentRuntimeProviderRegistry, + @Inject(RUNTIME_PROVIDER_AUDIT_SINK) + private readonly audit: RuntimeAuditSink, + @Inject(RUNTIME_APPROVAL_VERIFIER) + private readonly approvals: RuntimeApprovalVerifier, + ) {} + + async capabilities( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'runtime.capabilities', + undefined, + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.capabilities(scope), + ); + } + + async health(providerId: string, context: RuntimeProviderRequestContext): Promise { + return this.execute( + providerId, + 'runtime.health', + undefined, + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.health(scope), + ); + } + + async listSessions( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.list', + 'session.list', + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.listSessions(scope), + ); + } + + async getSessionTree( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.tree', + 'session.tree', + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.getSessionTree(scope), + ); + } + + streamSession( + providerId: string, + sessionId: string, + cursor: string | undefined, + context: RuntimeProviderRequestContext, + ): AsyncIterable { + return this.stream( + providerId, + 'session.stream', + 'session.stream', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable => + provider.streamSession(sessionId, cursor, scope), + ); + } + + async sendMessage( + providerId: string, + sessionId: string, + message: RuntimeMessage, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.send', + 'session.send', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.sendMessage(sessionId, message, scope), + ); + } + + async attach( + providerId: string, + sessionId: string, + mode: RuntimeAttachMode, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.attach', + 'session.attach', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.attach(sessionId, mode, scope), + ); + } + + async detach( + providerId: string, + attachmentId: string, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.attach', + 'session.attach', + attachmentId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.detach(attachmentId, scope), + ); + } + + async terminate( + providerId: string, + sessionId: string, + approvalRef: string, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.terminate', + 'session.terminate', + sessionId, + context, + async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => { + const approved = await this.approvals.consume(approvalRef, { + providerId, + sessionId, + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + }); + if (!approved) { + throw new RuntimeApprovalDeniedError(); + } + await provider.terminate(sessionId, approvalRef, scope); + }, + ); + } + + private async execute( + providerId: string, + operation: RuntimeProviderOperation, + requiredCapability: RuntimeCapability | undefined, + resourceId: string | undefined, + context: RuntimeProviderRequestContext, + invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise, + ): Promise { + const scope = this.deriveScope(context); + await this.record(providerId, operation, 'requested', scope, resourceId); + let invocationStarted = false; + try { + const provider = this.provider(providerId); + if (requiredCapability) { + await this.assertCapability(provider, requiredCapability, scope); + } + invocationStarted = true; + const result = await invoke(provider, scope); + await this.recordCompletion(providerId, operation, scope, resourceId); + return result; + } catch (error: unknown) { + if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) { + await this.recordFailure(providerId, operation, scope, resourceId); + } else { + await this.record(providerId, operation, 'denied', scope, resourceId); + } + throw error; + } + } + + private async *stream( + providerId: string, + operation: RuntimeProviderOperation, + requiredCapability: RuntimeCapability, + resourceId: string, + context: RuntimeProviderRequestContext, + invoke: ( + provider: AgentRuntimeProvider, + scope: RuntimeScope, + ) => AsyncIterable, + ): AsyncIterable { + const scope = this.deriveScope(context); + await this.record(providerId, operation, 'requested', scope, resourceId); + let invocationStarted = false; + try { + const provider = this.provider(providerId); + await this.assertCapability(provider, requiredCapability, scope); + invocationStarted = true; + for await (const event of invoke(provider, scope)) { + yield event; + } + await this.recordCompletion(providerId, operation, scope, resourceId); + } catch (error: unknown) { + if (invocationStarted) { + await this.recordFailure(providerId, operation, scope, resourceId); + } else { + await this.record(providerId, operation, 'denied', scope, resourceId); + } + throw error; + } + } + + private provider(providerId: string): AgentRuntimeProvider { + try { + return this.registry.require(providerId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Runtime provider is not registered'; + throw new NotFoundException(message); + } + } + + private async assertCapability( + provider: AgentRuntimeProvider, + requiredCapability: RuntimeCapability, + scope: RuntimeScope, + ): Promise { + const capabilities = await provider.capabilities(scope); + if (!capabilities.supported.includes(requiredCapability)) { + throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`); + } + } + + private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope { + const actorId = context.actorScope.userId.trim(); + const tenantId = context.actorScope.tenantId.trim(); + const channelId = context.channelId.trim(); + const correlationId = context.correlationId.trim(); + if (!actorId || !tenantId || !channelId || !correlationId) { + throw new ForbiddenException( + 'Authenticated runtime actor scope and correlation are required', + ); + } + return Object.freeze({ actorId, tenantId, channelId, correlationId }); + } + + private async recordFailure( + providerId: string, + operation: RuntimeProviderOperation, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + try { + await this.record(providerId, operation, 'failed', scope, resourceId); + } catch { + this.logger.error( + `Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, + ); + } + } + + private async recordCompletion( + providerId: string, + operation: RuntimeProviderOperation, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + try { + await this.record(providerId, operation, 'succeeded', scope, resourceId); + } catch { + this.logger.error( + `Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, + ); + } + } + + private async record( + providerId: string, + operation: RuntimeProviderOperation, + outcome: RuntimeProviderAuditOutcome, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + await this.audit.record({ + providerId, + operation, + outcome, + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + ...(resourceId ? { resourceId } : {}), + }); + } +} diff --git a/docs/scratchpads/tess-m1-002-provider-registry.md b/docs/scratchpads/tess-m1-002-provider-registry.md new file mode 100644 index 0000000..f9d6c67 --- /dev/null +++ b/docs/scratchpads/tess-m1-002-provider-registry.md @@ -0,0 +1,38 @@ +# TESS-M1-002 — Provider Registry + +- **Issue:** #707 +- **Branch:** `feat/tess-provider-registry` +- **Objective:** Build the runtime provider registry/service boundary that derives immutable actor/tenant/channel/correlation scope server-side, fail-closes unsupported and destructive runtime operations, binds termination approval to the exact structured action, and emits correlation-safe audit events. + +## Plan + +1. Add a provider-agnostic registry in `@mosaicstack/agent` over the merged `AgentRuntimeProvider` contract. +2. Write abuse-case tests before implementation for duplicate/unknown providers, immutable server-derived scope, capability denial, approval mismatch/absence, and audit failure. +3. Implement the Gateway service that converts only authenticated `ActorTenantScope` plus trusted ingress metadata into a frozen `RuntimeScope`, gates capabilities and terminate approval, and records metadata-only audits. +4. Register the service in `AgentModule`, then run focused, baseline, cold-cache, and independent-review gates. + +## Security Invariants + +- Caller-supplied actor/tenant identity never reaches runtime providers. +- Provider capability absence and approval/audit failure deny before side effects. +- Termination approval is verified against provider, session, actor, tenant, channel, and correlation context. +- Audit events retain correlation and authority metadata but never message content or approval material. + +## Progress + +- 2026-07-12: Created fresh worktree from `origin/main` at `119f64e6`; source and Tess planning/security documentation reviewed. +- 2026-07-12: Security TDD added registry and gateway abuse tests before implementation. +- 2026-07-12: Implemented `AgentRuntimeProviderRegistry` and gateway `RuntimeProviderService`; registered both in `AgentModule` and documented the internal boundary. +- 2026-07-12: Independent review found two audit correctness issues. Remediated completion-audit failure handling and provider execution failures: pre-invocation denials are audited as `denied`; post-invocation errors as `failed`; completion audit failure does not misreport a completed effect as retryable. +- 2026-07-12: Final independent security review: no findings. Final code review had one false positive: `@mosaicstack/types` is already declared in `packages/agent/package.json`. + +## Tests + +- TDD red: `pnpm --filter @mosaicstack/gateway test -- runtime-provider-registry.service.test.ts` failed before both audit remediations, as expected. +- Focused: package registry 2 tests and gateway security boundary 7 tests pass. +- Cold-cache: removed this worktree's `node_modules`, then `pnpm install --offline --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` passed. +- Cold-cache baseline: `TURBO_FORCE=true pnpm typecheck` — 42/42 tasks passed; `TURBO_FORCE=true pnpm lint` — 23/23 tasks passed; `TURBO_FORCE=true pnpm format:check` passed; `TURBO_FORCE=true pnpm test` — 42/42 tasks passed (gateway 548 tests passed, 11 intentionally skipped). + +## Risks / Blockers + +- The canonical durable approval implementation is currently command-specific. This card introduces a fail-closed runtime approval verifier boundary so a runtime provider cannot terminate until its exact-action verifier is wired; later provider implementations cannot bypass it. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 180cf0a..36472c4 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -37,6 +37,12 @@ Required operations: Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors. +### M1 Registry Boundary + +`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references. + +Termination is fail-closed: a runtime approval verifier must consume a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. Until the durable verifier is wired, the default verifier denies termination. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary. + ## Authority Model | Intent | Owner | Tess behavior | diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0c18d5d..409ae61 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1 +1,3 @@ export const VERSION = '0.0.0'; + +export * from './runtime-provider-registry.js'; diff --git a/packages/agent/src/runtime-provider-registry.test.ts b/packages/agent/src/runtime-provider-registry.test.ts new file mode 100644 index 0000000..4e88f08 --- /dev/null +++ b/packages/agent/src/runtime-provider-registry.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import { AgentRuntimeProviderRegistry } from './runtime-provider-registry.js'; + +class TestRuntimeProvider implements AgentRuntimeProvider { + readonly id = 'test-runtime'; + + async capabilities(_scope: RuntimeScope): Promise { + return { supported: ['session.list'] }; + } + + async health(_scope: RuntimeScope): Promise { + return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' }; + } + + async listSessions(_scope: RuntimeScope): Promise { + return []; + } + + async getSessionTree(_scope: RuntimeScope): Promise { + return []; + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + _scope: RuntimeScope, + ): AsyncIterable { + return; + } + + async sendMessage( + _sessionId: string, + _message: RuntimeMessage, + _scope: RuntimeScope, + ): Promise {} + + async attach( + _sessionId: string, + _mode: RuntimeAttachMode, + _scope: RuntimeScope, + ): Promise { + return { + attachmentId: 'attachment-1', + sessionId: 'session-1', + mode: 'read', + expiresAt: '2026-07-12T00:00:00.000Z', + }; + } + + async detach(_attachmentId: string, _scope: RuntimeScope): Promise {} + + async terminate(_sessionId: string, _approvalRef: string, _scope: RuntimeScope): Promise {} +} + +describe('AgentRuntimeProviderRegistry', (): void => { + it('resolves only registered runtime providers', (): void => { + const registry = new AgentRuntimeProviderRegistry(); + const provider = new TestRuntimeProvider(); + + registry.register(provider); + + expect(registry.get(provider.id)).toBe(provider); + expect(registry.get('unknown-runtime')).toBeUndefined(); + expect(registry.list()).toEqual([provider]); + }); + + it('rejects duplicate and blank provider identities rather than silently replacing a runtime', (): void => { + const registry = new AgentRuntimeProviderRegistry(); + const provider = new TestRuntimeProvider(); + + registry.register(provider); + + expect((): void => { + registry.register(provider); + }).toThrow(/already registered/); + expect((): void => { + registry.require(''); + }).toThrow(/provider ID is required/); + expect((): void => { + registry.require('unknown-runtime'); + }).toThrow(/not registered/); + }); +}); diff --git a/packages/agent/src/runtime-provider-registry.ts b/packages/agent/src/runtime-provider-registry.ts new file mode 100644 index 0000000..16cb7d9 --- /dev/null +++ b/packages/agent/src/runtime-provider-registry.ts @@ -0,0 +1,40 @@ +import type { AgentRuntimeProvider } from '@mosaicstack/types'; + +/** + * Registry for runtime providers. Registration is explicit and replacement is + * forbidden so a provider identity cannot be silently hijacked at runtime. + */ +export class AgentRuntimeProviderRegistry { + private readonly providers = new Map(); + + register(provider: AgentRuntimeProvider): void { + const providerId = provider.id.trim(); + if (providerId.length === 0) { + throw new Error('Runtime provider ID is required'); + } + if (this.providers.has(providerId)) { + throw new Error(`Runtime provider is already registered: ${providerId}`); + } + this.providers.set(providerId, provider); + } + + get(providerId: string): AgentRuntimeProvider | undefined { + return this.providers.get(providerId); + } + + require(providerId: string): AgentRuntimeProvider { + const normalizedProviderId = providerId.trim(); + if (normalizedProviderId.length === 0) { + throw new Error('Runtime provider ID is required'); + } + const provider = this.providers.get(normalizedProviderId); + if (!provider) { + throw new Error(`Runtime provider is not registered: ${normalizedProviderId}`); + } + return provider; + } + + list(): AgentRuntimeProvider[] { + return Array.from(this.providers.values()); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf89e42..5124acd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) + '@mosaicstack/agent': + specifier: workspace:^ + version: link:../../packages/agent '@mosaicstack/auth': specifier: workspace:^ version: link:../../packages/auth -- 2.49.1 From 5f9067cf5756b894241e3211ed70d5651f2bd911 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 17:00:52 -0500 Subject: [PATCH 11/16] fix(tess): redact sensitive runtime content --- .../commands/command-executor-p8012.spec.ts | 15 +++----- .../src/commands/command-executor.service.ts | 18 ++++++---- packages/log/src/index.ts | 5 +++ packages/log/src/redaction.spec.ts | 14 ++++++++ packages/log/src/redaction.ts | 35 +++++++++++++++++++ 5 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 packages/log/src/redaction.spec.ts create mode 100644 packages/log/src/redaction.ts diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index 242d2ac..fc5d966 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -106,8 +106,8 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.command).toBe('provider'); }); - // /provider login anthropic — success with URL containing poll token - it('/provider login returns success with URL and poll token', async () => { + // /provider login anthropic — no bearer token or auth URL reaches chat output + it('/provider login keeps its one-time token out of chat output', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login anthropic', @@ -117,14 +117,9 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.success).toBe(true); expect(result.command).toBe('provider'); expect(result.message).toContain('anthropic'); - expect(result.message).toContain('http'); - // data should contain loginUrl and pollToken - expect(result.data).toBeDefined(); - const data = result.data as Record; - expect(typeof data['loginUrl']).toBe('string'); - expect(typeof data['pollToken']).toBe('string'); - expect(data['loginUrl'] as string).toContain('anthropic'); - expect(data['loginUrl'] as string).toContain(data['pollToken'] as string); + expect(result.message).not.toContain('http'); + expect(result.message).not.toContain('token='); + expect(result.data).toEqual({ provider: 'anthropic' }); // Verify Valkey was called expect(mockRedis.set).toHaveBeenCalledOnce(); const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number]; diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index a7a455a..6c2a2a7 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -436,22 +436,28 @@ export class CommandExecutorService { }; } const pollToken = crypto.randomUUID(); - const key = `mosaic:auth:poll:${pollToken}`; - // Store pending state in Valkey (TTL 5 minutes) + const tokenDigest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(pollToken), + ); + const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string => + byte.toString(16).padStart(2, '0'), + ).join(''); + const key = `mosaic:auth:poll:${tokenHash}`; + // Persist only a short-lived token digest. The raw token is delivered only by + // the authenticated dashboard flow, never in chat output or command metadata. await this.redis.set( key, JSON.stringify({ status: 'pending', provider: providerName, userId }), 'EX', 300, ); - // In production this would construct an OAuth URL - const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; return { command: 'provider', success: true, - message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`, + message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`, conversationId, - data: { loginUrl, pollToken, provider: providerName }, + data: { provider: providerName }, }; } diff --git a/packages/log/src/index.ts b/packages/log/src/index.ts index 86bdffe..9b49ccf 100644 --- a/packages/log/src/index.ts +++ b/packages/log/src/index.ts @@ -10,3 +10,8 @@ export { type LogQuery, } from './agent-logs.js'; export { registerLogCommand } from './cli.js'; +export { + redactSensitiveContent, + type RedactionResult, + type SensitiveClassification, +} from './redaction.js'; diff --git a/packages/log/src/redaction.spec.ts b/packages/log/src/redaction.spec.ts new file mode 100644 index 0000000..9016398 --- /dev/null +++ b/packages/log/src/redaction.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { redactSensitiveContent } from './redaction.js'; + +describe('redactSensitiveContent', (): void => { + it('redacts seeded secret and PII canaries before persistence or egress', (): void => { + const result = redactSensitiveContent( + 'email canary@example.test token=sk_CANARY12345678 phone +1 555 555 1212', + ); + expect(result.content).not.toContain('canary@example.test'); + expect(result.content).not.toContain('sk_CANARY12345678'); + expect(result.content).not.toContain('+1 555 555 1212'); + expect(result.classifications).toEqual(['secret', 'pii']); + }); +}); diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts new file mode 100644 index 0000000..2bbbc0f --- /dev/null +++ b/packages/log/src/redaction.ts @@ -0,0 +1,35 @@ +export type SensitiveClassification = 'secret' | 'pii'; + +export interface RedactionResult { + content: string; + classifications: SensitiveClassification[]; +} + +const SECRET_PATTERNS: RegExp[] = [ + /\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g, + /\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi, +]; +const PII_PATTERNS: RegExp[] = [ + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, + /\b\+?\d[\d(). -]{7,}\d\b/g, +]; + +export function redactSensitiveContent(content: string): RedactionResult { + let redacted = content; + const classifications: SensitiveClassification[] = []; + for (const pattern of SECRET_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('secret'); + redacted = redacted.replace(pattern, '[REDACTED_SECRET]'); + } + pattern.lastIndex = 0; + } + for (const pattern of PII_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('pii'); + redacted = redacted.replace(pattern, '[REDACTED_PII]'); + } + pattern.lastIndex = 0; + } + return { content: redacted, classifications: [...new Set(classifications)] }; +} -- 2.49.1 From 0de3d514661d80d8a8c7e690e5db9acd5d49c639 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 18:47:20 -0500 Subject: [PATCH 12/16] fix(tess): redact chat persistence and egress --- apps/gateway/src/chat/chat.gateway.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 41fa81c..7640296 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -18,6 +18,7 @@ import { } from '@mosaicstack/discord-plugin'; import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; +import { redactSensitiveContent } from '@mosaicstack/log'; import type { SetThinkingPayload, SlashCommandApprovalResultPayload, @@ -317,7 +318,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa { conversationId, role: 'user', - content: data.content, + content: redactSensitiveContent(data.content).content, metadata: { timestamp: new Date().toISOString(), ...(correlationId @@ -327,6 +328,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa discordUserId: discordIngress?.userId, } : {}), + classifications: redactSensitiveContent(data.content).classifications, }, }, userId, @@ -831,8 +833,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa { conversationId, role: 'assistant', - content: cs.assistantText, - metadata, + content: redactSensitiveContent(cs.assistantText).content, + metadata: { + ...metadata, + classifications: redactSensitiveContent(cs.assistantText).classifications, + }, }, userId, ) @@ -856,17 +861,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa if (assistantEvent.type === 'text_delta') { // Accumulate assistant text for persistence const cs = this.clientSessions.get(client.id); + const redactedDelta = redactSensitiveContent(assistantEvent.delta).content; if (cs) { - cs.assistantText += assistantEvent.delta; + cs.assistantText += redactedDelta; } client.emit('agent:text', { conversationId, - text: assistantEvent.delta, + text: redactedDelta, }); } else if (assistantEvent.type === 'thinking_delta') { client.emit('agent:thinking', { conversationId, - text: assistantEvent.delta, + text: redactSensitiveContent(assistantEvent.delta).content, }); } break; -- 2.49.1 From 98b4f952841b92b2194d1d31af13e86255489ba2 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 19:23:14 -0500 Subject: [PATCH 13/16] test(tess): cover redacted chat egress --- .../src/chat/chat.gateway-redaction.spec.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 apps/gateway/src/chat/chat.gateway-redaction.spec.ts diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts new file mode 100644 index 0000000..0ac831a --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ChatGateway } from './chat.gateway.js'; + +const CONVERSATION_ID = 'conversation-1'; +const CANARY = 'token=sk_canary12345678'; + +type GatewayInternals = { + clientSessions: Map; + relayEvent(client: unknown, conversationId: string, event: unknown): void; +}; + +function buildGateway() { + const brain = { + conversations: { + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + + return { gateway: gateway as unknown as GatewayInternals, brain }; +} + +describe('ChatGateway redaction boundary', (): void => { + it('redacts an assistant delta before egress and before its persistence buffer', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + const session = { + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }; + gateway.clientSessions.set(client.id, session); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: CANARY }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET]', + }); + expect(session.assistantText).toBe('[REDACTED_SECRET]'); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); + }); + + it('persists only redacted assistant content with classifications', (): void => { + const { gateway, brain } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + gateway.clientSessions.set(client.id, { + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: CANARY, + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }); + + gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' }); + + expect(brain.conversations.addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: '[REDACTED_SECRET]', + metadata: expect.objectContaining({ classifications: ['secret'] }), + }), + 'user-1', + ); + expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY); + }); +}); -- 2.49.1 From 70851d9474e9b5b79dcad2b3df1833033fb7a0fd Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 19:32:58 -0500 Subject: [PATCH 14/16] fix(tess): preserve redaction across stream chunks --- .../src/chat/chat.gateway-redaction.spec.ts | 17 ++- apps/gateway/src/chat/chat.gateway.ts | 119 ++++++++++++++++-- 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index 0ac831a..5e46330 100644 --- a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ChatGateway } from './chat.gateway.js'; const CONVERSATION_ID = 'conversation-1'; -const CANARY = 'token=sk_canary12345678'; +const CANARY = 'sk_canary12345678'; type GatewayInternals = { clientSessions: Map; @@ -31,7 +31,7 @@ function buildGateway() { } describe('ChatGateway redaction boundary', (): void => { - it('redacts an assistant delta before egress and before its persistence buffer', (): void => { + it('redacts a secret split across assistant deltas before egress and persistence', (): void => { const { gateway } = buildGateway(); const client = { connected: true, @@ -51,14 +51,21 @@ describe('ChatGateway redaction boundary', (): void => { gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', - assistantMessageEvent: { type: 'text_delta', delta: CANARY }, + assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' }, + }); + + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary'); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' }, }); expect(client.emit).toHaveBeenCalledWith('agent:text', { conversationId: CONVERSATION_ID, - text: '[REDACTED_SECRET]', + text: '[REDACTED_SECRET] ', }); - expect(session.assistantText).toBe('[REDACTED_SECRET]'); + expect(session.assistantText).toBe(`${CANARY} `); expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); }); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 7640296..81979cc 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -108,6 +108,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly logger = new Logger(ChatGateway.name); private readonly clientSessions = new Map(); + /** Raw stream fragments are kept in memory only until they are safe to redact and emit. */ + private readonly textEgressBuffers = new Map(); + private readonly thinkingEgressBuffers = new Map(); private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( @@ -156,6 +159,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); this.clientSessions.delete(client.id); } + this.textEgressBuffers.delete(client.id); + this.thinkingEgressBuffers.delete(client.id); } private getClientScope(client: Socket): ActorTenantScope | null { @@ -746,6 +751,81 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private appendAndFlushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + delta: string, + ): void { + buffers.set(client.id, `${buffers.get(client.id) ?? ''}${delta}`); + this.flushRedactedEgress(client, conversationId, eventName, buffers, false); + } + + /** + * Holds any suffix that could become a secret, email, or phone number after a + * later stream chunk. This avoids relying on downstream redaction after data + * has already reached the socket. + */ + private flushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + final: boolean, + ): void { + const buffered = buffers.get(client.id) ?? ''; + const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); + const released = buffered.slice(0, releaseLength); + const pending = buffered.slice(releaseLength); + + if (pending) { + buffers.set(client.id, pending); + } else { + buffers.delete(client.id); + } + + if (released) { + client.emit(eventName, { + conversationId, + text: redactSensitiveContent(released).content, + }); + } + } + + private safeRedactionPrefixLength(content: string): number { + let retainedFrom = content.length; + + // Retain the current token because it may become a split secret or email. + const token = /(?:^|\s)(\S*)$/.exec(content); + if (token) { + const matched = token[0] ?? ''; + const trailingToken = token[1] ?? ''; + retainedFrom = token.index + matched.length - trailingToken.length; + } + + // The secret classifier accepts whitespace around ':' and '=', so preserve + // a pending label until its value and delimiter are both complete. + const secretLabel = /(?:api[_-]?key|token|password|secret)\s*[:=]\s*$/i.exec(content); + if (secretLabel) { + retainedFrom = Math.min(retainedFrom, secretLabel.index); + } + + // Phone numbers can contain whitespace and punctuation; preserve the full + // trailing numeric candidate until a non-phone character establishes a boundary. + const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content); + if (phone) { + const matched = phone[0] ?? ''; + const trailingPhoneCandidate = phone[1] ?? ''; + retainedFrom = Math.min( + retainedFrom, + phone.index + matched.length - trailingPhoneCandidate.length, + ); + } + + return retainedFrom; + } + private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { if (!client.connected) { this.logger.warn( @@ -763,6 +843,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa cs.toolCalls = []; cs.pendingToolCalls.clear(); } + this.textEgressBuffers.set(client.id, ''); + this.thinkingEgressBuffers.set(client.id, ''); client.emit('agent:start', { conversationId }); break; } @@ -791,6 +873,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } : undefined; + this.flushRedactedEgress( + client, + conversationId, + 'agent:text', + this.textEgressBuffers, + true, + ); + this.flushRedactedEgress( + client, + conversationId, + 'agent:thinking', + this.thinkingEgressBuffers, + true, + ); client.emit('agent:end', { conversationId, usage: usagePayload, @@ -859,21 +955,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'message_update': { const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type === 'text_delta') { - // Accumulate assistant text for persistence + // Keep raw stream material in memory only; persist and emit only redacted text. const cs = this.clientSessions.get(client.id); - const redactedDelta = redactSensitiveContent(assistantEvent.delta).content; if (cs) { - cs.assistantText += redactedDelta; + cs.assistantText += assistantEvent.delta; } - client.emit('agent:text', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: redactedDelta, - }); + 'agent:text', + this.textEgressBuffers, + assistantEvent.delta, + ); } else if (assistantEvent.type === 'thinking_delta') { - client.emit('agent:thinking', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: redactSensitiveContent(assistantEvent.delta).content, - }); + 'agent:thinking', + this.thinkingEgressBuffers, + assistantEvent.delta, + ); } break; } -- 2.49.1 From fdd58a353b800f26318c8c2485b143e263dec344 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 19:39:37 -0500 Subject: [PATCH 15/16] fix(tess): harden redacted stream boundaries --- .../src/chat/chat.gateway-redaction.spec.ts | 45 +++++++++++++++++++ apps/gateway/src/chat/chat.gateway.ts | 45 ++++++++++++++++++- packages/log/src/redaction.spec.ts | 11 +++++ packages/log/src/redaction.ts | 5 +++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index 5e46330..e4e6b64 100644 --- a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -69,6 +69,51 @@ describe('ChatGateway redaction boundary', (): void => { expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); }); + it('retains a split secret label until its value can be redacted', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'token ' }, + }); + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET] ', + }); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123'); + }); + + it('drops an oversized unterminated stream fragment rather than retaining it', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_STREAM_OVERFLOW]', + }); + }); + it('persists only redacted assistant content with classifications', (): void => { const { gateway, brain } = buildGateway(); const client = { diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 81979cc..ce9b47d 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -64,6 +64,7 @@ interface ClientSession { * Keyed by conversationId, value is the model name to use. */ const modelOverrides = new Map(); +const MAX_REDACTION_BUFFER_LENGTH = 8_192; function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { if (typeof value !== 'object' || value === null) return false; @@ -111,6 +112,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa /** Raw stream fragments are kept in memory only until they are safe to redact and emit. */ private readonly textEgressBuffers = new Map(); private readonly thinkingEgressBuffers = new Map(); + private readonly overflowedEgress = new Set(); private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( @@ -161,6 +163,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } this.textEgressBuffers.delete(client.id); this.thinkingEgressBuffers.delete(client.id); + this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); } private getClientScope(client: Socket): ActorTenantScope | null { @@ -758,7 +762,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa buffers: Map, delta: string, ): void { - buffers.set(client.id, `${buffers.get(client.id) ?? ''}${delta}`); + const key = this.egressKey(client, eventName); + if (this.overflowedEgress.has(key)) return; + + const buffered = `${buffers.get(client.id) ?? ''}${delta}`; + if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { + buffers.delete(client.id); + this.overflowedEgress.add(key); + client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); + return; + } + + buffers.set(client.id, buffered); this.flushRedactedEgress(client, conversationId, eventName, buffers, false); } @@ -774,6 +789,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa buffers: Map, final: boolean, ): void { + const key = this.egressKey(client, eventName); + if (this.overflowedEgress.has(key)) { + if (final) this.overflowedEgress.delete(key); + return; + } + const buffered = buffers.get(client.id) ?? ''; const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); const released = buffered.slice(0, releaseLength); @@ -806,7 +827,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // The secret classifier accepts whitespace around ':' and '=', so preserve // a pending label until its value and delimiter are both complete. - const secretLabel = /(?:api[_-]?key|token|password|secret)\s*[:=]\s*$/i.exec(content); + const pendingSecretLabel = + /(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec( + content, + ); + if (pendingSecretLabel) { + const label = pendingSecretLabel[1] ?? ''; + retainedFrom = Math.min( + retainedFrom, + pendingSecretLabel.index + pendingSecretLabel[0].length - label.length, + ); + } + + const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec( + content, + ); if (secretLabel) { retainedFrom = Math.min(retainedFrom, secretLabel.index); } @@ -826,6 +861,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return retainedFrom; } + private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string { + return `${client.id}:${eventName}`; + } + private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { if (!client.connected) { this.logger.warn( @@ -845,6 +884,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } this.textEgressBuffers.set(client.id, ''); this.thinkingEgressBuffers.set(client.id, ''); + this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); client.emit('agent:start', { conversationId }); break; } diff --git a/packages/log/src/redaction.spec.ts b/packages/log/src/redaction.spec.ts index 9016398..4a0fd18 100644 --- a/packages/log/src/redaction.spec.ts +++ b/packages/log/src/redaction.spec.ts @@ -11,4 +11,15 @@ describe('redactSensitiveContent', (): void => { expect(result.content).not.toContain('+1 555 555 1212'); expect(result.classifications).toEqual(['secret', 'pii']); }); + + it('redacts common provider credential formats', (): void => { + const result = redactSensitiveContent( + 'Authorization: Bearer canary.bearer.token jwt eyJcanary.eyJpayload.eyJsignature aws AKIACANARY1234567890', + ); + + expect(result.content).not.toContain('canary.bearer.token'); + expect(result.content).not.toContain('eyJcanary.eyJpayload.eyJsignature'); + expect(result.content).not.toContain('AKIACANARY1234567890'); + expect(result.classifications).toEqual(['secret']); + }); }); diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts index 2bbbc0f..d0d54d2 100644 --- a/packages/log/src/redaction.ts +++ b/packages/log/src/redaction.ts @@ -8,6 +8,11 @@ export interface RedactionResult { const SECRET_PATTERNS: RegExp[] = [ /\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g, /\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi, + /\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9._~+/-]+=*/gi, + /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + /-----BEGIN(?: [A-Z]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)* PRIVATE KEY-----/g, + /https?:\/\/[^\s?#]+[^\s]*[?&](?:token|key|secret|signature|sig)=[^\s&#]+/gi, ]; const PII_PATTERNS: RegExp[] = [ /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, -- 2.49.1 From 726f7ab7726ff3fe1cb8e6d7f65cdcae9b8d0802 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 19:43:53 -0500 Subject: [PATCH 16/16] test(tess): cover streamed private key redaction --- .../src/chat/chat.gateway-redaction.spec.ts | 25 +++++++++++++++++++ apps/gateway/src/chat/chat.gateway.ts | 11 ++++++++ packages/log/src/redaction.ts | 2 +- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index e4e6b64..23c7060 100644 --- a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -94,6 +94,31 @@ describe('ChatGateway redaction boundary', (): void => { expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123'); }); + it('holds a streamed private key until it can be redacted', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' }, + }); + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET]', + }); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary'); + }); + it('drops an oversized unterminated stream fragment rather than retaining it', (): void => { const { gateway } = buildGateway(); const client = { diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index ce9b47d..7b4f0b3 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -858,6 +858,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); } + const privateKeyStart = content.lastIndexOf('-----BEGIN'); + if (privateKeyStart >= 0) { + const privateKey = content.slice(privateKeyStart); + if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) { + // Release the complete block in one pass so the full-block classifier can redact it. + retainedFrom = content.length; + } else { + retainedFrom = Math.min(retainedFrom, privateKeyStart); + } + } + return retainedFrom; } diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts index d0d54d2..3a9320b 100644 --- a/packages/log/src/redaction.ts +++ b/packages/log/src/redaction.ts @@ -11,7 +11,7 @@ const SECRET_PATTERNS: RegExp[] = [ /\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9._~+/-]+=*/gi, /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, - /-----BEGIN(?: [A-Z]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)* PRIVATE KEY-----/g, + /-----BEGIN(?: [A-Z]+)* KEY-----[\s\S]*?-----END(?: [A-Z]+)* KEY-----/g, /https?:\/\/[^\s?#]+[^\s]*[?&](?:token|key|secret|signature|sig)=[^\s&#]+/gi, ]; const PII_PATTERNS: RegExp[] = [ -- 2.49.1