diff --git a/docs/memory/architecture/api-endpoints.md b/docs/memory/architecture/api-endpoints.md index e0c3c6f67..9893534d6 100644 --- a/docs/memory/architecture/api-endpoints.md +++ b/docs/memory/architecture/api-endpoints.md @@ -281,6 +281,8 @@ WebSocket events: `operator_queue_new`, `operator_queue_responded` / `operator_q | Method | Path | Description | |--------|------|-------------| | POST | `/api/skill-gate/check` | Asked by the agent image's PreToolUse hook (`docker/base-image/hooks/skill-gate.py`) before Claude Code loads a skill into a run (`routers/skill_gate.py` → `skill_gate_service.check_invocation`; `# mcp: none`). The agent comes from the KEY, never the body (`get_self_agent`: agent key → itself, system key → `trinity-system`, else uniform 403 `agent_identity_required`, then the access 404). Body `SkillGateCheckRequest` (`extra="forbid"`: `via` `skill_tool`/`subagent_preload`, `invoked`, `names` ≤ 64 × ≤ 256, `resolved`, `subagent`, `execution_id` ≤ 128, `marker`) → **200** `{allowed, gated, message}` for every verdict: not gated → allowed; gated → allowed only when the run is this agent's live run and a gate record (`dispatching` / `dispatched` / `self_approved`) clears every gated skill it loads; refusals carry the model-facing copy and are audited `skill_gate_refused` (throttled). **503** `gate_unavailable` when the gate map cannot be read (the hook then decides by its marker); **422** on a malformed body. A reported `marker` that disagrees with the map schedules a rate-limited marker re-sync | +| GET | `/api/agents/{agent_name}/skill-gates` | The agent's skill gate map (trinity-enterprise#753; `routers/skill_gate.py` `agent_router` → `skill_gate_map_service.list_gates`): `{gates:[{skill_name, approver, deadline_hours, origin, set_by, set_by_agent, set_at, approver_reachable}], cleared_defaults, approver_kinds, default_deadline_hours}`. `get_skill_gate_readable_agent_by_name`: a person or the system key — anything they can access (uniform 404); an agent key — its own gates, or holding `skills.manage` an agent its owner owns; connector / other / no scope → 403 `skill_gates_not_readable`. `set_by` is null for a non-person principal | +| PUT/DELETE | `/api/agents/{agent_name}/skill-gates/{skill_name}` | Set or change / clear a gate. `require_person_or_capability("skills.manage", self_person_only=True)` then `get_owned_agent_by_name`: a person who owns the agent or is an admin, or a holder on an agent its owner owns — never itself (403 `person_required`); the system key is refused. PUT body `SkillGateSetRequest` (`approver`, `deadline_hours`; only sent fields apply) → `{gate, changed, warnings}` (`approver_unassigned`, `marker_not_written`); named 422s `invalid_skill_name` / `invalid_approver` / `approver_unavailable` / `invalid_deadline`, 409 `ephemeral_agent`. DELETE is idempotent → `{changed, cleared: deleted \| tombstoned \| null}`. Audited | ### Respond → Resume Dispatch (ent#329) diff --git a/docs/memory/architecture/backend.md b/docs/memory/architecture/backend.md index 9af7337d0..ac969108c 100644 --- a/docs/memory/architecture/backend.md +++ b/docs/memory/architecture/backend.md @@ -105,7 +105,7 @@ *Subscriptions & Skills:* - `subscriptions.py` - Subscription management (SUB-002). **#2572:** `POST` now also runs the credential-less adoption sweep (`subscription_service.adopt_for_credentialless_agents`) beside the #1089 rollover fan-out, in its own swallow-everything `try/except` — the sweep must never fail a registration whose credential is already stored, which is why the body param is `payload` and the injected one `http_request` (two things called `request` is how a log line inside an `except` raises `AttributeError` into a 500). The manual assign/clear routes now write the `subscription_assign` / `subscription_clear` audit rows this router had never carried (#2421 owns the remaining three actions); id and name only, never a token. #471 usage observability — extended `GET /{id}/usage` (failure counters + one-gate `rate_limited_now` + `headroom` block, `source: anthropic|observed`), `GET /{id}/usage/breakdown` (per-agent, cost-ranked), `POST /{id}/usage/refresh` (click probe), `GET/PUT /settings/headroom-auto-refresh` — all `assert_admin`; ent#433 adds `GET /{id}/headroom/history?window=24h|7d|30d` (bounded `last`-per-bucket series, `bucket_start` + real `fetched_at`, `coverage_pct`; 422 on an unknown window, id-OR-name + 404 parity with `/usage`; read-only, never probes); ent#434 adds `PUT /settings/headroom-alert-threshold` (`0` disables — the `operator_queue_retention_days` idiom — else 50–99 with a named 422; the escalation tier is DERIVED `max(threshold, 90)`, never a second knob, because two settable thresholds are an oscillator and `validate_ops_setting` is per-key so it cannot express the cross-field invariant) and extends the auto-refresh GET with a `weekly_alert` block carrying `active` + an `inactive_reason` of `no_subscriptions` / `threshold_disabled` / `auto_refresh_off` / `redis_unavailable` / `count_unavailable`, so "no alerts" is distinguishable from "not checking" (#2217) — an unreadable subscription list is its OWN reason rather than falling through to `active: true`, which it did while `None == 0` decided the first arm. Rendered by the threshold control in `SubscriptionsPanel.vue`, whose decidable rules live in `utils/headroomAlertSettings.js` (vitest runs `environment: 'node'` with no mount harness, so a rule inside the SFC is untestable — the ent#392 precedent). The key is 422-blocked on the generic `PUT /api/settings/{key}` - `skills.py` - Skill CRUD and assignment. **#2703:** assignment DELIVERS — `POST`/`PUT` call `skill_service.deliver_assigned` (the start-path injection, all assigned + `force=False`; a subset would rewrite CLAUDE.md's section) and return a `delivery` block (`injected|partial|pending_start|in_progress|not_delivered+reason`) on a 200 the row never rolls back; bounded by `SKILL_DELIVERY_BUDGET_SECONDS` with the work kept alive past it. **ent#672:** `delivery.skills[name].warnings` names a deprecated skill on every outcome (read from the library entry, so a stopped agent is told too); `GET /skills/library` carries `deprecated` / `superseded_by`. All four routes fire the thin `agent_skills_changed` WS trigger (`skill_service.broadcast_skills_changed`, identifiers only — #918 rule) — see [skill-injection.md](../feature-flows/skill-injection.md) -- `skill_gate.py` - The in-container skill-gate hook's check, `POST /api/skill-gate/check` (trinity-enterprise#752): the agent from its key (`get_self_agent`), every verdict a 200 — see [skill-gate.md](../feature-flows/skill-gate.md) +- `skill_gate.py` - The in-container skill-gate hook's check, `POST /api/skill-gate/check` (trinity-enterprise#752): the agent from its key (`get_self_agent`), every verdict a 200; and the per-agent gate map, `/api/agents/{agent_name}/skill-gates` (`agent_router`, trinity-enterprise#753) — see [skill-gate.md](../feature-flows/skill-gate.md) - `settings/` (package, #1028 — eleven domain modules, `provider_keys` carrying the #2715 Resend/Gemini routes split out of `credentials`; `generic.py`'s `/{key}` catch-all is included LAST, Invariant #4) - Platform admin settings (incl. Slack transport connect/disconnect/install). **#2572:** both Anthropic-key clear paths fire the credential-less adoption sweep, each gated on the route's existing `deleted` truthiness — the dedicated `DELETE /api-keys/anthropic` **and** the generic `DELETE /{key}` keyed on the two key aliases, because `db.delete_setting` has no delete-side twin of ent#435's `db.set_setting` sink guard and so reaches the key without ever touching `clear_secret_setting`. The hook sits on the ROUTES, not that leaf, which also serves `github_pat` and the Slack keys. Hosts the Tier-2 usage-sharing family (ent#12/ent#437): `GET /telemetry-sharing` (admin + human-only; `?preview=0` skips the aggregate build), `PUT /telemetry-sharing` (consent; mints/discards the share id), `POST /telemetry-sharing/ask/dismiss` (the once-per-install "don't ask again" marker) — see [Opt-in Instance Telemetry](#opt-in-instance-telemetry-ent437) *Content & Files:* @@ -179,7 +179,7 @@ - `monitoring_alerts.py` - Alert threshold configuration - `heartbeat_service.py` - Agent push-heartbeat liveness layer — see [Heartbeat Liveness](reliability.md#heartbeat-liveness-reliability-004-307) - `platform_alerts.py` - The platform-alert seam (#3246, OPS-001-PLATFORM-ALERTS, requirements §26.14). Stdlib-only leaf (DB imported lazily): the `Kind` registry (prefix, lifetime class, clear hook, `material_keys`), `observe(agent, kind, key, …)` → at most one pending row per subject `:` updated in place (`last_seen_at`, `context.seen_count`), `clear` / `reconcile` → ended `cancelled` by the **platform** (`condition_cleared`), lifetime (`OPERATOR_PLATFORM_ALERT_LIFETIME_DAYS`, 14) and snooze (`OPERATOR_PLATFORM_ALERT_SNOOZE_DAYS`, 7) readers, and the pure `plan_sweep` the upgrade migration runs. Never raises into an emitter. Only four emitter families use it in PR 1 (headroom, skills adoption, system agent, circuit breaker); the rest are ratcheted in `PREFIXES_NOT_YET_ON_SEAM` until PR 2. -- `operator_queue_service.py` - Operating Room sync with agent containers (OPS-001); the agent-authored ingestion boundary enforces the per-agent depth/rate/size caps + reserved-id guard + leader lock (#1632) +- `operator_queue_service.py` - Operating Room sync with agent containers (OPS-001); the agent-authored ingestion boundary enforces the per-agent depth/rate/size caps + reserved-id guard + leader lock (#1632); platform-minted alerts (`is_platform_minted`) have no agent audience — never written to the agent file, and an operator answer lands them `acknowledged` (#2372) - `operator_queue_choices.py` - The one rule for what an approval's `response` may be (#2376, #3242): an offered option (exact match, else `ResponseNotOfferedError` with the list) or the reserved `SOMETHING_ELSE = "(something else)"` with an instruction in `response_text` (`InstructionRequiredError` / `ReservedValueError` / `NotOffMenuError`, all `ReservedAnswerError`); import-free leaf the ask sink calls before any write; also holds `OPTIONS_DROPPED_MARKER`. Mirrored in `utils/operatorQueue.js` + MCP `types.ts` (parity-tested) - `sync_health_service.py` - Git sync health polling — see [Git Sync Health](agent-lifecycle.md#git-sync-health-389390) - `sync_freeze_policy.py` - The one sync-health rule (state / reason / recommendation / freeze), stdlib leaf, vendored byte-identically to `src/scheduler/` (ent#706) — see [Git Sync Health](agent-lifecycle.md#git-sync-health-389390) @@ -233,6 +233,8 @@ *Skills & System:* - `skill_service.py` - Skills library sync + full-directory package injection (ent#183): `git archive`-sourced tars via the existing agent-server restore primitive, tree-SHA versioning, manifest prune, declaration-only dep check — see [skill-injection.md](../feature-flows/skill-injection.md). **ent#236** adds the removal half — `remove_skills` (manifest-driven, takes the same per-agent inject lock) and `reconcile_agent_skills` (start-path diff of the agent's platform-managed skill dirs against the assignment set, so a removal reaches an agent that was stopped when it happened), plus durable sync status in `system_settings` (the in-process `_last_sync` is invisible to the other uvicorn worker). **ent#237** makes it multi-source: `sync_library` iterates every enabled `skill_sources` row under ONE library-wide lock, one failing source never blinds the others (per-source outcomes, aggregate succeeds if any synced), and `commit_changed` — the gate ent#236's fleet re-inject fires on — is computed per source against that source's own durable `last_commit_sha` and OR'd. `services/skill_source_clone.py` owns one checkout's git lifecycle (clone/fetch/reset/checkout, tag-pin refusal on **both** the update and clone paths — the update path compares the tag *peeled* (`^{commit}`), because every `trinity-skills` release tag is annotated and a bare rev-parse of one is the tag object, not the recorded commit (#2550) — non-repo quarantine); `skill_service` orchestrates N of them. **Editing a source has to reach disk**, which took three fixes of one shape (row moved, checkout didn't): (1) `_update_*` fetches **`origin`**, written at clone time, so a repointed `url` kept pulling the OLD repo forever while reporting `success` with a moving commit that fed the fleet re-inject — `sync` now compares `origin` against the configured remote (`canonical_remote`, credential-stripped so a PAT-bearing `origin` isn't a false repoint) and **discards + re-clones** on a genuine mismatch, since `remote set-url` would leave the old repo's tag refs behind and a tag name present in both at different commits then reads as a moved pin forever; an unreadable `origin` counts as a match, because the action gated on it is an `rmtree`. (2) `update_source` clears the sync bookkeeping on a `url`/`ref`/`ref_type` change — without it the documented tag-bump path (`v1` → `v2`) compared v2 against v1's recorded SHA and was refused as `moved_tag`, and on the fresh-clone path that refusal `rmtree`s first, so bumping a tag emptied the source. Non-identity edits (name/enabled/priority) must NOT clear it: a cleared baseline on disable would let a tag moved during the disabled window be adopted silently on re-enable. (3) `discard_source_checkout` reclaims `/` + `.broken` on delete, with `_reclaim_orphan_checkouts` (full sweeps only, **fail-closed** on the row read, id-shape-scoped so the legacy checkout and operator dirs are out of scope, logs what it reclaims per #1644) as the backstop for the crash window and for installs predating the fix. **ent#332** makes each source's layout root resolvable — `catalog.yaml` `skills_root:` (ent#314 hardened parse, segment-wise validation, realpath containment) → evidence-gated `skills/` probe (dual-layout keeps legacy + flags `layout_conflict`) → `.claude/skills/` fallback; every invalid tier falls through, and `skill_packaging.filter_skill_archive(source_root=…)` rewrites tar arcnames to the canonical agent-side `.claude/skills/` destination so manifests/prune/removal stay destination-canonical with zero migration (requirements §21.1.4) - `skill_packaging.py` - Pure skill-package primitives (ent#183): hardened frontmatter contract parse, archive member vetting, injection-tar assembly, prune diff; `compute_removal` (ent#236) is `compute_prune` against an empty new manifest, so confinement + cap live in one place. **ent#672:** the contract carries the library's lifecycle keys (`deprecated`, `superseded-by` → `superseded_by`, the author's text) and `deprecation_warning` is the one producer of the `deprecated[:]` code — see [skill-injection.md](../feature-flows/skill-injection.md) +- `skill_gate_service.py` - The gated-skill check (trinity-enterprise#751): `enforce` at every dispatch road (raises `SkillApprovalRequired` / `SkillGateRefused`, `skill_gate_errors.py`), exactly-once approved dispatch from `skill_gate_requests`, the in-container hook's `check_invocation` (#752) and the fail-closed marker sync; `list_skill_gates` is the one read of the gate map — see [skill-gate.md](../feature-flows/skill-gate.md) +- `skill_gate_map_service.py` - The per-agent gate map's only writer (trinity-enterprise#753): set / clear / the state-driven library-default reconcile / a person's unassign, each under the per-agent marker lock; called by `routers/skill_gate.py`, `routers/skills.py::_sync_gates`, the start path and the library sweep - `skills_sync_service.py` - Scheduled skills-library auto-sync + fleet-wide re-inject (ent#236). Leader-locked (`skills:sync:leader`), both flags default OFF; sweeps only when the library commit actually changed, over **running** non-ghost agents at bounded concurrency with skip-and-report on inject-lock contention. Backend-hosted rather than in the standalone scheduler because the sweep must reach agent containers and the scheduler is platform-network-only - `skill_sets.py` / `skill_set_service.py` - Skill sets (trinity-enterprise#530): the pure catalog parser + `plan_member_rows` (fail-closed), and resolution (skill precedence, fingerprint-cached), per-agent honest status, assign/unassign/replace. Every inject path (start, manual Sync, fleet re-inject) runs `reconcile_agent` BEFORE reading the names, then prunes. See [database.md](database.md) → agent_skill_sets - `system_agent_service.py` - System agent lifecycle. **#1816:** `ensure_deployed` is read-only when the container is running (3-state `check_base_image_state` → `base_image_state`, WARNING + an edge-triggered `base-image-stale-` operator alarm on `stale` only, **never** on `unknown`) and delegates to `start_agent_internal` when it is stopped, so the cold boundary adopts a rebuilt base image through the shared lifecycle instead of a bare `container_start`. Creation converges on the recreate path's contract (`TRINITY_AGENT_AUTH_TOKEN`, `trinity.full-capabilities`) so no predicate is permanently false. The generic `recreate_missing_container` rebuild **refuses** `trinity-system` (409, ADOPT-006) — it reconstructs a *regular* agent and would irreversibly downgrade the orchestrator (system-scoped MCP key deactivated for an agent-scoped one); `ensure_deployed`'s create branch is the only supported rebuild diff --git a/docs/memory/architecture/database.md b/docs/memory/architecture/database.md index 898811516..b897db783 100644 --- a/docs/memory/architecture/database.md +++ b/docs/memory/architecture/database.md @@ -1162,6 +1162,28 @@ Both tracks: SQLite `agent_capability_grants`, Alembic `0072_agent_capability_gr `AgentRef(..., CASCADE)` — rename re-keys, delete removes. Every read joins `agent_ownership` and filters `deleted_at`, so a soft-deleted agent holds nothing and recovery restores its grant. +**skill_gate_requests** (trinity-enterprise#751) — a gated-skill request frozen while its approval ask is open (`db/skill_gate_requests.py`); the ask records the decision, this row the effect: `pending → dispatching` is a CAS and `dispatched_execution_id` is UNIQUE, so an approval runs exactly once; a `self_approved` row (#752) is the clearance the in-container hook honours. Both tracks (SQLite `skill_gate_requests_table`, Alembic `0088`); `agent_name` and `source_agent` are CASCADE AgentRefs; machine keys never read these rows (#715). + +**agent_skill_gates** (trinity-enterprise#753) — the per-agent skill gate map the gated-skill +check reads (`skill_gate_service.list_skill_gates`; writes only through +`services/skill_gate_map_service.py`): +```sql +CREATE TABLE agent_skill_gates ( + agent_name TEXT NOT NULL, + skill_name TEXT NOT NULL, -- lowercased; every matcher casefolds + approver TEXT NOT NULL, -- primary | approver + deadline_hours INTEGER, -- 1..168; NULL → the 24h default + origin TEXT NOT NULL, -- set | library_default | cleared (tombstone, gates nothing) + set_by TEXT NOT NULL, + set_by_agent TEXT, -- R29 provenance; not an AgentRef + set_at TEXT NOT NULL, + PRIMARY KEY (agent_name, skill_name) +); +``` +Both tracks: SQLite `agent_skill_gates`, Alembic `0094_agent_skill_gates` (← `0093_platform_alert_responded_heal`). +`AgentRef(..., CASCADE)`. Writes run under `lock_agent_rows` and land only while the agent has a +live ownership row; the PK is the ON CONFLICT target, declared in `tables.py` as well. + **Tag pinning is the supply-chain control (AC#5).** Skills carry executable `scripts/` that the ent#139 runner executes and ent#236 re-injects fleet-wide unattended, so the community source — which takes public PRs — pins to a **tag we bump**, never a branch diff --git a/docs/memory/architecture/execution.md b/docs/memory/architecture/execution.md index 76e9b7d44..049f0e4aa 100644 --- a/docs/memory/architecture/execution.md +++ b/docs/memory/architecture/execution.md @@ -116,6 +116,8 @@ Every sync `/chat` / `/task` failure the backend classifies carries an **additiv ### Skill Gate — approval before dispatch (trinity-enterprise#751) +The gate map it reads is `agent_skill_gates` (trinity-enterprise#753 — set over REST/MCP or by a library `approval: recommended` default; see `feature-flows/skill-gate.md` §6). A failed read of it refuses (`gate_unavailable`), never dispatches. + A request that invokes a skill marked "requires approval" on its target agent is never dispatched: `services/skill_gate_service.enforce` freezes it in `skill_gate_requests`, raises an approval through the ask sink (`raise_ask(raised_by="gate")`) and **raises** `SkillApprovalRequired` (or `SkillGateRefused`) — an exception, never a result status, because every existing caller reads an unknown status as success (`routers/paid.py` would settle). Requirement and full behaviour: `requirements/security.md` §26.13; flow: [skill-gate.md](../feature-flows/skill-gate.md). - **Placement.** `/chat` (`admit_chat_request`) and `/task` (`dispatch_parallel_task`) call it right after `enforce_inter_agent_depth` — before the idempotency claim, the slot, the row and the activities (#2806 order) — and mark everything downstream `gate_checked=True` (the async path, the backlog drain and the sync path). Every other producer meets the **backstop** at step 1b of `execute_task`, inside the `try` and before `_admission_gate`: a gated request holds no slot, opens no activity and is never enqueued; a pre-created row is closed `SKIPPED` through `_write_terminal_and_gate` (the #1804 admission-path shape — no activity exists yet), and `execute_task` re-raises `SkillGateError` ahead of its `except Exception` (which would otherwise write FAILED). The voice tool (`gemini_voice._execute_tool`) reaches the agent without `execute_task` and refuses instead (`refuse_only=True`). diff --git a/docs/memory/architecture/mcp-server.md b/docs/memory/architecture/mcp-server.md index f06142661..80bc1f208 100644 --- a/docs/memory/architecture/mcp-server.md +++ b/docs/memory/architecture/mcp-server.md @@ -14,7 +14,7 @@ FastMCP, Streamable HTTP transport, port 8080. API-key auth via `Authorization: Bearer` header; FastMCP `authenticate` callback validates keys against the backend and stores an `McpAuthContext` in session: `{userId, userEmail, keyName, agentName?, scope: "user"|"agent", mcpApiKey}`. Agent-to-agent collaboration uses agent-scoped keys for access control. -**Tools** — 132 across 33 tool modules (`src/tools/`), plus `dynamic-agents.ts`, which registers the per-agent `chat_with_` tools at runtime (#846). Every backend router outside the by-design exclusions (`internal`, `setup`, `auth`, `public`, `paid`) declares its MCP surface in a first-line `# mcp:` header — `# mcp: .ts ()` when its tools live in a differently named module, `# mcp: none — ` when it is deliberately unexposed (Invariant #13). The tool table follows the policy note below: +**Tools** — 135 across 33 tool modules (`src/tools/`), plus `dynamic-agents.ts`, which registers the per-agent `chat_with_` tools at runtime (#846). Every backend router outside the by-design exclusions (`internal`, `setup`, `auth`, `public`, `paid`) declares its MCP surface in a first-line `# mcp:` header — `# mcp: .ts ()` when its tools live in a differently named module, `# mcp: none — ` when it is deliberately unexposed (Invariant #13). The tool table follows the policy note below: **A self-acting tool takes its identity from the key, and the platform's own agent has a system-scoped one (#2975).** `report`, the four canvas tools and the four metrics tools declare no agent-target parameter on purpose — they act as the caller, so there is nothing to spoof and the identity must come from the credential. Each resolved that itself with `scope === "agent" && agentName`, which refused `trinity-system`: `system_agent_service` mints its key agent-scoped and then flips the scope to `system` (`_set_system_scope`), and #1816 makes that permanent — the orchestrator's key is never re-minted as `agent`, so "give it an agent-scoped key instead" is not an available fix. The platform's own agent could read every surface and publish nothing; its daily fleet-health report and its canvas fell back to files and the operator queue. `access.ts::resolveActingAgent` is now the single rule, for the same reason `checkAgentEdge` is: the refusal had three copies and the fourth would have shipped with the old one. It stays an ALLOWLIST over a free-text `scope` column (#1854, #2323) — `agent` and `system`, and only when the key CARRIES an `agentName`, which comes from the key row and never from a parameter. `connector` is the one worth naming as excluded: it carries an `agentName` too (it is bound to one agent) but it is an END USER's consumption key, so admitting it would let a client publish as the agent serving them. The backend half already worked: those routes are `AuthorizedAgent` plus a self-gate that reads `current_user.agent_name`, which is set only for `scope == "agent"` (`dependencies.py`), so a system key was never the thing refusing. @@ -26,7 +26,7 @@ FastMCP, Streamable HTTP transport, port 8080. API-key auth via `Authorization: | `chat.ts` (4) | `chat_with_agent`, `get_chat_history`, `get_agent_logs`, `fan_out` | Chat (enforces sharing rules), history, logs; `fan_out` = parallel dispatch of one message to several agents (FANOUT-001). Both sync modes apply `MCP_CHAT_TIMEOUT_MS` (default 25000) — sequential `/chat` (#914) and parallel `/task` (#2661); on abort the client queries `/api/agents/{name}/executions` and returns `{status:"queued_timeout", execution_id, message}` so callers poll instead of duplicate-queueing. **Attribution must be provable, never newest-wins**: `/task` runs N tasks concurrently, so `triggered_by`/`source_mcp_key_id`/window are identical across one caller's rows — the matcher also keys on the call's own `message`, takes its trigger set per call site (`/chat` must NOT accept `self_task`), derives the window from the timeout (a fixed 30s made raising the documented knob a silent kill-switch), and returns **nothing when >1 candidate survives**. A duplicate under the same `Idempotency-Key` is answered from the 409's `execution_id` instead of an opaque `API error (409)`. The recovery read is separately bounded (`MCP_RECOVERY_TIMEOUT_MS`, no 401-reauth) because it spends what is left of the gateway budget. **`fan_out` is the third route (#2670)**, and the one that exceeds the gateway most reliably — a batch runs longer than any single task in it. Its receipt names a `fan_out_id`, not an `execution_id`: N rows share one id, so a single execution id could only name an arbitrary member. Ambiguity is therefore measured on the BATCH (>1 distinct `fan_out_id` ⇒ no receipt), and status is deliberately NOT filtered — by abort time a batch is normally a mix of finished and running rows, so requiring non-terminal ones would drop the batches furthest along. Poll `get_fan_out_result`, which folds `schedule_executions` rather than the idempotency snapshot (that snapshot is written by `complete()`, i.e. only once the batch is over, so it cannot answer what is happening now). **Chain-depth refusal (#2806):** `client.chat`/`task`/`fanOut` turn a 403 carrying `detail.error == "inter_agent_depth_exceeded"` into a typed `DepthRefusal` (`parseDepthRefusal`) instead of throwing; `runAgentChat` (every branch) and `fan_out` return it as `{status: "inter_agent_depth_exceeded", agent, depth, max_depth, retryable: false, message}`. A 403 without the code still throws. **Delegation contract (ent#568):** `src/delegation_contract.ts` is the one text the platform prompt's §Agent Collaboration also carries (byte parity: `tests/unit/test_ent568_delegation_contract.py`); `chat_with_agent` and every `chat_with_` publish it verbatim, `fan_out` / `send_message` repeat its rule sentence, and each description stays ≤ 2,048 chars because Claude Code cuts MCP tool descriptions there (`CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH`; per-mode detail lives in parameter descriptions, which are not cut). Every `execution_id` receipt a `chat_with_*` caller gets carries `readNotResend` — the MCP server's own `queued_timeout` and 409 replay, and the backend's `accepted`/`queued`/sync-`/task` `queued_timeout` snapshots, whose REST-only "poll GET …" line `runAgentChat` rewrites with a liveness-neutral lead; `fan_out_timeout` keeps its `get_fan_out_result` line. **Parent forwarding (ent#224/#3232):** `resolveReportBack` decides once per call whether `parent_execution_id` goes on the `/task` body (parallel and #946 pull-routed branches; sequential `/chat` never carries one) and which `report_back` fields the result gets. Async dispatches send the platform header turn (#2392) by default; a typed `execution_id="manual"` opts out; a self-task with `inject_result` is excluded; sync parallel is opt-in by a typed id; the header turn wins over a typed id, which is forwarded only if `isWellFormedExecutionId` accepts it. `callerTurn` (the forwarded `X-Trinity-Execution-Id`) is computed separately and never cleared by the opt-out. One `[Report-Back #3232]` log line per call records route, arm, source and verdict. `EXECUTION_ID_PARAM_DESCRIPTION` is the one parameter text shared with `dynamic-agents.ts`. Kill switch `MCP_REPORT_BACK_ENABLED` (default `true`): `createServer` reads it, passes it to `createChatTools`, and returns it so `index.ts` hands it to `startExposedToolsReconciler` (`ReconcilerOptions.reportBackEnabled` is required, so `tsc` fails if a start site forgets it). Second consumer of `parent_execution_id`: on agent images without the #2392 header the backend skill gate reads it as the requester execution (validated there as the caller's own). | | `schedules.ts` (8) | `list_agent_schedules`, `create_agent_schedule`, `get_agent_schedule`, `update_agent_schedule`, `delete_agent_schedule`, `toggle_agent_schedule`, `trigger_agent_schedule`, `get_schedule_executions` | Schedule CRUD and execution history | | `executions.ts` (5) | `list_recent_executions`, `get_execution_result`, `get_fan_out_result`, `get_agent_activity_summary`, `search_executions` | Execution queries, async result polling, activity monitoring (MCP-007). `get_execution_result` returns `compact_metadata` parsed (#2958), so a long turn reads as a one-off auto-compaction rather than a degraded agent. `get_fan_out_result` polls a BATCH — `GET /api/agents/{name}/fan-out/{fan_out_id}`, the only surface that answers while it is still running (#2670). `search_executions` (ent#653) proxies the entitlement-gated `/api/enterprise/execution-search` route (`mode=substring` or `mode=regex`, the latter PostgreSQL-only): a per-tool `canAccess` ALLOW-LIST of `system`/`user` scope (agent-scoped sessions never see it, and the backend refuses them regardless) and an honest `available:false` envelope when the module is absent (404) or unentitled (403) — never "no results" | -| `skills.ts` (11) | `list_skills`, `get_skill`, `get_skills_library_status`, `list_skill_sets`, `assign_skill_to_agent`, `set_agent_skills`, `sync_agent_skills`, `get_agent_skills`, `unassign_skill_set`, `list_runnable_skills`, `run_skill` | Skill management and assignment, plus the deterministic skill runner (ent#139). **Lifecycle (ent#672):** `list_skills` maps `deprecated` / `superseded_by` by name (a field it does not name stops at REST); the assign tools pass `delivery.skills[name].warnings` through unchanged. **Skill sets (ent#530):** `list_skill_sets` reads `GET /api/skills/library/sets` (names, status, members, problems — no agent target); `set:` is accepted wherever a skill name is — `assign_skill_to_agent` routes it to `POST /skill-sets/{set}`, `set_agent_skills` forwards it to the PUT (`set:` entries only add sets), `get_agent_skills` adds `individual`/`via_sets` per skill and the agent's `sets` (a failed set read omits the key, never the skills). The four write tools are `baselined` on the **backend skill-manager fence** (ent#596): an agent key needs the `skills.manage` capability — for its own skills too — or the call is refused with `skill_management_not_permitted` and nothing changes. `get_agent_skills` (a read) stays on ENT629 | +| `skills.ts` (14) | `list_skills`, `get_skill`, `get_skills_library_status`, `list_skill_sets`, `assign_skill_to_agent`, `set_agent_skills`, `sync_agent_skills`, `get_agent_skills`, `unassign_skill_set`, `list_skill_gates`, `set_skill_gate`, `clear_skill_gate`, `list_runnable_skills`, `run_skill` | Skill management and assignment, plus the deterministic skill runner (ent#139). **Lifecycle (ent#672):** `list_skills` maps `deprecated` / `superseded_by` (and `approval`, ent#753) by name (a field it does not name stops at REST); the assign tools pass `delivery.skills[name].warnings` through unchanged. **Skill sets (ent#530):** `list_skill_sets` reads `GET /api/skills/library/sets` (names, status, members, problems — no agent target); `set:` is accepted wherever a skill name is — `assign_skill_to_agent` routes it to `POST /skill-sets/{set}`, `set_agent_skills` forwards it to the PUT (`set:` entries only add sets), `get_agent_skills` adds `individual`/`via_sets` per skill and the agent's `sets` (a failed set read omits the key, never the skills). The four write tools are `baselined` on the **backend skill-manager fence** (ent#596): an agent key needs the `skills.manage` capability — for its own skills too — or the call is refused with `skill_management_not_permitted` and nothing changes. `get_agent_skills` (a read) stays on ENT629. **Skill gates (ent#753):** `list_skill_gates` / `set_skill_gate` / `clear_skill_gate` proxy `/api/agents/{agent}/skill-gates[/{skill}]`; `set_skill_gate` sends only the fields given (an omitted field keeps its stored value; `deadline_hours: null` resets), zod refuses a bad approver or a deadline outside 1–168 before the round trip; `baselined` on the backend gate fence (`SKILL_GATE_FENCE`) — a holder never on itself, an agent key reads only its own gates | | `tags.ts` (5) | `list_tags`, `get_agent_tags`, `tag_agent`, `untag_agent`, `set_agent_tags` | Agent tagging | | `systems.ts` (5) | `deploy_system`, `list_systems`, `restart_system`, `get_system_manifest`, `teardown_system` | System manifest deployment + teardown. `teardown_system` (ent#454) is a **license-blind proxy** over an entitlement-gated route: it degrades rather than throws, keeping 404 (route absent on this build) / 403 (not entitled, or an agent key on a human-only verb) / 503 (membership unverified, retryable) as three distinct operator situations, and returns a `failed` report that arrives as a 500 AS a result. `dry_run` defaults to **`true`** — the opposite of `deploy_system`, because an unwanted preview costs a round trip and an unwanted execute costs a fleet | | `subscriptions.ts` (6) | `register_subscription`, `list_subscriptions`, `assign_subscription`, `clear_agent_subscription`, `get_agent_auth`, `delete_subscription` | Subscription management | @@ -52,6 +52,7 @@ FastMCP, Streamable HTTP transport, port 8080. API-key auth via `Authorization: | `a2a_call.ts` (2) | `call_a2a_agent`, `get_a2a_task` | **Outbound** A2A runtime (#736) — task an external A2A agent through an operator-registered endpoint chosen **by name**; a URL parameter is deliberately absent (an agent's tool args are LLM-generated and prompt-injectable, so it would be a server-side-request primitive). `dedup_label` is REQUIRED — the effect guard keys on the endpoint + conversation, never the message, so a reused label replays the earlier answer. Separate module from `a2a.ts`, whose **inbound** management plane is entitlement-gated; this runtime and the three outbound control tools beside it are OSS-core. Agent-scoped gate is **self-only**, matching the backend — `{self} ∪ permitted` here would deny a strict subset of what the backend denies, i.e. block nothing at the cost of a round-trip. Own `AbortController` (40s) so the MCP server gives up before its gateway does and can report `possibly_delivered`. A **402** becomes `payment_required` + `payment` + `task_id` + `do_not_retry` — set even when the body is a proxy's HTML page, since the status is the fact — and a 502 carrying `detail.reason` becomes `remote_forbidden` or `payment_rejected`, read through the defensive detail-unwrap pattern so a parser cannot turn a readable refusal into a crash. Both descriptions name the ACTOR (relay to a person once, do not retry, do not re-route, pass the returned `task_id` afterwards), because `do_not_retry` with no named actor produces an agent that tries a different endpoint instead (#3185) | | `auth.ts` (2) | `request_login`, `verify_login` | #848 inline email auth — sign in from an MCP client with **no** pre-minted API key. Registered ONLY when `MCP_INLINE_AUTH_ENABLED` is on, and advertised ONLY to the `anonymous` session tier (`anonymousOnly`). `request_login(email)` mails the standard 6-digit code and returns one **constant** receipt on every path (enumeration-safe, no audit row); `verify_login(code)` upgrades the session **in place** — the object FastMCP hands every tool — recording the verified email + the agents it may reach. `scope` deliberately stays `"anonymous"` after login (the session still holds no credential and must never satisfy `operatorOnly`; pinned by test). The advertised tool list is **identical before and after login** — login flips *behaviour*, not *visibility*, because `toolsListChanged` re-filters live sessions and the #846 reconciler fires it every ~20s, which would make a login-keyed gate flip non-deterministically. Sessions are per-connection, so a client restart requires signing in again. **The in-place upgrade only survives because the context is memoized (#2035):** streamable HTTP is discrete POSTs, `mcp-proxy` re-runs `authenticate` on each and `FastMCPSession#updateAuth` REPLACES rather than merges, so a fresh context per request discarded every login — `verify_login` succeeded and the next call answered `login_required`. `createAnonymousSessionStore` returns the same object per `Mcp-Session-Id`; anonymous tier only (a keyed session re-validates its key every request, pinned by a source guard), bounded, 30 min idle / 4 h absolute. See [mcp-connector.md](../feature-flows/mcp-connector.md) | | `a2a.ts` (7) | `get_agent_a2a_config`, `set_agent_a2a_exposure`, `get_agent_a2a_card`, `set_a2a_inbound_allowlist`, `register_a2a_endpoint`, `list_a2a_endpoints`, `remove_a2a_endpoint` | A2A management plane — per-agent exposure + card (ent#157), the inbound allow-list seam, and the outbound endpoint registry (#736). **One module, two planes, two error mappers (ent#761).** The four inbound tools proxy the entitlement-gated management router and keep the entitlement-aware mapper (403 → `not_entitled`, 404 on an OSS-only build). The three outbound control tools address the OSS platform-wide endpoint store (`GET/PUT /api/settings/a2a-endpoints`, `DELETE …/{ref}`) — the only store `a2a_call.ts`'s runtime resolves against on every build — and use a status-based mapper on which `not_entitled` is **structurally unreachable**: inferring that flag from a 403 body mentioning `a2a` told the operator to buy a licence for an admin-tier or human-only refusal. Those three are admin + human-only (a platform-scope write grants a credentialed egress target fleet-wide, Invariant #8) and take `agent_name` as optional-and-ignored, since the store is not per-agent. `list_a2a_endpoints` refuses an agent-scoped key in-tool rather than running `{self} ∪ permitted`, which against a platform-scope list denies a strict subset of the backend at the cost of a round trip. Deliberately separate from the `a2a_call.ts` runtime. `register_a2a_endpoint` also takes `credential_kind` (#3185, optional — the store infers it), mirrors the route's kind-with-`clear_credentials` refusal before spending a round trip, and relays the store's single-use warning under its own key so it cannot overwrite the kill-switch hint | +| — gated skills (ent#751) | (no tool of its own) | `client.ts` `readGateResult` / `parseGateResult` read a 202 `pending_approval` or a named gate refusal (`X-Trinity-Error-Code`) without consuming the body; `chat_with_agent` and `fan_out` return it as the result (`pending_approval`: do not retry), never an empty reply | | `connector.ts` (3) | `list_playbooks`, `run_playbook`, `ask` | The connector-scoped tool set (ent#46 → #118): consumption-only, bound to ONE agent; operator tools stay hidden from connector keys — see [mcp-connector.md](../feature-flows/mcp-connector.md) | | `rooms.ts` (5) | `create_room`, `list_rooms`, `read_room`, `post_to_room`, `close_room` | Multi-agent rooms (ent#169; OSS core since ent#443) — see [Multi-Agent Rooms](workspace.md#multi-agent-rooms-ent169-oss-core-since-ent443) | | `canvas.ts` (5) | `set_canvas`, `patch_canvas`, `get_canvas`, `list_canvases`, `clear_canvas` | The agent's durable render surface (ent#438) — block vocabulary shared with voice mode (ent#536). No agent-target parameter; the canvas belongs to whoever the key names (`access.ts::resolveActingAgent`, #2975) | diff --git a/docs/memory/architecture/security.md b/docs/memory/architecture/security.md index 36f39979f..976b914c5 100644 --- a/docs/memory/architecture/security.md +++ b/docs/memory/architecture/security.md @@ -73,6 +73,8 @@ Enforced at the **MCP server layer** (`src/mcp-server/src/tools/`), not the back - **A fence on the named routes is not enough** — the generic `PUT`/`DELETE /files` and `POST /files/mkdir` wrote the same `.claude/skills/**` bytes behind an ACCESS check, so they take the capability for that subtree (a delete of an ancestor counts). When a fence guards an effect, enumerate every route that produces the effect, not every route with the effect's name. - **Audited**: every refusal (`capability_refused`, with agent and key id) and every grant/revoke. - **Attribution**: `acting_agent_name` names the agent behind a write — `agent_name` for `scope=agent`, `trinity-system` for `scope=system` (which the backend otherwise strips) — recorded on `agent_skills.assigned_by_agent` (Tandem R29). +- **The gated-skill check (trinity-enterprise#751)** freezes a held request in `skill_gate_requests` and raises its approval to a person; only a person it is addressed to may end it (`may_end`), an admin may cancel but never approve, and those rows are among the person rows a machine key never reads (#715). The check reads the requester's own text, and every "could not tell" refuses. +- **The skill gate map (trinity-enterprise#753)** uses the same capability with two tighter bounds, through `require_person_or_capability("skills.manage", self_person_only=True)` (the trinity#3236 helper): a holder reaches only agents its owner OWNS (owner equality — never `can_user_share_agent`'s admin short-circuit, 404 otherwise) and never itself, because a gate constrains the agent and the constrained agent must not lift it (grant-vs-use). Every other principal must be a PERSON (`assert_person`), so the system key cannot change gates. An agent's unassign of a gated library skill keeps the explicit gate, so the skill writes' wider reach cannot launder one away. The read (`get_skill_gate_readable_agent_by_name`) is an allowlist decided on the principal first; an agent key reads its own gates or, as a holder, an owner-owned agent's. - **Stated limits**: an agent writing its own `~/.claude/skills` from its shell; git pull into a sibling; the other owner-equivalent routes of ent#629. - **ent#164 extends the seam** to `schedules.manage`, `instructions.manage` and `agents.manage` ([self-change-grants.md](../feature-flows/self-change-grants.md)). One place it LOOSENS rather than narrows: the five reconfigure routes were person-only under #2996 and now admit an `agents.manage` holder (`require_person_or_capability`). Those handlers authorise through `assert_agent_owner` → `can_user_share_agent`, which admits any admin — so the dependency itself bounds the holder to agents its owner **owns** (`owner_username` equality, 404 otherwise), and keeps `read-only` and `guardrails` person-only when the holder targets itself. The same bound (`_refuse_unless_owners_agent`) runs inside `capability_fence` for every fenced route that names a target, since those handlers go through `can_user_access_agent`, which has the same admin short-circuit. The out-of-scope routes are listed in the flow doc. diff --git a/docs/memory/feature-flows/mcp-connector.md b/docs/memory/feature-flows/mcp-connector.md index d3bd2408d..19b9a2224 100644 --- a/docs/memory/feature-flows/mcp-connector.md +++ b/docs/memory/feature-flows/mcp-connector.md @@ -102,7 +102,9 @@ backend re-gates EVERY call: email_has_agent_access(agent, email) AND connector `run_playbook` sends `/ ` (trinity-enterprise#751; it used to send the prose `Please run the "" playbook.`), so a playbook marked "requires approval" is recognised by the skill gate and the call answers `pending_approval` or a named refusal -instead of running ([skill-gate.md](skill-gate.md)). +instead of running ([skill-gate.md](skill-gate.md)). Both caller tiers read that answer the +same way: the inline (email) tier's `inlineConnectorChat` checks the gate result before its +own 403 / non-2xx handling, so a refusal keeps its named code (trinity#3274). **Keyless setup (AC6).** With the flag on, the owner shares the agent WITHOUT minting a key — the collaborator drops in a keyless config and signs in by email. The connector diff --git a/docs/memory/feature-flows/mcp-orchestration.md b/docs/memory/feature-flows/mcp-orchestration.md index a07558073..01b497900 100644 --- a/docs/memory/feature-flows/mcp-orchestration.md +++ b/docs/memory/feature-flows/mcp-orchestration.md @@ -675,7 +675,7 @@ The receipts above only help a caller who knows what they mean. The rule is now `get_fan_out_result` or `set_reminder` and end the turn; an error without an `execution_id` is checked in `list_recent_executions` before a word-for-word re-send (`agent_busy` included — it is not "nothing ran", #3244); `pending_approval` is not - retried; `parallel=true, async=true` for long work, whose end fires + retried, and its `message` says whether the outcome will reach the caller (#3233); `parallel=true, async=true` for long work, whose end fires `agent.task.*` for a subscriber (a sequential `/chat` turn fires none). #3245 added: after a confirmed `failed` or `cancelled`, re-send word for word to retry; if the same `execution_id` comes back (a `lease_expired` failure is diff --git a/docs/memory/feature-flows/operating-room.md b/docs/memory/feature-flows/operating-room.md index 1bb1f701f..2eef5927f 100644 --- a/docs/memory/feature-flows/operating-room.md +++ b/docs/memory/feature-flows/operating-room.md @@ -340,7 +340,7 @@ All endpoints require JWT authentication via `get_current_user` dependency. Per- | POST | `/api/operator-queue/bulk-cancel` | `bulk_cancel_queue_items()` | — | (#1017) Cancel a list of still-pending items in one call. Body `{ids: [...], reason?}` (1-500 ids; `reason` ≤ 500, #611); ids are deduped order-preserving so the `skipped` count is honest. Only listed ids are touched; non-pending/inaccessible ids are skipped. **A person only** (403 `person_required`, #611). Through the ask sink: one `batch_id` stamped on exactly the rows this sweep flipped, ONE `bulk_cancel` audit row (the ids actually cancelled + `batch_id` + `has_reason`), ONE `operator_queue_cleared` trigger (`scope`, `count` — no operator email), ONE ending event to the observers. Returns `{cancelled, skipped, batch_id}` | | POST | `/api/operator-queue/clear-resolved` | `clear_resolved_queue_items()` | 158-204 | (#1017) **Hide** terminal items (`acknowledged`/`cancelled`/`expired`) by setting `cleared_at` — NOT a DELETE (a delete would be resurrected by the 5s sync loop; see DB layer). `responded` rows are kept visible so the sync write-back can still deliver the answer. Actual row deletion is deferred to the retention sweep (#1142). Body `{agent_name?}` (403 if inaccessible). Idempotent — empty match returns `{cleared: 0}`. Audit-logged (`clear_resolved`), broadcasts `operator_queue_cleared` (`scope: "resolved"`) when `cleared > 0` | | GET | `/api/operator-queue/{item_id}` | `get_queue_item()` | 207-218 | Single item by ID; 404 if not found (does NOT filter on `cleared_at` — hidden items remain fetchable by id) | -| POST | `/api/operator-queue/{item_id}/respond` | `respond_to_queue_item()` | — | Submit operator response. **A person only** (403 `person_required`, checked before the row is read, #611). Validates status=pending (400), divergence (409 `item_diverged`, #2915), the offered option (422, #2376 — enforced by the ask sink), or the reserved `(something else)` with an instruction in `response_text` (422 `instruction_required` / `reserved_value` / `not_off_menu`, #3242; `response_text` ≤ 4000); the sink's CAS writes the answer + ledger; 409 on a lost race (#1017) and 409 `expired` when the deadline passed before the poller swept (#611); audit `answered`, thin `operator_queue_responded`, the ent#329 resume | +| POST | `/api/operator-queue/{item_id}/respond` | `respond_to_queue_item()` | — | Submit operator response; a platform-minted row lands `acknowledged`, not `responded` (#2372). **A person only** (403 `person_required`, checked before the row is read, #611). Validates status=pending (400), divergence (409 `item_diverged`, #2915), the offered option (422, #2376 — enforced by the ask sink), or the reserved `(something else)` with an instruction in `response_text` (422 `instruction_required` / `reserved_value` / `not_off_menu`, #3242; `response_text` ≤ 4000); the sink's CAS writes the answer + ledger; 409 on a lost race (#1017) and 409 `expired` when the deadline passed before the poller swept (#611); audit `answered`, thin `operator_queue_responded`, the ent#329 resume | | POST | `/api/operator-queue/{item_id}/cancel` | `cancel_queue_item()` | — | Cancel pending item. **A person only** (#611). Optional body `{reason}` (≤ 500). Validates status=pending (400); the sink's CAS writes the cancel + ledger; 409 on a lost race (was a silent 200); audit `cancelled` (`has_reason`, never the text), thin `operator_queue_cancelled`, the ending wake | | GET | `/api/agents/{name}/operator-queue/{request_id}` | `get_my_ask()` (`agent_router`) | — | (#611) The agent's OWN ask by its own `request_id` — only as itself (`get_self_acting_agent`: agent key == name, system key == `trinity-system`; any other principal or name → one uniform 403 `agent_identity_required`, then `AuthorizedAgent`'s uniform 404). Ignores `cleared_at`. Redacted projection (`_READBACK_FIELDS`: no person email, no `resolved_to`) | | POST | `/api/agents/{name}/operator-queue` | `raise_my_ask()` (`agent_router`) | — | (#611 PR B) The agent raises an ask as itself (the same `get_self_acting_agent` gate). Body `OperatorAskCreate` (`extra="forbid"`) into `ask_service.raise_ask`; **201** with the receipt, **200** replaying the first receipt, named 422 / 429 refusals as `{code, message, …}` — see [Raising an ask](#raising-an-ask-trinity-enterprise611-pr-b) | @@ -433,6 +433,8 @@ Accepts `terminal_items` (#1017 — cancelled/expired) and a `file_exists` param 6. Sets `status=responded`, `response`, `response_text`, `responded_at` fields in the agent's JSON (no `responded_by` since trinity-enterprise#715) 7. The agent server's `write_file` endpoint (`docker/base-image/agent_server/routers/files.py:360`) automatically creates parent directories (`mkdir(parents=True, exist_ok=True)`), so `.trinity/` is created if it doesn't exist. +**Platform-minted alerts have no agent audience (#2372).** A row whose `request_id` carries a reserved platform prefix (`is_platform_minted`) is never written into an agent's file, and an operator's answer is its terminal event: `ask_service.answer` passes `terminal=is_platform_minted(item)`, so the respond CAS writes `status='acknowledged'` + `acknowledged_at` (ledger still `answered` / `person`). The row is then cleared by Clear All and ages out under `operator_queue_retention_days`. Files written before ent#499 can still carry such an entry as `responded`; ingestion skips a reserved-prefix entry that is no longer `pending` without the #1631 WARNING (only a `pending` one can pre-create a row, and the WARNING stays for that). Rows already stuck were moved once by the dual-track heal `platform_alert_responded_heal` / Alembic `0093` (frozen prefix list, matched like `is_platform_minted`, never `LIKE` because of `val_`). + **WebSocket broadcast payloads**: `operator_queue_new` (line 159-169): diff --git a/docs/memory/feature-flows/run-agent-loop.md b/docs/memory/feature-flows/run-agent-loop.md index d072c97b5..52d7aac89 100644 --- a/docs/memory/feature-flows/run-agent-loop.md +++ b/docs/memory/feature-flows/run-agent-loop.md @@ -48,7 +48,7 @@ Phase 1 shipped headless (API/MCP only); iterations also appear in the standard ### Service - `src/backend/services/loop_service.py` — **terminal-driven since #2523.** `LoopService.start_loop()` creates the `agent_loops` row and dispatches iteration 1; `advance_on_terminal(execution_id)` closes that iteration and decides the next. There is no `_run()` coroutine and no `_handles` dict — the DB row *is* the loop, so it survives a backend restart and, crucially, can run on the durable pull queue (a pull-claimed row returns no `TaskExecutionResult`, so the old `for` loop was structurally push-only; `loop` is in `pull_pilot.PULL_REACHABLE_TRIGGERS` as of #2523). - **Where the advance is called from:** `event_dispatch_service.spawn_task_terminal_event`, the wrapper every CAS-won terminal writer already goes through (push applier, pull sink, lease reaper, cleanup). Deliberately NOT inside `emit_task_terminal_event`, which returns early when no event subscription matches — the common case. `_run_and_advance` also calls it directly for `execute_task`'s fast-fail returns (capacity / circuit-open / ephemeral budget), which write a FAILED row without ever reaching a terminal writer. - - **Idempotency:** `db.claim_loop_advance(loop_id, run_number)` is a CAS on `runs_completed` from N-1 to N. Pull is at-least-once, so the same terminal can arrive twice; the loser returns without dispatching. That is also what makes the double call site above safe. + - **Idempotency:** `db.claim_loop_advance(loop_id, run_number)` is a CAS on `runs_completed` from N-1 to N. Pull is at-least-once, so the same terminal can arrive twice; the loser returns without dispatching. That is also what makes the double call site above safe. **Claimed-but-unclosed repair (#3316):** the claim and the run-row close are two writes, so a fault between them (a `database is locked`, a process kill) used to strand the loop `running` forever — every later delivery and `reconcile_after_restart` lost the claim CAS. A delivery that loses the claim now checks for that state (loop non-terminal, `runs_completed == N`, run N still `running`) and closes the run itself. `finalize_loop_run` is therefore a second CAS (on `status='running'`, returns bool), and only the caller that wins the close runs the tail (progress, gates, next dispatch), so a repair racing a live advance still dispatches N+1 once. - **Derived state:** everything the old runner held in locals is rebuilt per advance from `agent_loop_runs` (`_DerivedState`): accumulated cost, `failed_runs`, consecutive failures, and the #1157 fingerprint chain. `MAX_RUNS_LIMIT` is 100, so that is at most 100 rows read once per iteration. - **Wall-clock deadline (#1156):** when `max_duration_seconds` is set, the runner checks `now - started_at` only at iteration boundaries (before the next run, and before/after the inter-run delay — which is itself capped to the remaining budget). An in-flight run is never killed mid-turn, so total overshoot is bounded by one `timeout_per_run`; on expiry the loop exits `stopped` / `stop_reason="deadline_exceeded"`. Complements the `max_runs` count cap with a time cap. - **Cost budget (#1155):** when `max_cost_usd` is set, accumulated cost is summed from the completed `agent_loop_runs` rows (#2523 — it was an in-memory accumulator), counting only the success rows (the sole path that loops back to a boundary check) and only for finite, positive costs (a NaN/inf cost is ignored so it can't poison the accumulator; a NULL/unknown cost counts as 0 fail-open). Both unusable-cost cases emit a distinct `logger.warning` under an active budget (metering blind spot stays greppable). At each iteration boundary — *after* the deadline check — it stops the loop *before the next run* with `stopped` / `stop_reason="budget_exhausted"` once `accumulated_cost >= max_cost_usd`. Boundary-only: the current run always finishes (first-run overshoot is by design), and a run that crosses the budget but is the final `max_runs` run or matches `stop_signal` yields those reasons instead. diff --git a/docs/memory/feature-flows/skill-assignment.md b/docs/memory/feature-flows/skill-assignment.md index 7fe2f558e..5ea41ee5f 100644 --- a/docs/memory/feature-flows/skill-assignment.md +++ b/docs/memory/feature-flows/skill-assignment.md @@ -6,6 +6,7 @@ | Date | Changes | |------|---------| +| 2026-10-07 | **trinity-enterprise#753** — every route here that writes `agent_skills` (PUT, POST, DELETE, the inject Sync, POST/DELETE set) now also keeps the agent's skill gate map in line (`_sync_gates` → `skill_gate_map_service`), and answers with a `gates` block (`gate_defaults` applied/removed, `gates_removed`, `gates_kept`, or null). A library skill whose metadata says `approval: recommended` is gated BEFORE its package is delivered; a gate on an unassigned skill goes only once its package removal completed, and an explicit gate only when a person unassigned it. See [skill-gate.md](skill-gate.md) §6. | | 2026-09-30 | **ent#672** — a deprecated library skill is marked and its assignment says so. `SkillsPanel.vue` shows a `deprecated` badge + "Superseded by …" line on the assigned rows (an agent already holding one shows it on load) and on the picker rows (before the tick); `POST` / `PUT` still succeed and their `delivery.skills[name].warnings` carries `deprecated[:]`, rendered as a warning line under the save note; after a manual Sync the per-skill warning list says it in words. See "Deprecated skills" below and [skill-injection.md](skill-injection.md). | | 2026-09-24 | **ent#530** — skill **sets**. `agent_skill_sets` + `agent_skills.individual`; `POST`/`DELETE /api/agents/{name}/skill-sets/{set}` (ent#596 fence), `GET …/skill-sets` (honest status), `GET /api/skills/library/sets`. The PUT adds `set:` entries / replaces with `sets` (absent = untouched) and resolves held sets inside its transaction so set-derived rows survive a replace; a single unassign of a set-named skill answers `retained_via_sets`. The Skills tab renders `AgentSkillSets.vue`, badges `via `, locks set-only members, and its draft is the INDIVIDUAL list. See *Skill sets* below. | | 2026-09-11 | **#2703** — assignment now DELIVERS. `POST` (both branches) and `PUT` (added names) call `skill_service.deliver_assigned` and return a `delivery` block beside the existing keys; `PUT` is symmetric (added injected, dropped removed). Every listing change — assign, unassign, replace, manual Sync, background completion, fleet sweep — fires the thin `agent_skills_changed` WS trigger, and the Skills tab's Save note / the Library control say what happened instead of "Saved. Sync now, or…". Full flow in [skill-injection.md](skill-injection.md) → *Delivery on assign*. | diff --git a/docs/memory/feature-flows/skill-gate.md b/docs/memory/feature-flows/skill-gate.md index bcc36c59e..f8c5567f2 100644 --- a/docs/memory/feature-flows/skill-gate.md +++ b/docs/memory/feature-flows/skill-gate.md @@ -4,7 +4,7 @@ A person marks a skill on an agent "requires approval by role X". When a request dispatched to that agent invokes the skill, the platform raises the approval **before** the agent sees the request. **Approve** runs it once, exactly as written; reject, expiry or cancel runs nothing. The requester is answered at once and told the outcome later. -The per-agent gate map is stored by trinity-enterprise#753. Until it lands, `skill_gate_service.list_skill_gates` returns nothing and the gate is inert. +The per-agent gate map is trinity-enterprise#753 (§6 below): one row per agent and skill, set over REST or MCP — by a person, or by an orchestrator agent holding `skills.manage` — or by the library's `approval: recommended` default on assignment. `skill_gate_service.list_skill_gates` reads it. The dispatch-time check reads what a requester typed. A request that names a skill only in prose ("please pay the invoice") reaches the executor, and the agent's own `Skill` call would load the skill. On Claude Code agents, an **in-container hook** (trinity-enterprise#752, §5 below) asks the platform before the skill loads into a run: it is refused unless that run was approved for it, or self-approved by its approver. @@ -21,6 +21,7 @@ As the owner of a finance agent, I want "pay an invoice" to run only after the p - **The Workspace (the agent page's main chat)**: `client_portal/router.py::portal_chat` and `portal_chat_stream` → `client_portal/service.py::portal_chat`. The route passes `PortalPrincipal.is_person` through as `gate_is_person`. - **Every other producer**: the backstop at step 1b of `TaskExecutionService.execute_task` (`src/backend/services/task_execution_service.py:1887`). This covers the scheduler, loops, fan-out, channels, rooms, sessions, A2A, public links, paid calls, operator resumes, validation and voice post-processing. - **Voice tool**: `gemini_voice._execute_tool` (`src/backend/services/gemini_voice.py:1483`) reaches the agent without `execute_task`, so it refuses a gated skill instead of raising an approval. +- **Setting the map (trinity-enterprise#753)**: `GET/PUT/DELETE /api/agents/{agent_name}/skill-gates[/{skill_name}]` (`src/backend/routers/skill_gate.py` `agent_router`) → `services/skill_gate_map_service.py`; MCP `list_skill_gates` / `set_skill_gate` / `clear_skill_gate` (`src/mcp-server/src/tools/skills.ts`). The Skills tab UI is trinity-enterprise#754. - **Inside the agent (trinity-enterprise#752)**: Claude Code's PreToolUse hook `docker/base-image/hooks/skill-gate.py` → `POST /api/skill-gate/check` (`src/backend/routers/skill_gate.py`) → `skill_gate_service.check_invocation`, on every `Skill` call and every `Agent`/`Task` call whose subagent preloads skills. ## Frontend Layer @@ -30,6 +31,12 @@ As the owner of a finance agent, I want "pay an invoice" to run only after the p - **Operations → Needs Response** (`components/operator/QueueCard.vue`) and the **Workspace Inbox** render the approval as an ordinary ask. The question names who asked and previews the request, and `QueueProposal` shows `kind: gated_skill`, the skills, the exact input, the requester and the fingerprints. A dedicated card is trinity-enterprise#755. - **A refused decision:** a 403 `not_addressee` (the approval was addressed to someone else) is shown on that card as an `InlineError` beside the controls (`components/operator/QueueCard.vue:134`, `data-testid="queue-not-addressee-notice"`) and stays until dismissed. `/m` does the same (`views/MobileAdmin.vue:1294`). - **The Workspace thread:** a held turn is answered with the notice as a normal assistant reply, never a Failed turn. An approved run's result arrives later in the same thread ("**Finished**" plus the result). +- **The other run senders (trinity#3274)** read the 202 through `utils/skillGate.js` (`pendingApprovalMessage`, `isGateRefusal`), show the server's own `message`, and poll nothing: + - **Chat tab** (`ChatPanel.vue`) and **`/m` chat** (`MobileAdmin.vue`): the notice is a platform line (`chat/ChatSystemLine.vue`, the Workspace's centred muted line; `/m` reuses its `.empty-state` tone). The held message and its notice stay on screen but are flagged `held` and left out of the history the next turn sends, which would otherwise ask for the approval again on every later turn; + - **Public link** (`PublicChat.vue`): its async turn is held in the background, so it never sees a 202. The poll now stops at a `skipped` row. The status route returns that row's text and `gate` (`routers/public.py::_gate_state`, only for a row the link's own chat started): `held` (a gate record names the row) shows as the grey line, and `refused` shows in the red error box; + - **Tasks tab** (`TasksPanel.vue`): the local row stays, as `pending_approval` with the notice expanded; a refused request stays as `failed` with its reason. Neither creates a server row; + - **Playbooks** Run and **Dashboard** Update: an `info` toast on the agent page (`AgentDetail.vue` passes `notify`; the toast gained a `status-info` arm, so an info toast no longer renders red, and it carries `role="status"`, or `"alert"` for an error). A gate refusal there is an error toast, which stays until dismissed. Playbooks no longer navigates to a run that does not exist; + - a refusal everywhere reads its named message (`apiErrorMessage`), never the `detail` object. ### State Management @@ -38,7 +45,8 @@ As the owner of a finance agent, I want "pay an invoice" to run only after the p ### API Calls - `POST /api/operator-queue/{id}/respond`: the decision, which may be refused with 403 `not_addressee`. -- Chat and task callers receive `202 {status: "pending_approval", …}`. The UI chat and task panels do not render that 202 yet; that work must land with trinity-enterprise#753. +- Chat and task callers receive `202 {status: "pending_approval", …, outcome_delivery, message}`; the UI senders render the `message` (above). +- `GET /api/public/executions/{token}/{id}/status` returns `error` and `gate` (`held` / `refused`) for a `skipped` row the public link started. ## Backend Layer @@ -48,7 +56,8 @@ As the owner of a finance agent, I want "pay an invoice" to run only after the p request → entry point ├─ triggered_by == "skill_gate"? ── yes → ungated (the gate's own notice) ├─ list_skill_gates(agent) ── error → 503 gate_unavailable (read_gates :281) - ├─ find_gated_invocations(request_text, gates) (utils/skill_invocation.py:39) + ├─ find_gated_invocations(request_text, gates) (utils/skill_invocation.py) + │ + find_gated_invocations(context_text, gates) scanned apart, never joined (#3274) │ none → ungated → dispatch as today ├─ approvers = role_addressing.resolve(agent, role) none → 422 role_unassigned ├─ proven person ∈ approvers? ── yes → self-approved → dispatch (+ audit, :641) @@ -69,18 +78,23 @@ request → entry point | Producer | What the gate reads | |---|---| -| Channels | the sender's text | +| Channels | the sender's text, after the quote line in a Telegram group (`[Replying to …: "…"]`, the line the agent reads — #3274) | | Workspace | the quoted reply + what the client typed | | Public links | the visitor's message | | Rooms | the participants' new messages | | Operator resumes | the chosen answer + the free text | | Operator endings | the reason | | Validation | the operator's custom prompt | -| `/task` | `message` + `user_message` + `system_prompt` (all caller-supplied) | +| `/task` | `message` + `user_message` + `system_prompt` (all caller-supplied; an identical part is read once) | +| Schedules | the schedule's message (its name is context, below) | | Fan-out | each subtask's message + the batch's `system_prompt` | | Loops | no `request_text`, so the rendered iteration is read (what an approval would run) | -On `/task`, `message` is caller-supplied and may carry history, so an invocation quoted there is held: the gate cannot tell a quote from a request. Where the platform composes the history itself (the Workspace, channels, rooms, public links), only the new words are read. A held request replayed in a later cold turn's history therefore reaches the agent as text; scanning that history would re-hold every later turn of the thread. On Claude Code, if the agent acts on it, the in-container hook (§5) refuses the skill. On Codex and Gemini it is a stated limit. +**Beside the request (trinity#3274).** The executor's system prompt also carries free text the requester sets: the schedule's name, the MCP key's name and the requester's email (`platform_prompt_service`'s schedule and Triggered-by lines). `requester_context_text` passes each to `enforce` as `context_text`, as given **and** as the prompt renders it, since the renderer collapses `---` and cuts at 80 characters with `…`, which can make a name appear. It is scanned on its own and never joined to the request, which could spell a name neither part holds. When a skill was named only there, the card adds "Also sent to with this request:" and the labelled lines, counted toward the 6,000-byte bound. It is never stored as the request or replayed. A parity test classifies every `ExecutionContext` string field as scanned or platform-controlled. The schedule-name part is defence in depth today: the scheduler does not send `schedule_name` to `/api/internal/execute-task` (never wired since #171, trinity#3341), so a scheduled run carries no schedule context and the name never reaches the executor. The key name and the email are live. + +**The matcher** (`utils/skill_invocation.py`): a `/` preceded by anything but a letter, a dot or another slash starts an invocation, so `-/x`, `_/x_` and `1/x` match while `docs/x`, `https://host/x` and `//x` stay paths. A path whose last segment follows a digit (`api/v1/x`) now matches too. After the name, a trailing `.`, `_` or `-` ends it. A run of `_`/`-` into a letter or digit, or a dot directly before one, makes it another skill: `/x.v2`, `/x_v2` and `/x--v2` are other skills, while `/x...y` and `/x._y` still invoke `x`. Every change since #751 only widens what matches, and a property test against the #751 matcher holds it. The before-slash rule matches a run of invisibles only from its start, so a long run costs linear time. Unicode's Default_Ignorable code points are matched both removed (a run right before a slash reads as a space) and replaced by a space, and the two results are unioned. So stripping one can never join a letter to the slash, and none hides an invocation inside a name or at its end. + +On `/task`, `message` is caller-supplied and may carry history, so an invocation quoted there is held: the gate cannot tell a quote from a request. Where the platform composes the history itself (the Workspace, channels, rooms, public links, a Telegram group's history block), only the new words are read. Sender labels and a group title are not read either. A held request replayed in a later cold turn's history therefore reaches the agent as text; scanning that history would re-hold every later turn of the thread. On Claude Code, if the agent acts on it, the in-container hook (§5) refuses the skill. On Codex and Gemini it is a stated limit. **Who asked.** - `/chat` and `/task` build the requester from the authenticated principal (`requester_from_principal`, `:570`). @@ -126,6 +140,8 @@ sweep (each a compare-and-set, after 5 min): pending, ask gone → cancelled (approval_ask_missing) → notify; frees the cap slot ``` +- **The card shows exactly what runs (trinity#3274 review).** For a request frozen with replay parts, the card is built from those very strings: the message, then "With these instructions as its system prompt:" and the caller's system prompt (`_card_text`). It never re-sanitises their join, which could redact across the line between them and hide from the approver a part the run sends. +- **What the approved run sends (trinity#3274).** `/task` and fan-out scan the caller's message and system prompt together, but they freeze the two apart (`frozen_replay` → `replay_message` / `replay_system_prompt` in the record's `dispatch`; fan-out passes it as `execute_task(gate_replay=…)`). The approved run sends the message as the message and the system prompt as the system prompt, each sanitised like the card. A record without them replays `request_text`, as every other producer does. - A request from a public-facing surface (`public`, `paid`, channels) runs under the owner's public-channel caller prompt, re-derived at dispatch. - The approved run is not gated again: the backstop finds the gate record whose `dispatched_execution_id` is the run's id. - `DISPATCH_FIELDS` decides, for each `execute_task` field, whether the approved run re-uses it (freeze), gets it fresh (reset), or never replays it (drop). Dropped fields: session, batch, loop, canvas, attachments, and the seat-memory prompt. @@ -162,6 +178,39 @@ claude --print (2.1.281) PreToolUse Skill | Agent | Task - **A self-approved `/chat` turn runs in its own session.** `prepare_chat_execution` sets `ChatExecutionContext.isolated_session`, `run_chat_turn` passes it through, and `build_chat_payload` puts `isolated_session: true` on the agent's `/api/chat` payload. The agent server (`execute_claude_code`) then runs the turn with no `--resume`, keeps no session id, leaves the shared model and session counters alone, and does no cold retry. The next ordinary turn resumes the shared session, without the skill. Workspace threads and the Chat tab keep their continuity. - **Registration**: `/etc/claude-code/managed-settings.d/50-skill-gate.json` (root:root 0444 in a 0755 dir), separate from `managed-settings.json` so a rejected entry voids only itself. It pins `LD_PRELOAD` / `LD_LIBRARY_PATH` / `LD_AUDIT` empty. The build smoke is `RUN … skill-gate.py --self-test`. Agent `/health` reports `skill_gate_hook`. +### 6. The gate map (trinity-enterprise#753) + +**Storage** — `agent_skill_gates` (`db/skill_gates.py` `SkillGateOperations`), PK `(agent_name, skill_name)`, `skill_name` lowercased (ASCII names; every matcher casefolds). Columns `approver` (`primary` | `approver`), `deadline_hours` (1–168, NULL → 24h), `origin`, `set_by`, `set_by_agent` (R29 provenance), `set_at`. `origin`: + +| origin | Written by | Gates? | Leaves when | +|---|---|---|---| +| `set` | a person or an orchestrator (`set_gate`) | yes | cleared; or a **person** unassigns the library skill it is on AND its package removal completed | +| `library_default` | `reconcile_library_gates`: an assigned skill whose library `SKILL.md` says `approval: recommended` | yes | cleared; or the skill is unassigned (by anyone) AND its package is gone — the route saw the removal complete, or the start path's prune inventoried the agent | +| `cleared` | `clear_gate` on a name that is library-assigned and recommended (a tombstone) | no | the skill is unassigned | + +**The read seam** — `skill_gate_service.list_skill_gates(agent)` reads the rows, skips `cleared`, and lets a DB error propagate (never `{}`), so `read_gates` refuses 503 `gate_unavailable` and the hook decides by its marker. + +**Writes** (`services/skill_gate_map_service.py`) all go through `_write_under_marker`: +1. Read the container state once; a stopped or missing agent is never exec'd (its next start syncs the marker). +2. Take `skill_gate_service.marker_lock(agent)` — the same per-agent lock as `sync_gate_marker`, not reentrant. +3. If the write may add a gate and the agent has none yet — decided under the lock, from the map as it is then — write the marker (`write_marker(agent, True)`) **before** the insert. +4. The DB write (one transaction under `lock_agent_rows`; an insert lands only while the agent has a live ownership row). +5. If anything changed, re-sync from the map (`sync_marker_locked`): written while gates remain, removed only after the last is gone. +Then the audit (best-effort, after the lock). A failed re-sync on a running agent still saves the gate and returns `warnings: ["marker_not_written"]`. + +- `set_gate` — the merge with the stored row (an omitted field keeps its value) happens inside the DB write, under `lock_agent_rows`, so two partial PUTs never drop each other's field. Validates the name (`SKILL_NAME_RE`; any well-formed name may be gated, decision 6), the approver (`approver` only where an assignments provider is registered — `approver_kinds()`), the deadline (`type(v) is int`, 1–168), refuses a ghost (409 `ephemeral_agent`, decision 2). An omitted field keeps its stored value; an explicit null deadline resets. `warnings: ["approver_unassigned"]` when the kind reaches nobody (`role_addressing.resolve`; the default admin has no email, so `primary` can reach nobody and every gated request would be refused `role_unassigned`). +- `clear_gate` — idempotent; a name that is library-assigned AND recommended (or the library is unreadable) becomes a `cleared` tombstone so the default is not re-applied; any other clear deletes, so a later recommendation can still apply. +- `reconcile_library_gates(agent, add=True, drop_defaults=None)` — state-driven. `add` inserts `library_default` (`primary`) for every assigned recommended name with no row (agents that held the skill before it was recommended included — the backfill), and runs BEFORE a package is delivered (`_sync_gates` ahead of `_deliver_assigned_skills`; before injection on start, Sync and the sweep). `cleared` tombstones of unassigned skills always go. A `library_default` of an unassigned skill goes only when `drop_defaults` allows it: the names the route's own removal reported `removed`/`not_present` (`packages_gone`), or `AllExcept(keep)` after the start path's / Sync's / sweep's prune (`drop_after_prune`; a name the prune could not remove, or an unmanaged own copy, is kept). A deferred or failed removal keeps the default — the files may still be on the agent. Never removes a default because the metadata changed (tighten-only). The library read runs off the event loop; an unreadable library inserts nothing and is retried by the next reconcile. Ghosts get no default. Never raises. +- `drop_explicit_gates_on_unassign` — only from `_sync_gates` when a **person** unassigned (PUT replace, DELETE skill, DELETE set) AND the route's removal reported the package gone: deletes the `set` rows for names still unassigned when the write runs, except names whose library row was `delivery_status = conflict` (the agent's own skill of that name runs, so the gate is the own skill's — decision 1). An agent's unassign (a `skills.manage` holder, itself or a sibling), the system key, a deferred removal, Sync, start and the sweep keep explicit gates and report `gates_kept`, so unassign-then-reassign cannot launder a gate away. + +**Who may** (`routers/skill_gate.py`): +- Read — `set_by` (a username: an email for email-login users) is withheld (null) from any non-person principal, on the read and the PUT response (#715 people stay with people). `get_skill_gate_readable_agent_by_name`: a person or the system key, anything they can access (uniform 404); an agent key, its OWN gates, or (holding `skills.manage`) an agent its owner owns; connector / other / no scope → 403 `skill_gates_not_readable` on the principal alone. +- Write — `require_person_or_capability("skills.manage", self_person_only=True)` (the shared helper from #3236; its holder reach bound is `_refuse_unless_owners_agent`) then `get_owned_agent_by_name`: a person (session or own user key) who owns the agent or is an admin; an agent key holding `skills.manage`, only on an agent its owner OWNS (owner equality, never the admin short-circuit; 404 otherwise) and never on itself (403 `person_required`). The system key is refused. + +**Audit** (`CONFIGURATION`, only when a row changed): `skill_gate_set`, `skill_gate_cleared`, `skill_gate_default_applied`, `skill_gate_default_removed`, `skill_gate_removed_with_skill`; `details.via` from the principal (`ui` / `api` / `orchestrator` / `system`) and `details.trigger` (`direct` / `assignment` / `start` / `library_sync`). An agent actor is the actor, its owner as `actor_email`. + +**Library metadata** — `skill_packaging.extract_contract` reads `approval:` (top level or the `trinity:` block) from the closed set `{"recommended"}`; anything else is `frontmatter_invalid:approval`. It rides `list_skills()` entries and `SkillInfo.approval`. The agent server does not parse it (own skills). + ### Database Operations `skill_gate_requests` exists on both tracks: SQLite `skill_gate_requests_table`, and Alembic `0088_skill_gate_requests` on top of `0087_pull_sync`. Columns: @@ -175,18 +224,21 @@ claude --print (2.1.281) PreToolUse Skill | Agent | Task | State | `state`, `state_detail` | | Timestamps | `created_at`, `decided_at`, `dispatched_at`, `notified_at` | +`agent_skill_gates` (trinity-enterprise#753): SQLite `agent_skill_gates`, Alembic `0094_agent_skill_gates` on top of `0093_platform_alert_responded_heal`. See §6. `agent_name` is in `AGENT_REFS` (CASCADE); `set_by_agent` is not. + `state` moves `pending → dispatching → dispatched | stale | not_run | unknown`, or `pending → denied | expired | cancelled | refused`. A `self_approved` row (trinity-enterprise#752, no migration — a new value in the same column) is inserted in that state by `record_self_approved_run` and never moves; it is never pending work, so no cap, sweep or ask reads it. Both agent columns are in `AGENT_REFS` (CASCADE). ## Side Effects -- **The requester's notice** (`_notify`, `skill_gate_service.py:1054`; text from `outcome_text`, `:1030`): +- **The requester's notice** (`_notify`; text from `outcome_text`). Who is told follows one rule, `outcome_delivery` (`agent_task` / `inbox` / `none`), which the pending answer also carries (#3233): - **Agent requester:** a platform task, trigger `skill_gate` (analytics bucket "Operator queue"), naming the request, the outcome, the originating execution and the run's execution id. It never says which person decided (#715), and it contains no slash. - **Person requester, including a Workspace caller:** an Inbox `alert` addressed to them, with a `gate-note-…` id that never counts against the agent's ask budget. It is worded for a person, without the `[Trinity]` marker. A person who decided their own request is not told of that decision (approved, rejected, cancelled). An approval that then could not run (`stale`, `not_run`, `unknown`) is news, and is sent once. - - **Schedules, channel users, public visitors, paid callers, connectors:** the request id in the immediate answer only. + - **The decider is never named by email** (trinity#3274, `_decider_label`). A request a platform session or key made (`source_user_id` frozen — only the `/chat` and `/task` seams and the platform's own producers set it) reads the decider's display name (`users.name`, one line, without `@`). Everyone else reads "the agent's approver": the Workspace, the inline connector, operator resumes, a decider with no usable name, and a failed lookup. Most email-login accounts have no name, so the label is the common case. + - **Schedules, channel users, public visitors, paid callers, connectors, system keys:** the request id in the immediate answer, whose message now says so: "Nothing will be sent back when it is decided: if it is approved, it runs as a new execution on ; if not, nothing runs." - **The approved run's result** goes back to the Workspace thread or the channel conversation it came from (`channel_completion_report._is_approved_gate_run`, `src/backend/services/channel_completion_report.py:683`), because an approved run was never answered inline. Only a `dispatching` or `dispatched` record counts: a `self_approved` one names a run that already answered inline. - **Audit rows:** `skill_gate_outcome` once per decided record (ids only); `skill_gate_self_approved` once per execution the gate let through on the approver's own request; `skill_gate_refused` when the in-container hook is refused (at most once per run and skill, and 20 per agent, per 10 minutes). - **The marker** in a gated agent's container (`/opt/trinity/skill-gates-active`), written and removed by root `docker exec`. -- **A held run's row** is closed `skipped` with "Awaiting approval (gate-…)". The scheduler does not retry it, and a loop stops with `approval_required`. +- **A held run's row** is closed `skipped` with the notice itself as its error, which names the request id and is what a public-link visitor reads (#3274). The scheduler does not retry it, and a loop stops with `approval_required`. ## Error Handling @@ -194,7 +246,7 @@ All responses go through the app handler `error_handlers.skill_gate_error` (`src | Case | Status / code | What the caller sees | |---|---|---| -| Approval raised | 202 `approval_pending` | MCP: `pending_approval` ("do not retry"); fan-out: subtask `pending_approval` + `request_id`; A2A: `input-required` keyed on the request id; paid: 202, not settled; Workspace: the notice as the reply | +| Approval raised | 202 `approval_pending` | MCP (every tier, the inline connector included since #3274): `pending_approval` ("do not retry"; "the outcome will be sent to you" only when `outcome_delivery` is `agent_task` or `inbox`, otherwise where to look: `list_recent_executions`); fan-out: subtask `pending_approval` + `request_id`; A2A: `input-required` keyed on the request id; paid: 202, not settled; Workspace: the notice as the reply; the UI senders: the notice, no poll | | …on a paid door | `GATE_HELD` / `GATE_REFUSED` | Both paid doors run `services/paid_turn_service.run_paid_turn`, which returns these outcomes. Nothing ran and nothing is charged; the claim is released with `fail()` (a completed-unsettled claim would make a retry try to settle). The x402 door answers 202 or the refusal; the paid A2A door renders the same `input-required` / `rejected` task, with x402 "verified, nothing charged" metadata (`services/a2a_payment_gate.py::_OUTCOME_RENDER`). | | Gate map unreadable | 503 `gate_unavailable` | nothing dispatched | | Approval could not be raised | 503 `approval_unavailable` | no pending record left behind | @@ -202,6 +254,12 @@ All responses go through the app handler `error_handlers.skill_gate_error` (`src | Skills with different approvers | 422 `mixed_approvers` | ask for them separately | | Request too long for the card | 422 `request_too_long` | shorten it | | Retry after the decision | 409 `request_` | the decision, never "pending" | +| Gate map writes (trinity-enterprise#753) — bad name / approver / deadline | 422 `invalid_skill_name` / `invalid_approver` / `invalid_deadline` | nothing written (`HTTPException`, `{code, message}`, `X-Trinity-Error-Code`) | +| …`approver` on an install with no assignments provider | 422 `approver_unavailable` | only `primary` is offered (`approver_kinds`) | +| …on an ephemeral agent | 409 `ephemeral_agent` | after the access check (an inaccessible ghost is the uniform 404) | +| …an agent key without `skills.manage` | 403 `skill_management_not_permitted` | audited `capability_refused` | +| …an agent key on itself, or a system / connector / other key | 403 `person_required` | on the principal alone, before any lookup | +| …a read by a connector or unknown scope | 403 `skill_gates_not_readable` | on the principal alone | | Skill missing / ambiguous / unreadable | 409 `gated_skill_not_installed` / `_ambiguous` / `_unreadable` | nothing raised | | Too many waiting / too fast | 429 `approval_queue_full` / `approval_rate_limited` | MCP shows `refused`, never "agent busy" | | Voice tool | 403 `approval_not_available_here` | "ask in chat instead" | @@ -227,13 +285,15 @@ On a refusal: the scheduler leaves the row `skipped`; channels, rooms and public - **#715:** gate rows name people (who asked, who decided), so they are in `operator_queue_service._ABOUT_A_PERSON_ID_PREFIXES` (`src/backend/services/operator_queue_service.py:302`). A machine key's queue reads never return them (`routers/operator_queue.py:109`), and `get_my_ask` answers 404 for them (`:510`). - **Fingerprint read:** the script runs as root through the image's interpreter with `-I -S`, so the agent's Python startup files (`sitecustomize`, a `.pth`, `PYTHON*` env) do not run inside it, and symlinks and ambiguous names are refused. This guards against accidental interference, not an adversarial executor: the agent user has passwordless sudo and could replace the interpreter itself. - **Execution id:** `X-Trinity-Execution-Id` is kept only when it is one of the requesting agent's own executions. The MCP server forwards the platform header, never the model-typed `execution_id`. -- **Schedule names:** a name is free text an agent can set, so it is collapsed to one line of at most 80 characters before it appears on the approver's card. +- **Schedule names:** a name is free text an agent can set, so it is collapsed to one line of at most 80 characters before it appears on the approver's card. It is scanned whole, and as the prompt renders it (#3274). - **Stated limits:** - an agent acting outside the skill with its own credential, or rewriting what the fingerprint read sees (capability confinement is the boundary for both); - the gap between the dispatch-time fingerprint and the moment the agent reads the files; - a revoked agent permission between request and approval is not re-checked; - plugin (`plugin:skill`) names are not matched; - - a session minted by a delegate key counts as a person (registered as debt). + - a session minted by a delegate key counts as a person (registered as debt); + - history is not re-read (#3274, decided): not the Workspace's or a channel thread's, not a room's or a public link's, and not a Telegram group's earlier messages, since one past mention would hold every later turn. Neither are the sender labels or a group title. On Claude Code agents the in-container hook refuses a gated skill that history talks the agent into. Agents on Codex or Gemini have no hook, so on those runtimes nothing stops it; + - a schedule-name approval runs the schedule's message without the name, which is not replayed. - **Stated limits of the in-container hook (trinity-enterprise#752):** - Claude Code only: Codex and Gemini have no hook (D4). On Codex, which has no `--resume`, a held request sits in replayed Workspace history on every later turn; - not a boundary against an adversarial executor: passwordless `sudo` (the registration, the hook and the marker are root-owned, which stops accidents only); reading a SKILL.md and acting on it; calling the skill's own tools, such as an MCP tool, directly; a nested `claude` started from Bash, whose environment — the execution id — is the agent's to set, and in which a prompt that starts with `/` expands with no tool call at all. The boundary is credential confinement (R23, trinity-enterprise#558); @@ -242,27 +302,27 @@ On a refusal: the scheduler leaves the row `skipped`; channels, rooms and public - voice-tool calls carry no platform execution and are always refused on a gated agent; - it needs a base-image rebuild and an agent recreate, and an older backend answers 404, so nothing is refused; - server-managed claude.ai settings outrank the files; - - a self-approved turn in a Workspace thread or the Chat tab keeps the loaded skill in that conversation; - - inert until trinity-enterprise#753 supplies the gate map. + - a self-approved turn in a Workspace thread or the Chat tab keeps the loaded skill in that conversation. - Security audit: `docs/security-reports/cso-diff-2026-10-02-ent751-gated-skills.md`. ## Testing ### Prerequisites -Until trinity-enterprise#753 ships, a gate exists only in a local build where `list_skill_gates` returns a map. Roles other than `primary` resolve only through an assignments provider that implements `people_for`. +Set a gate with `PUT /api/agents/{agent}/skill-gates/{skill}` (or MCP `set_skill_gate`). `primary` resolves to the agent owner's email, so the owner must have one (the default admin often has none — `GET .../skill-gates` then shows `approver_reachable: false`). Roles other than `primary` resolve only through an assignments provider that implements `people_for`. ### Test Steps (localhost, two accounts) 1. **The approver asks in the Workspace.** Send `/pay-invoice INV-1` → it runs directly, and one `skill_gate_self_approved` audit row is written. -2. **A schedule's Run now asks.** → the run is `skipped` ("Awaiting approval"); a card in Needs Response names the person who pressed Run now and shows the full request. +2. **A schedule's Run now asks.** → the run is `skipped` with the notice as its error; a card in Needs Response names the person who pressed Run now and shows the full request. 3. **Approve it.** → a new run executes the request, and the requester gets no Inbox notice because they decided it themselves. 4. **Reject one.** → nothing runs, and there is no notice. 5. **An agent asks** (`chat_with_agent`). → the agent reports pending and doesn't retry. On approve, the agent gets one `skill_gate` task stating the outcome with no email. 6. **A non-approver asks in the Workspace.** → - the notice comes back as a normal reply; - an admin's Approve is refused on the card (`not_addressee`); - - when the approver approves, the result lands back in the asker's thread, and the asker's Inbox notice names the decider. + - when the approver approves, the result lands back in the asker's thread, and the asker's Inbox notice says "approved by the agent's approver" (never an email; #3274). +8. **The UI senders (#3274)**, as a person who is NOT the approver: the Chat tab, the Tasks tab, a playbook's Run, Dashboard → Update Dashboard, `/m` chat, and a public link. Each shows the server's message (a platform line, a kept task row or an info toast) and no "No execution_id" error, failed row or timeout. A second chat message after a held one does not raise a second approval. 7. **The in-container hook** (#752). It needs a base image built from the branch and a recreated agent; the agent's `/health` shows `skill_gate_hook: ok`. Save the running image under a second tag first, and restore by re-tagging it: rebuilding from a local `dev` that is older than the running image downgrades the agents. Claude Code expands a message that *starts* with `/pay-invoice` without any `Skill` call, so the hook is never asked about it; the dispatch-time check above decides those. The hook's allow path is a `Skill` call inside a cleared run, so the approved and self-approved steps put the slash mid-sentence: - **prose:** another agent sends "Please pay invoice INV-7 for 100 EUR." with no slash → the model's `Skill` call is refused with the hand-back, and a `skill_gate_refused` audit row is written (`not_cleared`); - **approved:** an agent asks "Please run /pay-invoice for INV-8 100 EUR." → card → Approve → inside the approved run the hook logs `skill_gate_allow`, and the skill runs; @@ -288,7 +348,9 @@ These are covered by unit tests: - a deleted agent; - the loopback token; - an invocation hidden in `system_prompt`; -- a forged schedule name. +- a forged schedule name; +- a schedule name, a key name or an email that invokes a gated skill, raw and as rendered (#3274); +- `-/x`, `_/x_`, `1/x`, and every Default_Ignorable character before, after or inside the name (#3274). ### Test Files @@ -306,7 +368,13 @@ These are covered by unit tests: | `tests/unit/test_ent751_gate_guards.py` | discovery guards and the frozen-dispatch parity | | `tests/unit/test_ent329_operator_resume.py`, `tests/unit/test_ent611_ask_endings.py` | what the gate reads on a resume and an ending | | `src/frontend/tests/unit/queueCardNotAddressee.spec.js`, `mobileAdminNotAddressee.spec.js` | the refusal shown on the card | -| `src/mcp-server/src/chat-gate.test.ts` | the MCP results and the forwarded turn | +| `src/mcp-server/src/chat-gate.test.ts` | the MCP results and the forwarded turn; the delivery-honest suffix and the inline connector tier (#3274) | +| `tests/unit/test_3274_gate_context.py` | the context scan at the backstop, `/task` and `/chat`; raw vs rendered; scanned apart; the `ExecutionContext` parity | +| `tests/unit/test_3274_gate_replay.py` | `/task` and fan-out replay the message and the system prompt apart; legacy records | +| `tests/unit/test_3274_gate_notice.py` | `outcome_delivery` on the 202 and its retry, `_notify` parity per kind, the decider label, the held row's text | +| `tests/unit/test_3274_gate_wiring.py` | the loopback flag, the observer registration and the sweep call, each through the code that runs it | +| `tests/unit/test_3274_channel_quote.py`, `test_3274_public_skipped_status.py` | the Telegram quote; the public status route for a held turn | +| `src/frontend/tests/unit/skillGateSenders.spec.js`, `mobileAdminSkillGate.spec.js`, `skillGate.spec.js`, `agentDetailGateNotice.spec.js` | the six UI senders, mounted; the reader util; the agent page's toast wiring (source pin) | | `tests/unit/test_ent752_skill_gate_hook.py` | the hook executed in its own process (`-I -S`, import-level seam): the verdict table, every no-verdict path × marker, the deadline, one retry, identity from `/proc` not its env, crash injection through the bootstrap, FIFO traps, the log | | `tests/unit/test_ent752_name_resolution.py` | generous name resolution ⊇ #751's `_FINGERPRINT_SCRIPT`; the `skills:` forms; subagent lookup | | `tests/unit/test_ent752_check_endpoint.py` | the route over the real DB: who may ask, every verdict, uniform answers, 503 / 422, the throttled audit, the marker heal, and the hook's own request replayed against it | @@ -315,14 +383,21 @@ These are covered by unit tests: | `tests/unit/test_ent752_isolated_chat_session.py` | the isolated `/chat` turn through the router, the payload, the agent server and the real `execute_claude_code` (#2958 fake CLI) | | `tests/unit/test_ent752_managed_registration.py`, `test_ent752_cli_pin.py` | the drop-in's exact shape, the matcher through a port of the CLI's own branch, the image wiring, the CLI pin | | `tests/unit/test_ent752_health_skill_gate.py` | `/health` `skill_gate_hook` | +| `tests/unit/test_ent753_gate_storage.py` | both migration tracks (registered entry, single head), the PK in `tables.py`, rename/purge cascade, facade parity, the read seam (tombstones skipped, a failed read raises → 503), the case round trip through the matcher | +| `tests/unit/test_ent753_gate_map_service.py` | named refusals, update semantics, tombstones, the reconcile (backfill, tighten-only, unreadable library, ghosts, cost, a concurrent worker's insert), the person drop, the marker ordering and the critical-section race, the audit | +| `tests/unit/test_ent753_gate_routes.py` | the REST routes over the real DB: the write and read principal matrices, uniform 404s, ghost 409 after the access check, named codes on the wire, PUT→GET round trip, census entries | +| `tests/unit/test_ent753_assignment_hooks.py` | the AST guard (every `agent_skills` writer reaches the reconcile; mutation-checked) and the behaviour through `routers/skills.py`, the start path and the sweep | +| `tests/unit/test_ent753_approval_metadata.py` | `approval:` parsing (YAML bool/date/mapping never raise) and its listing | +| `src/mcp-server/src/tools/skill-gates.test.ts` | the three MCP tools: routes, only-sent fields, the zod schema, policy rows, description cap | ### Status -✅ Implemented and eyeballed on localhost (2026-10-03). It is inert until the gate map lands. The in-container hook (trinity-enterprise#752) is implemented and unit-tested, with every call site mutation-checked. It was eyeballed inside real agent containers on localhost (2026-10-05), and every case in step 7 passed. +✅ Implemented and eyeballed on localhost (2026-10-03). The gate map (trinity-enterprise#753) makes it live: implemented and unit-tested, every write path mutation-checked. The #3208 validation gaps were closed by trinity#3274 before the map (plus #3233). The in-container hook (trinity-enterprise#752) is implemented and unit-tested, with every call site mutation-checked. It was eyeballed inside real agent containers on localhost (2026-10-05), and every case in step 7 passed. ## Related Flows -- **Upstream:** the gate map (trinity-enterprise#753); the agent Skills tab "Requires approval" row (trinity-enterprise#754). +- **Upstream:** the agent Skills tab "Requires approval" row (trinity-enterprise#754); an orchestrator agent applying a fleet approval policy through the MCP tools. +- **Part of this flow:** the gate map (trinity-enterprise#753, §6). - **Downstream / shared:** - the ask sink and its endings (`operating-room.md`, trinity-enterprise#611); - the approval card (trinity-enterprise#755); diff --git a/docs/memory/feature-flows/skills-library-sync.md b/docs/memory/feature-flows/skills-library-sync.md index 948261bbc..00017cf14 100644 --- a/docs/memory/feature-flows/skills-library-sync.md +++ b/docs/memory/feature-flows/skills-library-sync.md @@ -26,6 +26,8 @@ As a platform administrator, I want to configure and sync a GitHub repository co Both default OFF; a zero-config install behaves exactly as before. +`_reinject_agent` also reconciles each agent's skill-gate library defaults (trinity-enterprise#753): before the re-inject (a newly `approval: recommended` skill is gated on every holder) and, when the set reconcile dropped members, after the prune (`drop_after_prune`). See [skill-gate.md](skill-gate.md) §6. + ``` skills_sync_service loop (every worker, self-gating, config re-read per cycle) │ skills:sync:leader (SET NX, TTL 3×interval, own-lease refresh, fail-open) diff --git a/docs/memory/feature-flows/skills-on-agent-start.md b/docs/memory/feature-flows/skills-on-agent-start.md index e47656a5c..6b23896a8 100644 --- a/docs/memory/feature-flows/skills-on-agent-start.md +++ b/docs/memory/feature-flows/skills-on-agent-start.md @@ -9,6 +9,12 @@ > wiring only. Code excerpts below predate ent#183 where they show the old > single-file `write_file` loop. +> **Skill gates (trinity-enterprise#753, 2026-10-07)**: `inject_assigned_skills` now calls +> `skill_gate_map_service.reconcile_library_gates` right after the set reconcile (library +> defaults applied BEFORE injection — the backfill for agents that already held a +> newly recommended skill) and again after the prune (`drop_after_prune`: defaults of +> unassigned skills go once their packages are gone). See [skill-gate.md](skill-gate.md) §6. + ## Overview When an agent starts, Trinity automatically injects all assigned skills into the agent container as full skill-directory packages under `~/.claude/skills/{name}/`. This happens after container start, Trinity prompt injection, and credential injection, and is skipped entirely for an already-running container that needed no recreation (#421). diff --git a/docs/memory/learnings/2026-10-07-a-matcher-must-read-every-form-the-reader-sees.md b/docs/memory/learnings/2026-10-07-a-matcher-must-read-every-form-the-reader-sees.md new file mode 100644 index 000000000..4f8db0fb9 --- /dev/null +++ b/docs/memory/learnings/2026-10-07-a-matcher-must-read-every-form-the-reader-sees.md @@ -0,0 +1,3 @@ +## 2026-10-07 — pitfall — A text gate must match every form the model can read, not one normalisation of it +**Context**: trinity#3274, `utils/skill_invocation.py` and `skill_gate_service.requester_context_text`. Widening the matcher's invisible-character set looked like a strict gain: more characters stripped, more invocations found. Review proved it lost matches. Stripping a soft hyphen joins `run` to the slash, and `run/pay-invoice` then reads as a path. The same shape appeared one layer up. The gate scanned a schedule's name as stored, but the executor reads it through `platform_prompt_service._sanitize_field`. That function collapses `---` to `-` and cuts at 80 characters with `…`, which NFKC turns into `...`. So `run /pay---invoice` and an 80-character name ending in `/pay-invoicexyz` both reach the model as `/pay-invoice`, while the raw value matches nothing. +**Lesson**: Any normalisation between what the gate reads and what the model reads can create a match as easily as remove one. That covers stripping, collapsing, truncation and the renderer's own sanitiser. Match every form the reader can see and take the union: removed AND spaced for invisibles, raw AND rendered for prompt fields. Then assert the union with rows where exactly one form matches. Two global forms are still not enough when the right choice differs by position: `run\u00ad/pay\u200d-invoice` needs the first invisible read as a space and the second removed. So the removed form reads a run right before a slash as a space. A fix that only adds normalisations needs a test that the pre-existing matches survive it. Here, four existing invocations would have silently stopped matching. diff --git a/docs/memory/learnings/2026-10-07-a-sys-path-insert-inside-a-test-rebinds-bare-imports-for-later-tests.md b/docs/memory/learnings/2026-10-07-a-sys-path-insert-inside-a-test-rebinds-bare-imports-for-later-tests.md new file mode 100644 index 000000000..a259a7d4b --- /dev/null +++ b/docs/memory/learnings/2026-10-07-a-sys-path-insert-inside-a-test-rebinds-bare-imports-for-later-tests.md @@ -0,0 +1,4 @@ +## 2026-10-07 — pitfall — A `sys.path.insert(0, …)` made while a test RUNS rebinds every later bare-name import, including `from conftest import …` + +**Context**: trinity-enterprise#753. A new route test imported `_route_census` by doing `sys.path.insert(0, tests/unit)` inside the test function. `test_ent500_cascade_rename`'s fixture runs `from conftest import ensure_schema_tables`, which the suite resolves to the ROOT `tests/conftest.py` (on the path through `pyproject.toml`'s `pythonpath`). Run after the new test, it resolved to `tests/unit/conftest.py` instead and errored with `ImportError: cannot import name 'ensure_schema_tables'`, but only in that order. Each file passed alone. `test_2996_human_only_routes.py` makes the same insert at module level, during collection, and breaks nothing. So did the new test once its insert moved to module level. +**Lesson**: In `tests/unit`, put any `sys.path` change at module level, never inside a test or fixture body. Never put the `tests/` root at position 0 either: it is already on the path, and moving it ahead of `tests/unit` changes what a bare `conftest` resolves to. When a sibling test reports an `ImportError` naming `conftest` or a helper module, look for a run-time path mutation in the tests that ran before it before you suspect your own code. diff --git a/docs/memory/learnings/2026-10-07-an-approval-card-is-built-from-the-strings-that-run.md b/docs/memory/learnings/2026-10-07-an-approval-card-is-built-from-the-strings-that-run.md new file mode 100644 index 000000000..8fbd48fb7 --- /dev/null +++ b/docs/memory/learnings/2026-10-07-an-approval-card-is-built-from-the-strings-that-run.md @@ -0,0 +1,3 @@ +## 2026-10-07 — pitfall — An approval card must be built from the exact strings the approved run sends +**Context**: trinity#3274 review of PR #3345 (`skill_gate_service.enforce` / `frozen_replay`). The card showed `sanitize_text(message + "\n" + system_prompt)`, while the approved run sent `sanitize_text(message)` and `sanitize_text(system_prompt)` separately. `credential_sanitizer`'s `Basic\s+…` pattern spans a line break. So a message ending "…Use auth Basic" with a system prompt "/transfer+9000+EUR+…" put `***REDACTED***` on the card, and Approve then ran the hidden instruction with system-prompt authority. Every test passed, because each one built its card and its replay from the same plain inputs. +**Lesson**: "Approve runs exactly what you read" holds only when the card text and the dispatched text are the SAME strings. Never let the card be a second derivation (a different join, a lossy transform such as a sanitiser or a truncation, a re-render) of what runs. Build the card from the frozen dispatch parts, and label any part with different authority, such as a system prompt. Test with an input whose transform differs between the join and its parts. diff --git a/docs/memory/learnings/2026-10-07-classify-a-permanent-artifact-by-its-own-fields.md b/docs/memory/learnings/2026-10-07-classify-a-permanent-artifact-by-its-own-fields.md new file mode 100644 index 000000000..7aa8a77ce --- /dev/null +++ b/docs/memory/learnings/2026-10-07-classify-a-permanent-artifact-by-its-own-fields.md @@ -0,0 +1,3 @@ +## 2026-10-07 — pitfall — A permanent artifact classified by a row that retention deletes reverts once the row is pruned +**Context**: trinity#2372, `operator_queue_service._sync_agent` reserved-prefix guard. Agent files written before ent#499 still carry platform alerts as `responded` entries, and nothing ever removes them. The obvious way to stop the #1631 impersonation WARNING was "skip quietly when the platform holds a row with this id for this agent". That works until the retention sweep deletes the row. From then on the same leftover entry is "unknown" again and the WARNING returns on every boot, permanently. +**Lesson**: When an artifact outlives the DB (a file entry in an agent workspace, a cached copy, a webhook replay), classify it by fields it carries itself. Do not classify it by whether a row that some retention policy can delete still exists. Here the entry's own `status` is enough: only a `pending` entry can pre-create a row, so only a `pending` reserved-prefix entry is impersonation. Test the classifier with the row absent. A fixture that has the row cannot show the regression. diff --git a/docs/memory/requirements/security.md b/docs/memory/requirements/security.md index 96c0368bc..b0c6e0679 100644 --- a/docs/memory/requirements/security.md +++ b/docs/memory/requirements/security.md @@ -653,24 +653,24 @@ - **Tests**: `tests/unit/test_ent734_ask_raising_chat.py` ### 26.13 Gated Skills — the approval comes before the executor sees the request (OPS-001-GATE) -- **Status**: ✅ Implemented (trinity-enterprise#751; the in-container hook, Claude Code only, trinity-enterprise#752; the per-agent gate map's storage, UI and API are trinity-enterprise#753, the Inbox card trinity-enterprise#755) +- **Status**: ✅ Implemented (trinity-enterprise#751; the in-container hook, Claude Code only, trinity-enterprise#752; the per-agent gate map's storage, REST and MCP, trinity-enterprise#753; its Skills-tab UI is trinity-enterprise#754, the Inbox card trinity-enterprise#755) - **Requirement ID**: OPS-001-GATE - **Priority**: P1 - **Description**: A person can mark a skill on an agent "requires approval by role X". When a request dispatched to that agent invokes the skill, the platform raises the approval **before** the agent sees the request: approve runs it once, exactly as written; reject, expiry or cancel runs nothing. Nothing pauses mid-run — the requester gets an answer at once and the outcome later. - **Where it is checked:** one helper (`skill_gate_service.enforce`) at every road to an agent — the `/chat` and `/task` admission seams (after the chain-depth guard, before the idempotency claim, the slot, the row and the activities), a backstop at the top of `execute_task` for every producer that calls it directly (scheduler, loops, fan-out, channels, the Workspace, rooms, sessions, A2A, public links, paid calls, operator resumes, validation), and the voice tool, which reaches the agent without `execute_task` and so refuses a gated skill instead of raising an approval. A guard test discovers every function that posts to an agent and fails on one the gate does not cover. - - **What is read:** everything a requester put in front of the executor, and nothing the platform composed around it — `/task` reads `message`, `user_message` and `system_prompt` (all caller-supplied; the executor receives the last appended to its own system prompt); fan-out reads each subtask's message with the batch's `system_prompt`; loops read the rendered iteration (what an approval would run); the Workspace reads the quoted reply plus what the client typed; operator resumes read the chosen answer and the free text; channels, rooms and public links read the sender's words. On `/task`, whose `message` is caller-supplied, an invocation quoted from history is held, not ignored: the gate cannot tell a quote from a request. History the platform composes (the Workspace, channels, rooms, public links) is not re-read, so a held request replayed there on a later cold turn reaches the agent as text only; on Claude Code the in-container hook (below) refuses the skill if the agent acts on it. Matched against the agent's gated names: any `/` token, case-insensitive, after NFKC normalisation and with zero-width and bidi characters removed; `/pay-invoice.` at the end of a sentence counts, `/pay-invoice.v2` is a different skill. A request that names a skill only in prose ("please pay the invoice") is not held here; on Claude Code the in-container hook refuses the skill when the agent loads it. + - **What is read:** everything a requester put in front of the executor, and nothing the platform composed around it — `/task` reads `message`, `user_message` and `system_prompt` (all caller-supplied; the executor receives the last appended to its own system prompt); fan-out reads each subtask's message with the batch's `system_prompt`; loops read the rendered iteration (what an approval would run); the Workspace reads the quoted reply plus what the client typed; operator resumes read the chosen answer and the free text; channels, rooms and public links read the sender's words, and a Telegram group reply also its quote line (trinity#3274). Beside the request, the free text the requester sets that the executor's prompt carries — a schedule's name (not sent by the scheduler today, trinity#3341; scanned for when it is), an MCP key's name, the requester's email — is scanned as context, both as given and as the prompt renders it, never joined to the request, shown on the card when it alone matched, and never replayed (trinity#3274). The matcher reads `-/x`, `_/x_` and `1/x` as invocations, and matches Default_Ignorable characters both removed and as a space. On `/task`, whose `message` is caller-supplied, an invocation quoted from history is held, not ignored: the gate cannot tell a quote from a request. History the platform composes (the Workspace, channels, rooms, public links) is not re-read, so a held request replayed there on a later cold turn reaches the agent as text only; on Claude Code the in-container hook (below) refuses the skill if the agent acts on it. Matched against the agent's gated names: any `/` token, case-insensitive, after NFKC normalisation and with zero-width and bidi characters removed; `/pay-invoice.` at the end of a sentence counts, `/pay-invoice.v2` is a different skill. A request that names a skill only in prose ("please pay the invoice") is not held here; on Claude Code the in-container hook refuses the skill when the agent loads it. - **Self-approval:** a PERSON proven by the entry point — a signed-in session or a user-scoped key at `/chat` and `/task`, or the Workspace caller its route verified (`PortalPrincipal.is_person`, passed to `execute_task` as `gate_requester`); never an agent key, a system key, a channel or public identity, the event loopback, a schedule or a voice relay — who resolves to the approver runs it directly, audited `skill_gate_self_approved` and recorded as a `self_approved` row in `skill_gate_requests` on the execution the agent receives — the clearance the in-container hook honours (below). The Workspace is the agent page's main chat, so this is where an owner usually asks. A user-scoped key held by automation counts as its owner (stated limit; on an install with no assignments provider the approver is the owner). - **The request is frozen** in `skill_gate_requests` (both migration tracks) before the ask is raised: skills, the sanitised request text, a content fingerprint per skill (hashed inside the executor as root with `python3 -I -S`; ambiguous or symlinked skills refused), the requester and its originating execution (`X-Trinity-Execution-Id`, kept only when it is the requester's own), and an explicit freeze/reset/drop table over every `execute_task` keyword (a guard test fails on an unclassified one). The card carries the whole request (what Approve runs), with a 500-character preview in its question; a request over 6,000 bytes as JSON is refused (`request_too_long`), so an approver never approves text they could not read. - **Who asked, per requester:** each schedule is its own requester (keyed by its id, named on the card), Run now is asked by the person who pressed it, and a Workspace caller is a person — read from the pre-created row when the call does not carry it, so requesters never share one budget by accident. - **One approval per occurrence:** the request id derives from the requester plus the pre-created execution, the fan-out task, the caller's Idempotency-Key, or a fresh id — so every schedule tick asks again, a retry replays, and another caller re-using the same key gets its own request. A retry after the decision is answered with that decision (409 `request_`), never "pending". - - **Named answers:** a pending request answers **202** `{status: "pending_approval", request_id, skills, approver_role, expires_at}`; refusals carry their status — `gate_unavailable` 503, `agent_unavailable` / `gated_skill_not_installed` / `gated_skill_ambiguous` / `gated_skill_unreadable` 409, `role_unassigned` / `mixed_approvers` / `request_too_long` 422, `approval_queue_full` / `approval_rate_limited` 429, `approval_not_available_here` 403, `approval_unavailable` 503 (the ask could not be raised; no pending record is left behind) — all with `X-Trinity-Error-Code`. Every "could not tell" refuses; nothing is dispatched on a read that failed. A pre-created row is closed `skipped` (the scheduler does not retry it). Channels, rooms, public links, the Workspace, A2A (`input-required` / `rejected`), loops (stopped, `approval_required`), fan-out (`pending_approval` per subtask) and the MCP server each surface it as a result, never an empty reply. In the Workspace a request put up for approval is answered with the notice as the reply, not as a failed turn; only a refusal is a named error (`gated`). The notice names no role: "Not run: the skill pay-invoice on testfix needs approval before it can run. Request gate-… is waiting for a decision." + - **Named answers:** a pending request answers **202** `{status: "pending_approval", request_id, skills, approver_role, expires_at, outcome_delivery, message}` — `outcome_delivery` (`agent_task` / `inbox` / `none`) is the same rule `_notify` follows, and when it is `none` the message says so: "Nothing will be sent back when it is decided: if it is approved, it runs as a new execution on ; if not, nothing runs." The MCP layer promises delivery only for `agent_task` / `inbox`, and the delegation contract defers to the message (trinity#3233); refusals carry their status — `gate_unavailable` 503, `agent_unavailable` / `gated_skill_not_installed` / `gated_skill_ambiguous` / `gated_skill_unreadable` 409, `role_unassigned` / `mixed_approvers` / `request_too_long` 422, `approval_queue_full` / `approval_rate_limited` 429, `approval_not_available_here` 403, `approval_unavailable` 503 (the ask could not be raised; no pending record is left behind) — all with `X-Trinity-Error-Code`. Every "could not tell" refuses; nothing is dispatched on a read that failed. A pre-created row is closed `skipped` (the scheduler does not retry it). Channels, rooms, public links, the Workspace, A2A (`input-required` / `rejected`), loops (stopped, `approval_required`), fan-out (`pending_approval` per subtask) and the MCP server each surface it as a result, never an empty reply. In the Workspace a request put up for approval is answered with the notice as the reply, not as a failed turn; only a refusal is a named error (`gated`). The notice names no role: "Not run: the skill pay-invoice on testfix needs approval before it can run. Request gate-… is waiting for a decision." - **Caps (their own budget):** at most 10 pending approvals per requester per agent, 50 per agent, and 10 new gated requests per requester per minute, checked before the in-container read. Gate asks never count against the agent's own ask budget. - **Anyone may ask** — agents, people, schedules, channel users, public-link visitors, paid callers and connector clients. A paid call is not settled: nothing ran, and an approved run later goes through uncharged (stated limit). - **People stay with people (#715):** the approval card names the person who asked and the notice the person who decided, so gate rows are among the rows a machine key never reads (`operator_queue_service.is_about_a_person`) and an agent's own readback refuses them; the notice to a requesting agent states the outcome, never who decided it. - **Who decides:** only a person the approval was addressed to (`may_end`, in the ask sink, so every answer door inherits it); an admin may cancel it, never approve it. - **Exactly once:** on the ending (an observer in every worker, plus a sweep in the operator-queue poll cycle), `pending → dispatching` is a compare-and-set that also writes the run's id; then the agent's existence, the requesting agent's existence and every fingerprint are re-checked (a changed skill is `stale`, nothing runs). The run starts like an async `/task` — a busy agent queues it, and a request from a public-facing surface keeps the owner's public-channel caller prompt — and is recorded `dispatched`. The sweep reconciles the gaps a dead process leaves: a run that started but was never recorded is recorded and reported; a claim whose run never started is `unknown`, reported to the requester and the approver, and never re-run; a pending request whose ask is gone is `cancelled`. An unreachable agent is `not_run`. The approved run's result goes back where the request came from — the Workspace thread or the channel conversation — through the completion report, which otherwise skips a requester's trigger as "answered inline". Deleting or renaming the agent cancels its waiting requests; the ask is ended as the acting person only when a person acts (an agent key leaves it open and inert). - - **The requester is told on every ending:** an agent by a platform task (trigger `skill_gate`) naming the request, the outcome and both execution ids; a person by an Inbox notice addressed to them — except of a decision they made themselves (an approval that then could not run is still sent). Notices name skills without a slash, so a notice never reads as a request to run one. Schedules, channel users, public visitors, paid callers and connectors get the request id in the immediate answer only. - - **Stated limits:** an agent acting outside the skill with its own credential (capability confinement is the boundary); the window between the dispatch-time fingerprint and the agent reading the files; a revoked agent permission between the request and the approval is not re-checked; plugin (`plugin:skill`) names are not matched. + - **The requester is told on every ending:** an agent by a platform task (trigger `skill_gate`) naming the request, the outcome and both execution ids; a person by an Inbox notice addressed to them — except of a decision they made themselves (an approval that then could not run is still sent). Notices name skills without a slash, so a notice never reads as a request to run one. Schedules, channel users, public visitors, paid callers, connectors and system keys get the request id in the immediate answer only, and are told nothing more will come. A person's notice never carries the decider's email (trinity#3274): it names the decider's display name for a request a platform session or key made, and "the agent's approver" for everyone else (the Workspace, the inline connector, a decider with no usable name). An approved `/task` or fan-out run is sent the caller's message and system prompt where they were, never their concatenation, and its card shows exactly those strings, with the system prompt labelled. The UI's run senders show the server's message instead of an error and poll nothing; the public link stops at its held turn's `skipped` row. + - **Stated limits:** an agent acting outside the skill with its own credential (capability confinement is the boundary); the window between the dispatch-time fingerprint and the agent reading the files; a revoked agent permission between the request and the approval is not re-checked; plugin (`plugin:skill`) names are not matched; history is not re-read — the Workspace's, a channel thread's, a room's, a public link's or a Telegram group's earlier messages, nor sender labels or a group title (one past mention would hold every later turn; on Claude Code the in-container hook is the backstop, on Codex and Gemini nothing is); a schedule-name approval runs the message without the name. - **In-container hook (trinity-enterprise#752, Claude Code only).** The check above reads what a requester typed, so a request that named a skill in prose reached the executor, and the agent's own `Skill` call loaded it. A `PreToolUse` hook now asks the platform before Claude Code loads a skill into a run. - **What is checked:** every `Skill` tool call (the invoked name, trimmed, one leading `/` stripped), and every `Agent`/`Task` call whose subagent definition preloads skills (`skills:` in a definition under `~/.claude/agents/` or the project's `.claude/agents/`, found by its frontmatter `name`; no `subagent_type` means `general-purpose`, which a definition of that name overrides) — a preload is an invocation. A `CLAUDE_CONFIG_DIR` in the claude process's environment adds its `skills/` and `agents/`. Names resolve generously: the invoked name, its last `:` segment, and every name the same skill directory answers to (the directory, #751's front-matter read, a YAML-faithful `name:`), so a gate keyed by any of a skill's names is found; over-matching only adds refusals. A subagent with no definition on disk (a built-in) preloads nothing and costs no call. - **The question:** `POST /api/skill-gate/check`, with the agent's own key. The agent comes from the key, never from the request (`trinity-system` as itself; every other principal a uniform 403 `agent_identity_required`). Not gated → allowed. Gated → allowed only when the run is **cleared** for every gated skill it loads (D3: the execution and the skill, not an input hash): an approved run (the gate record that dispatched it) or a self-approved one (the `self_approved` record), on this agent, with the run still live (`running` / `queued` / `pending_retry`). An unknown, finished or other agent's run gets the same answer from the same two reads. A call the hook could not resolve (no skill name, an unparseable `skills:` list, a plugin subagent whose definition was not found, a skill or definition whose `name:` it cannot read and that might be the one the CLI resolves, a scan bound) is refused on an agent with gates. Every verdict is a 200; an unreadable gate map is 503 `gate_unavailable`. @@ -681,8 +681,19 @@ - **Registered where the agent cannot edit it:** a managed-settings drop-in `/etc/claude-code/managed-settings.d/50-skill-gate.json` (root:root 0444, directory 0755), exec form (`/usr/bin/env -i /usr/local/bin/python3 -I -S …`, no shell to wrap), with `LD_PRELOAD` / `LD_LIBRARY_PATH` / `LD_AUDIT` pinned empty. Its own file, so an entry the CLI rejected would void only itself and never the GUARD-002 hooks; `managed-settings.json` is unchanged. The image does not build unless the hook imports and decides (`--self-test`), and a unit test fails when the pinned Claude Code version moves past the one its CLI facts were read from (`SKILL_GATE_CLI_VERIFIED`). - **Visible:** the agent's `/health` reports `skill_gate_hook` (`ok` / `missing` / `not_root_owned` / `writable` / `unsupported_runtime`) — the signal trinity-enterprise#753's UI needs to say that a gated agent on an older image or another runtime is not enforced in-container. Nothing reads it yet. - **Stated limits:** Claude Code only — Codex and Gemini have no hook (D4), and on Codex, which has no `--resume`, a held request sits in replayed Workspace history on every later turn. Not a boundary against an adversarial executor (passwordless `sudo`; reading a SKILL.md and acting on it; calling the skill's own tools, such as an MCP tool, directly; a nested `claude` started from Bash, whose environment — the execution id — is the agent's to set, and in which a prompt that starts with `/` expands with no tool call at all) — the boundary is credential confinement (R23, trinity-enterprise#558). A leading `/skill` in a dispatched message expands without a tool call, so the hook never sees it. That includes an approved or self-approved run's own request, and it is the dispatch-time check's lane. What a clearance lets through is a `Skill` call inside the cleared run: a `/skill` mid-sentence, or the model choosing the skill. Plugin and nested names follow #751 (the hook over-matches the last segment). Voice-tool calls carry no platform execution and are always refused on a gated agent. It needs a base-image rebuild and an agent recreate, and an older backend answers 404, so nothing is refused. Server-managed claude.ai settings outrank the files. Inert until trinity-enterprise#753 supplies the gate map. -- **Files**: `src/backend/services/skill_gate_service.py`, `src/backend/services/skill_gate_errors.py`, `src/backend/utils/skill_invocation.py`, `src/backend/db/skill_gate_requests.py`, `src/backend/services/ask_service.py` (`may_end`, gate budget), `src/backend/db/operator_queue.py`, `src/backend/services/task_execution_service.py` (backstop), `src/backend/services/dispatch_admission_service.py`, `src/backend/services/chat_execution_service.py`, `src/backend/error_handlers.py`, the callers named above, `src/mcp-server/src/client.ts` (`parseGateResult`), `src/mcp-server/src/tools/{chat,connector}.ts`; the hook (#752): `src/backend/routers/skill_gate.py`, `src/backend/dependencies.py` (`get_self_agent`), `src/backend/services/channel_completion_report.py`, `src/backend/services/agent_service/lifecycle.py` (marker sync), `docker/base-image/hooks/{skill-gate.py,_skill_gate.py}`, `docker/base-image/hooks/managed-settings.d/50-skill-gate.json`, `docker/base-image/agent_server/services/claude_code.py` (isolated turn), `docker/base-image/agent_server/routers/info.py` (`/health`) -- **Tests**: `tests/unit/test_ent751_*.py`, `tests/unit/test_ent752_*.py`, `src/mcp-server/src/chat-gate.test.ts` + - **The gate map (trinity-enterprise#753).** Per agent, not global: the same skill can be harmless on one agent and dangerous on another. + - **Storage:** one row per agent × skill in `agent_skill_gates` (both tracks) — the approver kind (`primary` | `approver`, v1 — one approver kind per gated skill; role ids are not stored) and an optional deadline (1–168 hours, none = 24). No row = ungated. Keys are stored lowercased (every matcher casefolds). The rows follow a rename and go with a purge (CASCADE). + - **Read:** `list_skill_gates` reads it; a failed read raises, so the check refuses (`gate_unavailable`) rather than reading "nothing is gated". + - **REST + MCP:** `GET/PUT/DELETE /api/agents/{agent}/skill-gates[/{skill}]`; MCP `list_skill_gates`, `set_skill_gate`, `clear_skill_gate`. A change needs a person (a signed-in session or the person's own user-scoped key) who owns the agent or is an admin, or an agent holding `skills.manage` on an agent its owner owns — **never on itself**: the agent a gate constrains cannot set, loosen or lift its own. The system key cannot change gates. Anyone with access to the agent may read them; an agent key reads its own gates (pull, never pushed into the workspace), a `skills.manage` holder also an agent its owner owns. + - **Validation (named 422s):** `invalid_skill_name` (`SKILL_NAME_RE`; any well-formed name may be gated — one the agent does not have gates nothing until a skill answers to it, and a request naming it on that agent is refused `gated_skill_not_installed`), `invalid_approver`, `approver_unavailable` (`approver` where no assignments provider can resolve it — OSS offers only `primary`, and the API says which kinds it offers), `invalid_deadline` (a JSON `true` or `"24"` is refused, not coerced). An ephemeral agent holds no gates (409 `ephemeral_agent`). + - **Honest status:** each gate reports `approver_reachable`; a write whose approver kind reaches nobody returns `approver_unassigned`. On an install whose admin has no email, `primary` reaches nobody, so a gated skill is refused `role_unassigned` until the owner has an email. + - **Library default:** a library skill whose `SKILL.md` says `approval: recommended` is gated (`primary`, 24h) on every agent it is assigned to — before its package is delivered, and including agents that already held it when the recommendation appeared, at their next start or library sync. The library can tighten, never loosen: a default is not removed because the metadata changed. Clearing it on a skill the agent still holds is recorded and sticks; unassigning the skill removes the default once the package has left the agent, and re-assigning restores it. + - **Removal:** a gate never goes while the skill's files may still be on the agent: an unassigned skill whose package removal deferred or failed (a busy or stopped agent) keeps every gate until the package is gone — the start path's prune then drops the default. An own skill's gate stays until someone clears it (the platform gets no event when an agent deletes its own skill folder, and dropping the gate on absence would let an agent un-gate itself). An explicit gate on a library skill goes when a **person** unassigns the skill and the package removal completed. An agent's unassign — a `skills.manage` holder, on itself or a sibling — the system key, Sync, start and the library sweep keep it (reported `gates_kept`), so unassigning and re-assigning cannot launder a gate away. A name that is also the agent's own skill (the library package in `conflict`) keeps its gate on any unassign. + - **Audit:** every change, only when a row changed: who, and from where (`via`: UI session, API key, orchestrator, system; `trigger`: direct, assignment, start, library sync). + - **Marker ordering:** written before an agent's first gate on a running agent, removed after its last, under the per-agent marker lock; a stopped agent is synced at its next start. A write whose marker could not be written on a running agent is saved and returns `marker_not_written` (the check re-syncs within 5 minutes). Serialised per worker (stated limit). + - **Stated limits:** an explicit gate kept by a deferred removal stays until someone clears it (shown as "skill not found, gate kept"); per-worker marker serialisation; one approver per gated skill; on PostgreSQL a single `assign`/`unassign` committing inside a gate write's transaction can be missed (the skill-set writers share its lock; SQLite's write lock closes it). +- **Files**: `src/backend/services/skill_gate_service.py`, `src/backend/services/skill_gate_errors.py`, `src/backend/utils/skill_invocation.py`, `src/backend/db/skill_gate_requests.py`, `src/backend/services/ask_service.py` (`may_end`, gate budget), `src/backend/db/operator_queue.py`, `src/backend/services/task_execution_service.py` (backstop), `src/backend/services/dispatch_admission_service.py`, `src/backend/services/chat_execution_service.py`, `src/backend/error_handlers.py`, the callers named above, `src/mcp-server/src/client.ts` (`parseGateResult`), `src/mcp-server/src/tools/{chat,connector}.ts`; the hook (#752): `src/backend/routers/skill_gate.py`, `src/backend/dependencies.py` (`get_self_agent`), `src/backend/services/channel_completion_report.py`, `src/backend/services/agent_service/lifecycle.py` (marker sync), `docker/base-image/hooks/{skill-gate.py,_skill_gate.py}`, `docker/base-image/hooks/managed-settings.d/50-skill-gate.json`, `docker/base-image/agent_server/services/claude_code.py` (isolated turn), `docker/base-image/agent_server/routers/info.py` (`/health`); the gate map (#753): `src/backend/db/skill_gates.py`, `src/backend/services/skill_gate_map_service.py`, `src/backend/routers/skill_gate.py` (`agent_router`), `src/backend/routers/skills.py` (`_sync_gates`), `src/backend/services/skill_packaging.py` (`approval:`), `src/backend/dependencies.py` (`require_person_or_capability`, `get_skill_gate_readable_agent_by_name`), `src/mcp-server/src/tools/skills.ts` +- **Tests**: `tests/unit/test_ent751_*.py`, `tests/unit/test_ent752_*.py`, `tests/unit/test_ent753_*.py`, `tests/unit/test_3274_*.py`, `src/mcp-server/src/chat-gate.test.ts`, `src/mcp-server/src/tools/skill-gates.test.ts`, `src/frontend/tests/unit/{skillGate,skillGateSenders,mobileAdminSkillGate,agentDetailGateNotice}.spec.js` ### 26.14 Platform Alerts — one row per subject, platform endings, a lifetime (OPS-001-PLATFORM-ALERTS) - **Status**: 🟡 Partially implemented (abilityai/trinity#3246, PR 1 of 2) — the seam, the schema, the upgrade sweep and the surfaces ship now; **only four emitter families are on the seam in this PR** (subscription headroom, legacy skills-library adoption, the system agent's stale base image / start failure, the agent circuit breaker's dormant alarm). Every other platform emitter still files through its old path and is listed by prefix in the ratchet `PREFIXES_NOT_YET_ON_SEAM` (`tests/unit/test_3246_platform_alerts_registry.py`); they follow in PR 2. diff --git a/docs/security-reports/cso-diff-2026-10-07-2372-platform-alert-ack.json b/docs/security-reports/cso-diff-2026-10-07-2372-platform-alert-ack.json new file mode 100644 index 000000000..84d703acb --- /dev/null +++ b/docs/security-reports/cso-diff-2026-10-07-2372-platform-alert-ack.json @@ -0,0 +1,43 @@ +{ + "version": "1.2.3", + "date": "2026-10-07", + "mode": "diff-daily", + "scope": "#2372", + "phases_run": [ + 0, + 1, + 2, + 5, + 7, + 8, + 9, + 12, + 13, + 14 + ], + "stack_cve_quicklist": [], + "attack_surface": { + "new_endpoints": 0, + "new_ingress": 0 + }, + "findings": [], + "hypotheses": [], + "coverage_gaps": [ + "no docker pass (diff run)", + "heal not executed on PostgreSQL locally; CI pg-migrations covers it" + ], + "supply_chain_summary": { + "scanner": "n/a \u2014 no dependency change" + }, + "filter_stats": { + "candidates": 0, + "dropped": 0 + }, + "totals": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0 + }, + "trend": "n/a" +} \ No newline at end of file diff --git a/docs/security-reports/cso-diff-2026-10-07-2372-platform-alert-ack.md b/docs/security-reports/cso-diff-2026-10-07-2372-platform-alert-ack.md new file mode 100644 index 000000000..32ac32c38 --- /dev/null +++ b/docs/security-reports/cso-diff-2026-10-07-2372-platform-alert-ack.md @@ -0,0 +1,27 @@ +# CSO diff audit — #2372 platform-alert acknowledgement + +**Mode:** `--diff`, daily (8/10 gate). Branch `feature/2372-ack-platform-alerts` vs `dev`, diffed from merge-base `a51549772`. +**Result:** 0 findings. + +## Scope +Changed files: `services/ask_service.py`, `services/operator_queue_service.py`, `db/operator_queue.py`, `database.py`, `db/migrations.py`, Alembic `0093_platform_alert_responded_heal`, plus tests and docs. The diff adds no endpoint, dependency, workflow, Dockerfile, compose change, credential or template. + +## Checks +| Area | Check | Verdict | +|---|---|---| +| A01 / ASI03 | Who may answer is unchanged: `reject_non_person_principal` (route) and `may_end` (gate approvals) run before the compare-and-set. The new `terminal` flag only changes the status the answer lands in. | clean | +| ASI03 | Can an agent get `terminal` applied to its own ask? `terminal` depends on `is_platform_minted`. File ingestion refuses reserved ids (`operator_queue_service.py:2024`), and native `ask_operator` refuses them too (`ask_service.py:690` `reserved_request_id`). | clean | +| #1631 guard | A reserved-prefix file entry that is no longer pending is now skipped without the WARNING. Only a pending entry can pre-create and suppress a platform row, and that case still WARNs and `continue`s. The skip happens before the acknowledged branch and the open-row reconcile, so a non-pending entry has no effect on any row. A missing `status` defaults to `pending`, so the uncertain case takes the warning path. | clean (the only change is to log output, which is excluded as log spoofing) | +| A05 | Heal SQL is parameterized (`:now`, `:id`). Prefix matching is done in Python, which avoids the `LIKE` wildcard in `val_`. The UPDATE is a compare-and-set on `status='responded'`. | clean | +| Data safety | The heal flips only rows that are `responded` and carry a reserved prefix. The write-back has excluded those rows since ent#499, so no agent was waiting on their delivery. Downgrade is a no-op. | clean | +| A10 | `terminal` defaults to `False`, so on failure the answer keeps the existing behaviour. The guard's failure direction is stated in its comment. | clean | +| Phase 2 | Added lines scanned for secret prefixes and email addresses: none. | clean | +| Phase 8 | Invisible/bidi Unicode grep over every changed and new file: none. | clean | + +## Hypotheses +none + +## Coverage gaps +- No Docker pass. A diff run is never a full run, and the diff touches no image. +- The heal was not executed on PostgreSQL locally. CI `pg-migrations` covers it. +- Independent verifier not spawned: there were no findings to verify. diff --git a/docs/security-reports/cso-diff-2026-10-07-3274-gate-hardening.json b/docs/security-reports/cso-diff-2026-10-07-3274-gate-hardening.json new file mode 100644 index 000000000..df4f2d441 --- /dev/null +++ b/docs/security-reports/cso-diff-2026-10-07-3274-gate-hardening.json @@ -0,0 +1,98 @@ +{ + "version": "1.2.3", + "date": "2026-10-07", + "mode": "diff", + "scope": "default", + "issue": "abilityai/trinity#3274", + "phases_run": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14 + ], + "stack_cve_quicklist": [ + { + "component": "Starlette", + "verdict": "not reachable by this change (pin unchanged)" + }, + { + "component": "Vite", + "verdict": "not reachable by this change (pin unchanged)" + }, + { + "component": "Vitest", + "verdict": "not reachable by this change (pin unchanged)" + }, + { + "component": "axios", + "verdict": "not reachable by this change (pin unchanged)" + }, + { + "component": "Redis image", + "verdict": "not reachable by this change (pin unchanged)" + }, + { + "component": "Claude Code", + "verdict": "not reachable by this change (pin unchanged)" + }, + { + "component": "runc/Docker host", + "verdict": "not reachable by this change (pin unchanged)" + }, + { + "component": "Node CLIs", + "verdict": "not reachable by this change (pin unchanged)" + } + ], + "attack_surface": { + "new_endpoints": 0, + "changed_responses": 1, + "new_agent_context_ingress": 0 + }, + "findings": [], + "fixed_during_audit": [ + { + "category": "skill-gate-matcher-bypass", + "file": "src/backend/utils/skill_invocation.py", + "title": "An invisible before the slash plus one inside the name evaded both normalisations", + "fix": "a run of invisibles right before a slash reads as a space on the removed form", + "test": "tests/unit/test_ent751_skill_invocation.py", + "mutation": "red with the rule removed" + } + ], + "hypotheses": [], + "coverage_gaps": [], + "supply_chain_summary": { + "scanner": "n/a (no dependency change in diff)", + "changed_manifests": 0 + }, + "filter_stats": { + "considered": 5, + "reported": 0, + "below_gate": 5 + }, + "totals": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0 + }, + "trend": { + "prior": [ + "cso-diff-2026-10-02-ent751-gated-skills", + "cso-diff-2026-10-05-ent752-gated-skill-hook" + ], + "resolved": [], + "persistent": [], + "new": [] + } +} diff --git a/docs/security-reports/cso-diff-2026-10-07-3274-gate-hardening.md b/docs/security-reports/cso-diff-2026-10-07-3274-gate-hardening.md new file mode 100644 index 000000000..5ab73e2bb --- /dev/null +++ b/docs/security-reports/cso-diff-2026-10-07-3274-gate-hardening.md @@ -0,0 +1,54 @@ +# /cso --diff — trinity#3274 gated-skill hardening (2026-10-07) + +**Mode**: diff (branch `feature/3274-gate-hardening` vs `a51549772`), daily confidence gate (8/10). cso v1.2.3. +**Scope**: the skill gate's inputs, the approved-run replay, the requester's notice and pending answer, the MCP gate result, the six UI run senders, and the public status route. No new endpoint, dependency, workflow, Dockerfile, compose or migration. + +## Verdict + +**0 findings** at the 8/10 gate. One defect was found during this audit and fixed on the branch before the report (below). Coverage gaps: none for the diff's scope. + +## Architecture delta + +- `skill_gate_service.enforce` gains `context_text`. It is scanned apart from the request, and its sources are the schedule name, MCP key name and requester email the executor's prompt renders, each raw and rendered. It goes on the card only when it alone matched, and is never replayed. Callers: the `execute_task` backstop, `/task`, `/chat`. +- The approved run's message and system prompt are frozen apart (`frozen_replay`) at `/task` and fan-out. They are stored sanitised in the record's `dispatch` JSON, which no route exposes. +- `outcome_delivery` (one rule, shared with `_notify`) rides the 202. The requester's notice names the decider by display name (platform-principal requests only) or "the agent's approver", never by email. +- `GET /api/public/executions/{token}/{id}/status` returns `error` for a `skipped` row whose `triggered_by` is `public`. +- MCP: `inlineConnectorChat` reads the gate result before its own 403 / non-2xx handling. `parseGateResult` promises delivery only for `agent_task` / `inbox`. + +## Stack CVE quicklist + +No pin in the quicklist changed in this diff (Starlette, Vite, Vitest, axios, Redis, Claude Code, runc, Node CLIs). Every row is outside the diff: **not reachable by this change**. + +## Attack-surface delta + +| Surface | Change | Assessment | +|---|---|---| +| Public status route (link-token auth) | `error` for `skipped` public rows | Same access check as before (link token + row on the link's agent). It exposes the gate notice (skill name, agent, request id), never a person or role. Workspace rows also carry `triggered_by = public`; reading one needs its unguessable execution id, and those rows' success responses were already readable this way. Below the gate, no new class. | +| Agent-context ingress | none new | The gate reads MORE (quote line, context). The Telegram quote is pre-neutralised by `reply_quote_line` (brackets and quotes rewritten) and already reached the executor in the composed prompt. | +| Approver card | context lines when they alone matched | Sanitised, one line per value, 200-char clamp, fixed labels; counted toward the 6,000-byte bound. Same forging class as the existing one-line schedule label. | +| Requester notice | decider label | Removes the email disclosure (item 8). The label never contains `@`, and a lookup failure falls to the generic label (fail toward hiding, stated in the docstring). | +| MCP inline tier | gate result first | An access 403 carries no `X-Trinity-Error-Code` with a matching `refused` body, so it still reads "no access" (pinned in `chat-gate.test.ts`). | + +## Phase notes (diff scope) + +- **P2 secrets:** the added lines contain no credential shapes. One test fixture originally used a vendor-prefixed fake key. It was replaced before this report with a `Bearer` value the sanitiser redacts, so the public repo carries no key-shaped string. Emails are `example.com`. +- **P3/P4/P5/P6:** no dependency, lockfile, workflow, Dockerfile, compose, webhook or internal-route change. +- **P7 (ASI01/03/09):** the gate only widens what it holds, in the fail-closed direction. Self-approval is unchanged: the `is_person` rule and the event-loopback flag, now pinned by `test_3274_gate_wiring.py`. The notice change narrows disclosure. +- **P8:** no skill, template, plugin or hook content changed. Invisible-Unicode scan over every changed and new file: the only hit is a pre-existing ⚠️ (U+FE0F) in a `fan_out_service.py` docstring, which agents do not load. The matcher test fixtures spell every invisible as an escape. +- **P9 A10 (failure direction):** `outcome_delivery` defaults to `none` (an answer that does not know never promises). An older backend's missing field reads as `none` in the MCP layer. `_held_notice` returns nothing for an absent `triggered_by`. `_decider_label` fails to the generic label. Each is stated in code. + +## Found and fixed during this audit + +- **Matcher: an invisible before the slash plus one inside the name evaded both normalisations** (fixed on the branch; would have been HIGH-adjacent bypass of the dispatch-time check on Codex/Gemini). `run­/pay‍-invoice`: removing both joins `run` to the slash (a path); spacing both splits the name. Fix: on the removed form, a run of invisibles right before a slash reads as a space (`utils/skill_invocation.py::_INVISIBLE_BEFORE_SLASH`). Row in `test_ent751_skill_invocation.py`; mutation-proved (removing the rule turns it red). Learnings fragment `2026-10-07-a-matcher-must-read-every-form-the-reader-sees.md`. + +## Hypotheses + +None at this gate. + +## Coverage gaps + +None for the diff's scope (no Docker-image, dependency or CI surface changed). + +## Trend + +Follows `cso-diff-2026-10-02-ent751-gated-skills` (both findings fixed in #3208) and `cso-diff-2026-10-05-ent752-gated-skill-hook`. This diff closes the seven gaps the #3208 merge-train validation listed. No persistent finding is reopened. diff --git a/docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.json b/docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.json new file mode 100644 index 000000000..27dd53ada --- /dev/null +++ b/docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.json @@ -0,0 +1,108 @@ +{ + "version": "1.2.3", + "date": "2026-10-07", + "mode": "daily", + "scope": "diff", + "branch": "feature/753-skill-gate-map", + "issue": "abilityai/trinity-enterprise#753", + "base": "a51549772", + "audited": "working tree vs base (pre-commit)", + "counts_as_full_run": false, + "phases_run": [ + 0, + 1, + 2, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "phases_not_applicable": { + "3": "no dependency change", + "4": "no workflow change", + "5": "no Docker/compose change", + "6": "no webhook/integration change" + }, + "stack_cve_quicklist": [ + { + "component": "all rows", + "verdict": "not touched by diff" + } + ], + "attack_surface": { + "new_endpoints": 3, + "new_mcp_tools": 3, + "new_exec": "marker force-write, constant argv", + "census": "AGENT_CALLABLE x3" + }, + "checks": [ + { + "check": "secret prefixes / emails in added lines", + "result": "none" + }, + { + "check": "enterprise-docs-guard PATTERN on added doc lines", + "result": "none" + }, + { + "check": "invisible Unicode in changed files", + "result": "none" + }, + { + "check": "request.url.path in new fences", + "result": "none (path_params / principal only)" + }, + { + "check": "guards", + "result": "test_2996, test_186, test_293, test_1310_* green" + }, + { + "check": "mutation battery", + "result": "27 mutations across write paths, auth bounds, marker ordering, drop gating; each red" + } + ], + "findings": [ + { + "id": 1, + "severity": "LOW", + "confidence": 8, + "status": "VERIFIED", + "evidence": "SUPPORTED", + "category": "info-disclosure", + "owasp_2025": "A01", + "asi": "ASI03", + "llm": "LLM02", + "phase": 7, + "file": "src/backend/services/skill_gate_map_service.py", + "symbol": "_public / list_gates", + "title": "Gate list returns set_by (an email for email-login users) to agent keys", + "exploit": "An email-login admin sets a gate on another user's agent; that agent's key calls list_skill_gates and reads the admin's email.", + "recommendation": "Withhold set_by from non-person principals on read and write responses (the #715 projection).", + "resolution": "fixed in-branch with a test", + "verifier": "independent agent (PARTIAL: only non-owner writers are new)" + } + ], + "hypotheses": [], + "coverage_gaps": [ + "no Docker pass (diff mode, no image change)", + "pg-migrations upgrade head not run locally" + ], + "supply_chain_summary": { + "scanner": "n/a (no dependency change)" + }, + "filter_stats": { + "candidates": 3, + "reported": 1 + }, + "totals": { + "CRITICAL": 0, + "HIGH": 0, + "MEDIUM": 0, + "LOW": 1 + }, + "trend": "new surface; no prior report" +} diff --git a/docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.md b/docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.md new file mode 100644 index 000000000..5fff67dda --- /dev/null +++ b/docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.md @@ -0,0 +1,68 @@ +# CSO diff audit — 2026-10-07 — trinity-enterprise#753 (the per-agent skill gate map) + +**Mode**: `--diff`, daily (8/10 gate) · **Skill**: cso v1.2.3 · **Base**: `dev` @ `a51549772` · **Branch**: `feature/753-skill-gate-map` (working tree, pre-commit) · **Counts as full run**: no + +## Scope + +| Area | Change | +|---|---| +| Storage | new `agent_skill_gates` (both tracks: SQLite `agent_skill_gates`, Alembic `0093`), `db/skill_gates.py`, CASCADE `AgentRef` | +| Read seam | `skill_gate_service.list_skill_gates` reads the table; a failed read raises (→ 503 `gate_unavailable`); `marker_lock` / `sync_marker_locked` / `write_marker` split out of `sync_gate_marker` | +| Writes | `services/skill_gate_map_service.py`: set / clear / reconcile / person-only drop, under the per-agent marker lock | +| REST | `GET/PUT/DELETE /api/agents/{agent_name}/skill-gates[/{skill_name}]` (`routers/skill_gate.py` `agent_router`) | +| Auth | `require_person_or_capability` (copied verbatim from trinity#3236), `get_skill_gate_readable_agent_by_name` | +| Assignment paths | `routers/skills.py` `_sync_gates` on six routes; `lifecycle.inject_assigned_skills`; `skills_sync_service._reinject_agent` | +| Metadata | `approval:` frontmatter key (closed set `{recommended}`) in `skill_packaging.extract_contract` | +| MCP | `list_skill_gates`, `set_skill_gate`, `clear_skill_gate`; `approval` mapped in `list_skills`; `TOOL_ACCESS_POLICY` rows | + +No dependency, CI, Docker, compose, base-image or frontend change. + +## Phase 0 — model and quicklist + +The gate map is configuration that tightens what an agent may run. Its integrity matters in one direction: anything that removes or loosens a gate is the sensitive write. The trust boundaries are (a) who may write a gate (a person who owns the agent or is an admin; a `skills.manage` holder on an owner-owned agent, never itself), (b) who may read it, and (c) the assignment paths that move library defaults. Quicklist: no pinned component touched. Starlette BadHost row: the new fences read `request.path_params` (`_path_agent`) and the principal, never `request.url.path`; `request.scope["path"]` is read for the audit row only. + +## Phase 1 — attack surface delta + +- New endpoints: 3 (census `AGENT_CALLABLE`, reasons recorded). New MCP tools: 3 (operator scopes only; connector keys never see them). +- New exec: the marker force-write — constant argv (`marker_command(True)`), path positional; no name reaches argv. +- Agent-context ingress: `list_skill_gates` returns skill names (validated by `SKILL_NAME_RE`), approver kind, origin, deadline, `set_by`, `set_by_agent`, `set_at` — no free text. See finding 1 for `set_by`. + +## Phases 2–11 + +- **P2 secrets**: added lines scanned for known prefixes and real-looking emails — none. Enterprise-docs guard PATTERN over added doc lines — no hit; wording that named an enterprise-only consumer was reworded to the generic seam during /review. +- **P3/P4/P5/P6**: not applicable (no dependency, workflow, Docker or webhook change). +- **P7 agentic**: + - ASI03 — writes refuse an agent on itself (`self_person_only`), bound a holder to agents its owner owns (owner equality, 404), and refuse the system key, connector, ops, portal_delegate and a missing scope (`assert_person`, sentinel). An agent's unassign keeps explicit gates; a deferred package removal keeps every gate on the skill; the library can only tighten. The census guard, `test_186`, `test_293`, `test_1310_*` are green. + - ASI10 — ghosts cannot hold gates (409 after the access check); `_EPHEMERAL_ALLOWED_ROUTES` unchanged. + - ASI01/ASI06 — no new third-party text into a prompt; `approval:` is a closed enum, never rendered. + - LLM02 — finding 1. +- **P8 agent-shipped**: no shipped file changed. Invisible-Unicode grep over every changed file: none. +- **P9 OWASP**: A01 — enumeration-safe (principal-only 403 before any lookup; sibling and missing agent answer byte-identical 404s, tested). A05 — SQLAlchemy Core binds only; no exec argument from input. A10 — failure directions stated: the read raises (closed); the reconcile never raises and inserts nothing on an unreadable library (stated, retried at the next start/sync/assignment — the library is admin-configured, an agent cannot make it unreadable); container-state unreadable → the exec is tried (bounded); `via_for` uses a sentinel for a missing scope. +- **P10 STRIDE (gate map)**: Spoofing — writer identity from the authenticated principal only. Tampering — every write under `lock_agent_rows` + the marker lock; insert only while the agent is live. Repudiation — audited `CONFIGURATION` rows with `via`/`trigger`, only on change. Information disclosure — finding 1. DoS — no cap on gates per agent (owner-written, self-inflicted; deferred by the user). EoP — covered under ASI03. +- **P11 data**: gate rows INTERNAL configuration; `set_by` CONFIDENTIAL (a username is an email for email-login users). + +## Findings + +| # | Sev | Conf | Status | Evidence | Category | Finding | Phase | File:Line | +|---|---|---|---|---|---|---|---|---| +| 1 | LOW | 8/10 | VERIFIED (fixed in-branch) | SUPPORTED | info-disclosure | The gate list returns `set_by` — a username, which for email-login users is their email — to agent keys, so an agent can learn a non-owner admin's email when that admin set or cleared a gate on it, or assigned it a recommended skill | P7 (LLM02) | `services/skill_gate_map_service.py` `_public`, `routers/skill_gate.py` `list_agent_skill_gates` | + +**Finding 1.** +- **What is exposed:** `_public` copies `set_by` into every gate, and the GET route and `list_skill_gates` pass it through unchanged. An agent key may read its own gates, and a `skills.manage` holder may read an owner-owned agent's. +- **How an agent sees it:** an admin who signs in by email sets a gate on another user's agent. That agent's key calls `list_skill_gates`, and the admin's email enters its context. +- **The verifier's refinement (PARTIAL):** a gate the agent's own key wrote names the agent's owner, which `GET /api/agents/{self}` already returns. Start, sync and sweep writes record `system`. Only a non-owner writer is new. +- **Fix:** withhold `set_by` from any non-person principal on the read and write responses. This follows the #715 queue projection. Done in the branch, with a test. +- **Verifier:** independent agent. + +## Hypotheses + +None (daily mode). + +## Coverage gaps + +- No Docker pass: diff mode, no image change. +- The PostgreSQL track was reviewed by reading the code. The Alembic revision is guarded by `has_table` and chained on `0092`. The `pg-migrations` CI job runs a real `upgrade head`; it was not run locally. + +## Trend + +New finding class for this surface; no prior report covers `agent_skill_gates`. diff --git a/src/backend/adapters/message_router.py b/src/backend/adapters/message_router.py index 5b5ff501f..57293691b 100644 --- a/src/backend/adapters/message_router.py +++ b/src/backend/adapters/message_router.py @@ -118,6 +118,25 @@ def _session_count(session) -> int: return count if isinstance(count, int) else 0 +def _reply_quote(message: NormalizedMessage) -> Optional[str]: + """The `[Replying to …: "…"]` line a group turn carries (ent#600), or None. + One helper for the prompt (`_format_group_sender`) and the skill gate's scan + (`_run_agent_task`), so the gate reads the line the agent reads (#3274).""" + raw_message = message.metadata.get("raw_message", {}) + return reply_quote_line(raw_message if isinstance(raw_message, dict) else {}, + message.metadata.get("bot_id", "")) + + +def _gate_request_text(message: NormalizedMessage, is_group: bool) -> Optional[str]: + """What the skill gate reads of a channel turn: the sender's text, after the + quote line in a group. Never the history block or the sender/group labels.""" + text = getattr(message, "text", None) + quote = _reply_quote(message) if is_group else None + if quote and text: + return f"{quote}\n{text}" + return quote or text + + def _format_group_sender(message: NormalizedMessage) -> str: """ Format sender identity for group chat context. @@ -156,7 +175,7 @@ def _format_group_sender(message: NormalizedMessage) -> str: # ent#600: a tagged reply to someone else's message carries the quoted # text — Telegram delivers it with every reply, Privacy Mode or not, so # this slice needs no BotFather setup. - quote = reply_quote_line(raw_message, message.metadata.get("bot_id", "")) + quote = _reply_quote(message) if quote: parts.append(quote) @@ -866,8 +885,10 @@ async def _run_agent_task( message=context_prompt, # trinity-enterprise#751: the skill gate reads what THIS sender # wrote, never the sender context + history `context_prompt` wraps - # it in — one past mention would gate every later turn. - request_text=getattr(message, "text", None), + # it in — one past mention would gate every later turn. #3274: + # plus, in a group, the message they reply to — the quote line + # is part of their request (the Workspace twin scans its quote). + request_text=_gate_request_text(message, is_group), triggered_by=channel, conversation_key=f"channel:{session_id}" if session_id else None, source_user_email=source_email, diff --git a/src/backend/database.py b/src/backend/database.py index 4c0429b88..00a4660ef 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -137,6 +137,7 @@ from db.skill_sets import SkillSetsOperations from db.role_readiness import RoleReadinessOperations from db.capability_grants import CapabilityGrantOperations +from db.skill_gates import SkillGateOperations from db.seat_decisions import SeatDecisionOperations from db.skill_sources import SkillSourcesOperations from db.public_chat import PublicChatOperations @@ -1018,6 +1019,7 @@ def __init__(self): self._skill_sets_ops = SkillSetsOperations() self._role_readiness_ops = RoleReadinessOperations() self._capability_grant_ops = CapabilityGrantOperations() + self._skill_gate_ops = SkillGateOperations() self._seat_decision_ops = SeatDecisionOperations() self._skill_sources_ops = SkillSourcesOperations() self._public_chat_ops = PublicChatOperations() @@ -2835,6 +2837,30 @@ def revoke_agent_capability(self, agent_name: str, capability: str) -> bool: def delete_agent_capability_grants(self, agent_name: str) -> int: return self._capability_grant_ops.delete_agent_capability_grants(agent_name) + # ========================================================================= + # Skill gate map (delegated to db/skill_gates.py) — trinity-enterprise#753 + def list_agent_skill_gates(self, agent_name: str): + return self._skill_gate_ops.list_agent_skill_gates(agent_name) + + def write_skill_gate(self, agent_name: str, skill_name: str, *, changes, origin: str, + set_by: str, set_by_agent): + return self._skill_gate_ops.write_skill_gate( + agent_name, skill_name, changes=changes, origin=origin, set_by=set_by, + set_by_agent=set_by_agent) + + def clear_skill_gate(self, agent_name: str, skill_name: str, *, set_by: str, set_by_agent, + recommended): + return self._skill_gate_ops.clear_skill_gate( + agent_name, skill_name, set_by=set_by, set_by_agent=set_by_agent, + recommended=recommended) + + def reconcile_library_skill_gates(self, agent_name: str, recommended, *, set_by: str, may_drop): + return self._skill_gate_ops.reconcile_library_skill_gates( + agent_name, recommended, set_by=set_by, may_drop=may_drop) + + def drop_unassigned_skill_gates(self, agent_name: str, names, *, keep=()): + return self._skill_gate_ops.drop_unassigned_skill_gates(agent_name, names, keep=keep) + # Role readiness (delegated to db/role_readiness.py) — ent#527 / #663 # ========================================================================= @@ -3853,10 +3879,10 @@ def operator_queue_walk_alert_priorities(self, **kwargs): def respond_to_operator_queue_item(self, item_id, response, response_text, responded_by_id, responded_by_email, - divergence_acknowledged=False): + divergence_acknowledged=False, terminal=False): return self._operator_queue_ops.respond_to_item( item_id, response, response_text, responded_by_id, responded_by_email, - divergence_acknowledged=divergence_acknowledged, + divergence_acknowledged=divergence_acknowledged, terminal=terminal, ) def cancel_operator_queue_item(self, item_id, *, disposed_by_email, reason=None, @@ -4307,7 +4333,7 @@ def list_non_terminal_loops(self): def start_loop_run(self, loop_id: str, run_number: int, *, execution_id=None) -> str: return self._loop_ops.start_loop_run(loop_id, run_number, execution_id=execution_id) - def finalize_loop_run(self, run_id: str, **kwargs): + def finalize_loop_run(self, run_id: str, **kwargs) -> bool: return self._loop_ops.finalize_loop_run(run_id, **kwargs) def list_loop_runs(self, loop_id: str): diff --git a/src/backend/db/agent_cleanup.py b/src/backend/db/agent_cleanup.py index ff4bc5cfb..f259f7fb0 100644 --- a/src/backend/db/agent_cleanup.py +++ b/src/backend/db/agent_cleanup.py @@ -287,6 +287,10 @@ class AgentRef: # for it finds no record on its ending and runs nothing. AgentRef("skill_gate_requests", "agent_name", Policy.CASCADE), AgentRef("skill_gate_requests", "source_agent", Policy.CASCADE), + # trinity-enterprise#753 — the agent's skill gate map follows it (rename + # re-keys, purge removes). `set_by_agent` is audit-only provenance and + # deliberately not registered (the `assigned_by_agent` precedent). + AgentRef("agent_skill_gates", "agent_name", Policy.CASCADE), AgentRef("access_requests", "agent_name", Policy.CASCADE), # --- MCP keys (scope='agent' only — user/system keys are not per-agent) diff --git a/src/backend/db/loops.py b/src/backend/db/loops.py index 5f9bb1c80..28c43196f 100644 --- a/src/backend/db/loops.py +++ b/src/backend/db/loops.py @@ -337,10 +337,25 @@ def finalize_loop_run( cost: Optional[float], duration_ms: Optional[int], execution_id: Optional[str] = None, - ) -> None: + ) -> bool: + """Close a ``running`` run row; return True when this call closed it. + + A CAS on ``status = 'running'`` (#3316). The loop-level + ``claim_loop_advance`` and this close are two writes, so a fault between + them leaves the loop claimed with the run still open — and the repair + path in ``loop_service.advance_on_terminal`` closes it from a later + delivery. Two callers can then race to close the same row (the original + claimer and a repairer); whoever wins this UPDATE owns the rest of the + advance, the loser returns without dispatching. + """ stmt = ( update(agent_loop_runs) - .where(agent_loop_runs.c.id == run_id) + .where( + and_( + agent_loop_runs.c.id == run_id, + agent_loop_runs.c.status == "running", + ) + ) .values( status=status, response=response, @@ -352,7 +367,7 @@ def finalize_loop_run( ) ) with get_engine().begin() as conn: - conn.execute(stmt) + return conn.execute(stmt).rowcount > 0 def list_runs(self, loop_id: str) -> List[dict]: stmt = ( diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py index 8fe58e77d..5743f64d4 100644 --- a/src/backend/db/migrations.py +++ b/src/backend/db/migrations.py @@ -5376,6 +5376,83 @@ def _run(sql, params, fetch=False): conn.commit() +# #2372: the platform-minted prefixes on the day the heal shipped. Frozen on +# purpose — a one-time fix of rows that already exist must not widen when a +# prefix is reserved later (`operator_queue_service._RESERVED_ID_PREFIXES` stays +# the live definition; a unit test pins the two equal at ship time). +PLATFORM_ALERT_HEAL_PREFIXES = ( + "queue-flood-", "poison-", "cb-dormant-", "sync-failing-", "git-bloat-", + "sync-diverged-", "skill-not-found-", "val_", "system-seed-", + "base-image-stale-", "alert-budget-", "effect-unguarded-", "db-backup-", + "log-archive-", "sub-headroom-", "skills-legacy-adoption-", + "workspace-problem-", "portal-inbox-collision-", "gitignore-untracked-", + "skills-reconcile-", "skills-fleet-reinject-", "retention-guard-", + "ent615-git-token-scrub-", "role-drift-", "gate-", +) +PLATFORM_ALERT_HEAL_SELECT_SQL = "SELECT id, request_id FROM operator_queue WHERE status = 'responded'" +PLATFORM_ALERT_HEAL_UPDATE_SQL = ( + "UPDATE operator_queue SET status = 'acknowledged', acknowledged_at = :now " + "WHERE id = :id AND status = 'responded'" +) + + +def platform_alert_heal_ids(rows): + """Ids of `responded` rows that are platform-minted — matched the way + `is_platform_minted` matches (strip + lowercase + prefix), in Python, + because SQL `LIKE` reads the `_` in `val_` as a wildcard.""" + return [ + row_id for row_id, request_id in rows + if str(request_id or "").strip().lower().startswith(PLATFORM_ALERT_HEAL_PREFIXES) + ] + + +def _migrate_platform_alert_responded_heal(cursor, conn): + """#2372: platform alerts an operator acknowledged before the fix sat in + `responded` — no agent ever acknowledges them, so they stayed for the 90-day + floor and out of Clear All. Moves each to `acknowledged` (`acknowledged_at` + = now), the state an operator's answer now lands in. Agents' own asks and + non-`responded` rows are untouched. Idempotent. + + Mirrored by the Alembic revision 0093_platform_alert_responded_heal. + """ + from utils.helpers import utc_now_iso + cursor.execute(PLATFORM_ALERT_HEAL_SELECT_SQL) + now = utc_now_iso() + sql = PLATFORM_ALERT_HEAL_UPDATE_SQL.replace(":now", "?").replace(":id", "?") + for row_id in platform_alert_heal_ids(cursor.fetchall()): + cursor.execute(sql, (now, row_id)) + conn.commit() + + +def _migrate_agent_skill_gates(cursor, conn): + """trinity-enterprise#753 — the per-agent skill gate map. + + One row per (agent, skill) that needs approval before it runs: the approver + kind and an optional deadline. The table the dispatch check (#751) and the + in-container hook (#752) read through ``skill_gate_service.list_skill_gates``. + See the DDL comment in db/schema.py. Additive: no row = ungated, so every + existing agent is unchanged on upgrade. + + Mirrored by the Alembic revision 0094_agent_skill_gates. + """ + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS agent_skill_gates ( + agent_name TEXT NOT NULL, + skill_name TEXT NOT NULL, + approver TEXT NOT NULL, + deadline_hours INTEGER, + origin TEXT NOT NULL, + set_by TEXT NOT NULL, + set_by_agent TEXT, + set_at TEXT NOT NULL, + PRIMARY KEY (agent_name, skill_name) + ) + """ + ) + conn.commit() + + MIGRATIONS = [ ("agent_sharing", _migrate_agent_sharing_table), ("schedule_executions_observability", _migrate_schedule_executions_observability), @@ -5538,4 +5615,6 @@ def _run(sql, params, fetch=False): ("platform_alert_subjects", _migrate_platform_alert_subjects), ("chat_session_claude_id", _migrate_chat_session_claude_id), ("portal_messages_attachments", _migrate_portal_messages_attachments), + ("platform_alert_responded_heal", _migrate_platform_alert_responded_heal), + ("agent_skill_gates", _migrate_agent_skill_gates), ] diff --git a/src/backend/db/operator_queue.py b/src/backend/db/operator_queue.py index a2680b7ba..2eb97965c 100644 --- a/src/backend/db/operator_queue.py +++ b/src/backend/db/operator_queue.py @@ -1162,9 +1162,19 @@ def respond_to_item( responded_by_id: Optional[str], responded_by_email: str, divergence_acknowledged: bool = False, + terminal: bool = False, ) -> Optional[Dict]: """Record a response to a queue item. + `terminal` (#2372): the row has no agent audience (a platform-minted + alert — the caller decides, this layer knows no prefixes), so the + answer is its last event: the same compare-and-set writes + `status='acknowledged'` + `acknowledged_at` instead of `responded`. No + agent will ever acknowledge it, and `responded` is kept from Clear All + and retained for the 90-day floor. `acknowledged_at` therefore means + "the agent acknowledged" on an agent's ask and "an operator + acknowledged" on a platform alert. + `divergence_acknowledged` (#2989 review): the operator saw that the agent had rewritten or closed the entry and answered anyway; the write-back then delivers into the entry as it is now instead of refusing it. @@ -1204,7 +1214,8 @@ def respond_to_item( ) ) .values( - status="responded", + status="acknowledged" if terminal else "responded", + acknowledged_at=now if terminal else None, response=response, response_text=response_text, responded_by_id=responded_by_id, diff --git a/src/backend/db/schema.py b/src/backend/db/schema.py index 351396e06..dddd533f0 100644 --- a/src/backend/db/schema.py +++ b/src/backend/db/schema.py @@ -1789,6 +1789,28 @@ ) """, + # trinity-enterprise#753 — the per-agent skill gate map: which skills on an + # agent need approval, and which kind of person approves. One row per + # (agent, skill); no row = ungated. `skill_name` is stored lowercased (skill + # names are ASCII and every matcher casefolds). `origin`: `set` (a person or + # an orchestrator), `library_default` (the library recommends approval) or + # `cleared` — a tombstone for a default the owner cleared, which gates + # nothing and stops the reconcile re-applying it. `agent_name` is a CASCADE + # AgentRef; `set_by_agent` is audit-only provenance and deliberately not. + "agent_skill_gates": """ + CREATE TABLE IF NOT EXISTS agent_skill_gates ( + agent_name TEXT NOT NULL, + skill_name TEXT NOT NULL, + approver TEXT NOT NULL, + deadline_hours INTEGER, + origin TEXT NOT NULL, + set_by TEXT NOT NULL, + set_by_agent TEXT, + set_at TEXT NOT NULL, + PRIMARY KEY (agent_name, skill_name) + ) + """, + # Nevermined Payment Integration (NVM-001) "nevermined_agent_config": """ CREATE TABLE IF NOT EXISTS nevermined_agent_config ( diff --git a/src/backend/db/skill_gates.py b/src/backend/db/skill_gates.py new file mode 100644 index 000000000..aaef4cc5e --- /dev/null +++ b/src/backend/db/skill_gates.py @@ -0,0 +1,228 @@ +"""The per-agent skill gate map (trinity-enterprise#753). SQL only. + +One row per (agent, skill) that needs approval before it runs. ``skill_name`` is +stored lowercased: skill names are ASCII (``SKILL_NAME_RE``) and every matcher — +the dispatch check, the in-container hook, the clearance read — casefolds, so +the key is unique however a caller spells it. + +``origin`` says where a row came from: + +* ``set`` — a person or an orchestrator set it; +* ``library_default`` — the library recommends approval for the skill and it + is assigned to the agent; +* ``cleared`` — a tombstone for a default the owner cleared. It gates nothing + (``skill_gate_service.list_skill_gates`` skips it) and stops the reconcile + re-applying the default while the skill stays assigned. + +Every write runs under ``lock_agent_rows`` and re-reads what it depends on +(the agent's gate rows, its assignment rows) inside that transaction, so two +gate writes on one agent never act on each other's stale snapshot. The skill +SET writers take the same lock; ``db.assign_skill`` / ``db.unassign_skill`` do +not, so on PostgreSQL a single assign committing mid-transaction can still be +missed (SQLite's database-wide write lock closes it). A write lands only while +the agent has a live ownership row: a rename or purge that wins the race leaves +nothing under a name a later agent could inherit. SQLAlchemy Core, so it runs +unchanged on SQLite and PostgreSQL. +""" +from typing import Any, Iterable, List, Mapping, Optional, Set, Tuple + +from sqlalchemy import and_, delete, select + +from .engine import get_engine, make_insert +from .skill_sets import lock_agent_rows +from .tables import agent_ownership, agent_skill_gates, agent_skills +from utils.helpers import utc_now_iso + +ORIGIN_SET = "set" +ORIGIN_LIBRARY_DEFAULT = "library_default" +ORIGIN_CLEARED = "cleared" + +_COLUMNS = ( + agent_skill_gates.c.agent_name, + agent_skill_gates.c.skill_name, + agent_skill_gates.c.approver, + agent_skill_gates.c.deadline_hours, + agent_skill_gates.c.origin, + agent_skill_gates.c.set_by, + agent_skill_gates.c.set_by_agent, + agent_skill_gates.c.set_at, +) + + +def _key(skill_name: str) -> str: + return (skill_name or "").lower() + + +def _row(conn, agent_name: str, skill_name: str) -> Optional[dict]: + row = conn.execute(select(*_COLUMNS).where( + agent_skill_gates.c.agent_name == agent_name, + agent_skill_gates.c.skill_name == _key(skill_name), + )).mappings().first() + return dict(row) if row else None + + +def _live(conn, agent_name: str) -> bool: + return conn.execute(select(agent_ownership.c.agent_name).where( + agent_ownership.c.agent_name == agent_name, + agent_ownership.c.deleted_at.is_(None), + )).first() is not None + + +def _assigned(conn, agent_name: str) -> Set[str]: + """The agent's library-assigned skill names, lowercased, read in `conn`.""" + return {r[0].lower() for r in conn.execute( + select(agent_skills.c.skill_name).where(agent_skills.c.agent_name == agent_name) + ) if r[0]} + + +def _delete(conn, agent_name: str, names: Iterable[str]) -> None: + names = sorted(set(names)) + if names: + conn.execute(delete(agent_skill_gates).where( + agent_skill_gates.c.agent_name == agent_name, + agent_skill_gates.c.skill_name.in_(names), + )) + + +class SkillGateOperations: + """Agent skill gate map operations.""" + + def list_agent_skill_gates(self, agent_name: str) -> List[dict]: + """Every row for the agent — tombstones included — by skill name. + + Raises on a failed read: the caller decides what an unreadable map + means, and "nothing is gated" is never it.""" + stmt = (select(*_COLUMNS) + .where(agent_skill_gates.c.agent_name == agent_name) + .order_by(agent_skill_gates.c.skill_name)) + with get_engine().connect() as conn: + return [dict(r) for r in conn.execute(stmt).mappings()] + + def write_skill_gate(self, agent_name: str, skill_name: str, *, changes: Mapping[str, Any], + origin: str, set_by: str, + set_by_agent: Optional[str]) -> Tuple[Optional[dict], Optional[dict], bool]: + """Insert or update one row. ``changes`` holds only the fields to set + (``approver``, ``deadline_hours``); a field it does not hold keeps the + stored value (a tombstone has none: ``primary``, no deadline). The merge + happens here, under the lock, so two partial writes never drop each + other's field. A mapping, not a sentinel: a module reload must never + turn "keep" into a value written to the row. + + Returns ``(previous, current, changed)``; ``(None, None, False)`` when the + agent has no live ownership row, and ``changed`` False when the row + already said exactly this (nothing is written).""" + with get_engine().begin() as conn: + lock_agent_rows(conn, agent_name) + if not _live(conn, agent_name): + return None, None, False + previous = _row(conn, agent_name, skill_name) + live = previous if previous and previous["origin"] != ORIGIN_CLEARED else None + approver = changes["approver"] if "approver" in changes else ( + live["approver"] if live else "primary") + deadline_hours = changes["deadline_hours"] if "deadline_hours" in changes else ( + live["deadline_hours"] if live else None) + if (previous and previous["origin"] == origin + and (previous["approver"], previous["deadline_hours"]) == (approver, deadline_hours)): + return previous, previous, False + values = dict(approver=approver, deadline_hours=deadline_hours, origin=origin, + set_by=set_by, set_by_agent=set_by_agent, set_at=utc_now_iso()) + conn.execute( + make_insert(agent_skill_gates) + .values(agent_name=agent_name, skill_name=_key(skill_name), **values) + .on_conflict_do_update( + index_elements=[agent_skill_gates.c.agent_name, agent_skill_gates.c.skill_name], + set_=values, + ) + ) + return previous, _row(conn, agent_name, skill_name), True + + def clear_skill_gate(self, agent_name: str, skill_name: str, *, set_by: str, + set_by_agent: Optional[str], + recommended: Optional[Set[str]]) -> Tuple[Optional[dict], Optional[str]]: + """Clear a gate. Returns ``(previous, action)``, action ``"deleted"``, + ``"tombstoned"`` or None (nothing was gated). + + A name that is library-assigned AND recommended (``recommended`` holds + the lowercased names; None = the library could not be read, so assume + it is) gets a ``cleared`` tombstone instead of a delete: without it the + reconcile would re-apply the default the owner just cleared. Any other + clear deletes, so a later recommendation can still apply. The tombstone + goes when the skill is unassigned. + """ + with get_engine().begin() as conn: + lock_agent_rows(conn, agent_name) + previous = _row(conn, agent_name, skill_name) + if previous is None or previous["origin"] == ORIGIN_CLEARED: + return previous, None + if (previous["skill_name"] in _assigned(conn, agent_name) + and (recommended is None or previous["skill_name"] in recommended)): + values = dict(origin=ORIGIN_CLEARED, set_by=set_by, set_by_agent=set_by_agent, + set_at=utc_now_iso(), deadline_hours=None) + conn.execute(agent_skill_gates.update().where( + agent_skill_gates.c.agent_name == agent_name, + agent_skill_gates.c.skill_name == previous["skill_name"], + ).values(**values)) + return previous, "tombstoned" + _delete(conn, agent_name, [previous["skill_name"]]) + return previous, "deleted" + + def reconcile_library_skill_gates(self, agent_name: str, recommended: Optional[Set[str]], *, + set_by: str, may_drop) -> Tuple[List[dict], List[dict]]: + """Bring the library-driven rows in line with the agent's assignments. + + Removes ``cleared`` tombstones whose skill is no longer assigned, and the + ``library_default`` rows of unassigned skills that ``may_drop(name)`` + allows — the caller knows whether the package has left the agent, and a + default stays while it may still be there. When ``recommended`` + (lowercased names) is not None, inserts a ``library_default`` row + (``primary``, no deadline) for every assigned recommended name with no + row. Never touches a ``set`` row, and never removes a default because the + metadata changed. Returns ``(removed rows, inserted rows)``. + """ + with get_engine().begin() as conn: + lock_agent_rows(conn, agent_name) + if not _live(conn, agent_name): + return [], [] + assigned = _assigned(conn, agent_name) + rows = [dict(r) for r in conn.execute(select(*_COLUMNS).where( + agent_skill_gates.c.agent_name == agent_name)).mappings()] + have = {r["skill_name"] for r in rows} + removed = [r for r in rows + if r["skill_name"] not in assigned + and (r["origin"] == ORIGIN_CLEARED + or (r["origin"] == ORIGIN_LIBRARY_DEFAULT and may_drop(r["skill_name"])))] + _delete(conn, agent_name, [r["skill_name"] for r in removed]) + inserted = [] + if recommended is not None: + now = utc_now_iso() + for name in sorted((assigned & {_key(n) for n in recommended}) - have): + values = dict(agent_name=agent_name, skill_name=name, approver="primary", + deadline_hours=None, origin=ORIGIN_LIBRARY_DEFAULT, + set_by=set_by, set_by_agent=None, set_at=now) + done = conn.execute( + make_insert(agent_skill_gates).values(**values).on_conflict_do_nothing( + index_elements=[agent_skill_gates.c.agent_name, + agent_skill_gates.c.skill_name]) + ).rowcount + if done: + inserted.append(values) + return removed, inserted + + def drop_unassigned_skill_gates(self, agent_name: str, names: Iterable[str], *, + keep: Iterable[str] = ()) -> List[dict]: + """Delete the ``set`` rows for ``names`` that are STILL not assigned + (a concurrent re-assign keeps its gate), except those in ``keep``. + Returns the rows removed.""" + wanted = {_key(n) for n in names} - {_key(n) for n in keep} + if not wanted: + return [] + with get_engine().begin() as conn: + lock_agent_rows(conn, agent_name) + gone = wanted - _assigned(conn, agent_name) + rows = [dict(r) for r in conn.execute(select(*_COLUMNS).where(and_( + agent_skill_gates.c.agent_name == agent_name, + agent_skill_gates.c.origin == ORIGIN_SET, + agent_skill_gates.c.skill_name.in_(sorted(gone)), + ))).mappings()] if gone else [] + _delete(conn, agent_name, [r["skill_name"] for r in rows]) + return sorted(rows, key=lambda r: r["skill_name"]) diff --git a/src/backend/db/tables.py b/src/backend/db/tables.py index 9c4eabf30..649143434 100644 --- a/src/backend/db/tables.py +++ b/src/backend/db/tables.py @@ -1558,6 +1558,21 @@ def process_bind_param(self, value, dialect): UniqueConstraint("dispatched_execution_id"), ) +# trinity-enterprise#753 — the per-agent skill gate map. See the DDL comment in +# db/schema.py. The primary key is the ON CONFLICT target of every write. +agent_skill_gates = Table( + "agent_skill_gates", + metadata, + Column("agent_name", Text, primary_key=True), + Column("skill_name", Text, primary_key=True), # lowercased + Column("approver", Text, nullable=False), # primary | approver + Column("deadline_hours", _Integer), # 1..168; NULL → the 24h default + Column("origin", Text, nullable=False), # set | library_default | cleared + Column("set_by", Text, nullable=False), + Column("set_by_agent", Text), + Column("set_at", Text, nullable=False), +) + nevermined_agent_config = Table( "nevermined_agent_config", metadata, diff --git a/src/backend/db_models.py b/src/backend/db_models.py index fc814160e..cfa88b3b3 100644 --- a/src/backend/db_models.py +++ b/src/backend/db_models.py @@ -802,6 +802,9 @@ class SkillInfo(BaseModel): # name, and is None unless `deprecated` is true. deprecated: bool = False superseded_by: Optional[str] = None + # trinity-enterprise#753: `recommended` — the skill is gated by default on + # every agent it is assigned to (the owner can clear it). None otherwise. + approval: Optional[str] = None class AgentSkillsUpdate(BaseModel): diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index b176b1add..8eb7296b1 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -2060,6 +2060,47 @@ async def dep(request: Request, current_user: User = Depends(get_current_user)) return dep +async def get_skill_gate_readable_agent_by_name( + agent_name: str = Path(..., description="Agent name from path"), + current_user: User = Depends(get_current_user), +) -> str: + """Who may read an agent's skill gate map (trinity-enterprise#753). + + An ALLOWlist decided on the principal first, so a refused scope learns + nothing about the target (403 before any lookup): + + * a person (signed-in session or the person's own user-scoped key) and the + system key — anything they may access (`get_authorized_agent_by_name`, + uniform 404); + * an agent key — its OWN gates (the in-container hook pulls; nothing is + pushed into the workspace), or, holding `skills.manage`, an agent its + owner OWNS (the orchestrator reports drift). Never the admin + short-circuit an agent key's owner role would otherwise carry. Any other + target is the same 404 as an agent that does not exist; + * connector, portal delegate, any other scope and a principal with no scope + at all — refused. + """ + scope = getattr(current_user, "mcp_scope", _SCOPE_ABSENT) + if ((scope in PERSON_SCOPES or scope == "system") + and not getattr(current_user, "connector_agent", None) + and not getattr(current_user, "portal_delegate", False)): + return get_authorized_agent_by_name(agent_name=agent_name, current_user=current_user) + own = getattr(current_user, "agent_name", None) + if scope == "agent" and own and not getattr(current_user, "vouched_source_agent", None): + from db.capability_grants import CAPABILITY_SKILLS_MANAGE + owner = db.get_agent_owner(agent_name) or {} + holder = capability_refusal(current_user, CAPABILITY_SKILLS_MANAGE) is None + if owner and (agent_name == own + or (holder and owner.get("owner_username") == current_user.username)): + return agent_name + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "skill_gates_not_readable", + "message": "This key cannot read an agent's skill gates."}, + ) + + async def get_skill_managed_agent_by_name( request: Request, agent_name: str = Path(..., description="Agent name from path"), diff --git a/src/backend/main.py b/src/backend/main.py index 0012c8a18..dac968a62 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -83,6 +83,7 @@ from routers.compatibility import router as compatibility_router # #668 agent compatibility from routers.skills import router as skills_router from routers.skill_gate import router as skill_gate_router # trinity-enterprise#752 in-container hook check +from routers.skill_gate import agent_router as skill_gate_agent_router # trinity-enterprise#753 gate map from routers.internal import router as internal_router, pull_router as internal_pull_router from routers.tags import router as tags_router, set_websocket_manager as set_tags_ws_manager from services.skill_service import set_websocket_manager as set_skills_ws_manager @@ -1396,6 +1397,7 @@ async def add_security_headers(request: Request, call_next): app.include_router(operator_queue_router) # Operator Queue (OPS-001) app.include_router(operator_queue_agent_router) # an agent's own ask readback (trinity-enterprise#611) app.include_router(skill_gate_router) # the in-container skill-gate hook's check (trinity-enterprise#752) +app.include_router(skill_gate_agent_router) # the per-agent skill gate map (trinity-enterprise#753) app.include_router(voice_router) # Voice Chat (VOICE-001) app.include_router(voip_public_router) # VoIP Telephony Media Streams WS (VOIP-001) app.include_router(voip_auth_router) # VoIP Telephony binding + trigger (VOIP-001) diff --git a/src/backend/migrations/versions/0093_platform_alert_responded_heal.py b/src/backend/migrations/versions/0093_platform_alert_responded_heal.py new file mode 100644 index 000000000..bfea55923 --- /dev/null +++ b/src/backend/migrations/versions/0093_platform_alert_responded_heal.py @@ -0,0 +1,41 @@ +"""platform_alert_responded_heal — acknowledged platform alerts leave `responded` (#2372). + +PostgreSQL half of the dual-track pair; the SQLite half is +``db/migrations.py::platform_alert_responded_heal``. Both use the same +statements and the same id filter (``platform_alert_heal_ids``), so the set of +healed rows cannot drift between them: every ``responded`` row whose +``request_id`` carries a platform prefix moves to ``acknowledged`` with +``acknowledged_at`` = now. Data only — no DDL. + +Revision ID: 0093_platform_alert_responded_heal +Revises: 0092_portal_messages_attachments +""" +from alembic import op +from sqlalchemy import text + + +revision = "0093_platform_alert_responded_heal" +down_revision = "0092_portal_messages_attachments" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + from db.migrations import ( + PLATFORM_ALERT_HEAL_SELECT_SQL, + PLATFORM_ALERT_HEAL_UPDATE_SQL, + platform_alert_heal_ids, + ) + from utils.helpers import utc_now_iso + + bind = op.get_bind() + now = utc_now_iso() + rows = bind.execute(text(PLATFORM_ALERT_HEAL_SELECT_SQL)).fetchall() + for row_id in platform_alert_heal_ids(rows): + bind.execute(text(PLATFORM_ALERT_HEAL_UPDATE_SQL), {"now": now, "id": row_id}) + + +def downgrade() -> None: + # Deliberately a no-op: the inverse parks acknowledged alerts back in a + # state nothing ever leaves. + pass diff --git a/src/backend/migrations/versions/0094_agent_skill_gates.py b/src/backend/migrations/versions/0094_agent_skill_gates.py new file mode 100644 index 000000000..1938ab009 --- /dev/null +++ b/src/backend/migrations/versions/0094_agent_skill_gates.py @@ -0,0 +1,47 @@ +"""agent_skill_gates — the per-agent skill gate map (trinity-enterprise#753). + +PostgreSQL half of the dual-track pair; the SQLite half is +``db/migrations.py::agent_skill_gates``. + +One row per (agent, skill) that needs approval before it runs: the approver +kind (``primary`` | ``approver``), an optional deadline, and where the row came +from (``set`` | ``library_default`` | ``cleared``, the last a tombstone that +gates nothing). ``agent_name`` is a CASCADE entry in ``db/agent_cleanup.py``. +Additive: no row = ungated, so no existing agent changes on upgrade. + +Revision ID: 0094_agent_skill_gates +Revises: 0093_platform_alert_responded_heal +""" +from alembic import op +import sqlalchemy as sa + +revision = "0094_agent_skill_gates" +down_revision = "0093_platform_alert_responded_heal" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # A fresh PostgreSQL database is built from `db/schema.py`'s DDL — which + # declares the table — before the revisions run, so guard on existence + # (the 0072 shape). + bind = op.get_bind() + if not sa.inspect(bind).has_table("agent_skill_gates"): + op.create_table( + "agent_skill_gates", + sa.Column("agent_name", sa.Text(), nullable=False), + sa.Column("skill_name", sa.Text(), nullable=False), + sa.Column("approver", sa.Text(), nullable=False), + sa.Column("deadline_hours", sa.Integer(), nullable=True), + sa.Column("origin", sa.Text(), nullable=False), + sa.Column("set_by", sa.Text(), nullable=False), + sa.Column("set_by_agent", sa.Text(), nullable=True), + sa.Column("set_at", sa.Text(), nullable=False), + sa.PrimaryKeyConstraint("agent_name", "skill_name"), + ) + + +def downgrade() -> None: + # Dropping the table ungates every skill on every agent — the honest + # inverse of an additive upgrade. + op.drop_table("agent_skill_gates") diff --git a/src/backend/models.py b/src/backend/models.py index 3f99ebb23..e2033a2e6 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -3845,6 +3845,67 @@ class SkillGateCheckResponse(BaseModel): message: Optional[str] = None +class SkillGateSetRequest(BaseModel): + """`PUT /api/agents/{agent_name}/skill-gates/{skill_name}` (trinity-enterprise#753). + + Only the fields sent are applied: on an existing gate an omitted field keeps + its stored value, and `deadline_hours: null` resets to the 24-hour default. + Typed loosely on purpose and checked by the service, so a bad value gets a + NAMED refusal (`invalid_approver`, `approver_unavailable`, `invalid_deadline`) + — and a JSON `true` or `"24"` is refused rather than coerced to an int. + """ + model_config = ConfigDict(extra="forbid") + + approver: Optional[Any] = Field( + default=None, description="Who approves: 'primary' (default) or 'approver' (where offered).") + deadline_hours: Optional[Any] = Field( + default=None, description="Hours an approver has, 1 to 168; null for the 24-hour default.") + + +class SkillGateEntry(BaseModel): + """One gated skill on one agent (trinity-enterprise#753).""" + skill_name: str + approver: str + deadline_hours: Optional[int] = None + origin: str # set | library_default + set_by: Optional[str] = None + set_by_agent: Optional[str] = None + set_at: Optional[str] = None + approver_reachable: Optional[bool] = None + + +class SkillGateMapResponse(BaseModel): + """`GET /api/agents/{agent_name}/skill-gates`. `approver_kinds` is what this + install can resolve — `primary` only on OSS. `cleared_defaults` are library + defaults the owner cleared; they gate nothing while the skill stays assigned.""" + agent_name: str + gates: List[SkillGateEntry] + cleared_defaults: List[str] = Field(default_factory=list) + approver_kinds: List[str] + default_deadline_hours: int + + +class SkillGateWriteResponse(BaseModel): + """What `PUT .../skill-gates/{skill_name}` did. `warnings`: + `approver_unassigned` (the approver kind reaches nobody now, so a gated + request would be refused) and `marker_not_written` (the running agent's + fail-closed marker could not be written; the check re-syncs it).""" + agent_name: str + skill_name: str + gate: SkillGateEntry + changed: bool + warnings: List[str] = Field(default_factory=list) + + +class SkillGateClearResponse(BaseModel): + """What `DELETE .../skill-gates/{skill_name}` did. `cleared`: `deleted`, + `tombstoned` (a library-assigned skill — its default stays off), or null.""" + agent_name: str + skill_name: str + changed: bool + cleared: Optional[str] = None + + class BulkCancelRequest(BaseModel): """Body for bulk-cancelling pending queue items (#1017). diff --git a/src/backend/routers/public.py b/src/backend/routers/public.py index c2c5246e7..610566703 100644 --- a/src/backend/routers/public.py +++ b/src/backend/routers/public.py @@ -890,6 +890,7 @@ async def public_execution_status( if not execution or execution.agent_name != agent_name: raise HTTPException(status_code=404, detail="Execution not found") + gate = _gate_state(execution) return { "execution_id": execution.id, "status": execution.status, @@ -897,10 +898,26 @@ async def public_execution_status( # "cancelled by user" error; surface both like failed/success so the # public poller gets a reason instead of a silent null body. "response": execution.response if execution.status in ("success", "failed", "cancelled") else None, - "error": execution.error if execution.status in ("failed", "cancelled") else None, + "error": execution.error if execution.status in ("failed", "cancelled") or gate else None, + "gate": gate, } +def _gate_state(execution): + """trinity#3274: the skill gate closes a public turn `skipped`, with its text + as the error — "held" when the turn waits for an approval (a gate record + names it), "refused" otherwise — so the page stops polling and shows a + wait as a notice and a refusal as an error. None for any other row, and for + a row this link's own chat did not start (`public`).""" + if execution.status != "skipped" or getattr(execution, "triggered_by", None) != "public": + return None + try: + record = db.get_gate_requests_by_origin_executions([execution.id]).get(execution.id) + except Exception: # noqa: BLE001 — unreadable: show it as an error, never as a wait + record = None + return "held" if record and record.get("state") != "refused" else "refused" + + @router.post("/executions/{token}/{execution_id}/terminate") async def public_terminate_execution( token: str, diff --git a/src/backend/routers/skill_gate.py b/src/backend/routers/skill_gate.py index 881e85761..f2f1765e8 100644 --- a/src/backend/routers/skill_gate.py +++ b/src/backend/routers/skill_gate.py @@ -1,6 +1,7 @@ -# mcp: none — the in-container skill-gate hook's own check; the agent's own key, own gates only; not a tool +# mcp: none for the in-container check (the agent's own key, own gates only; not a tool); skills.ts (list_skill_gates, set_skill_gate, clear_skill_gate) for the gate map """ -Gated skills — the in-container hook's check (trinity-enterprise#752). +Gated skills — the in-container hook's check (trinity-enterprise#752) and the +per-agent gate map (trinity-enterprise#753). `POST /api/skill-gate/check` is called by the PreToolUse hook in the agent image (`docker/base-image/hooks/skill-gate.py`) before Claude Code loads a skill @@ -9,14 +10,42 @@ use, not a grant. Every verdict is a 200; only an unreadable gate map is not (503 `gate_unavailable`, through the app's SkillGateError handler), and the hook reads every non-200 as "no answer" and decides by its marker. + +`/api/agents/{agent_name}/skill-gates[/{skill_name}]` is the gate map itself: +which skills on the agent need approval, and which kind of person approves. +Read by anyone with access to the agent (an agent key: its own, or — holding +`skills.manage` — an agent its owner owns). Changed by a person who owns the +agent or is an admin, or by an agent holding `skills.manage` on an agent its +owner owns — never on itself (the agent a gate constrains must not lift it). """ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request -from dependencies import get_current_user, get_self_agent -from models import SkillGateCheckRequest, SkillGateCheckResponse, User -from services import skill_gate_service +from db.capability_grants import CAPABILITY_SKILLS_MANAGE +from dependencies import ( + get_current_user, + get_owned_agent_by_name, + get_self_agent, + get_skill_gate_readable_agent_by_name, + is_person_principal, + require_person_or_capability, +) +from models import ( + SkillGateCheckRequest, + SkillGateCheckResponse, + SkillGateClearResponse, + SkillGateMapResponse, + SkillGateSetRequest, + SkillGateWriteResponse, + User, +) +from services import skill_gate_map_service, skill_gate_service router = APIRouter(prefix="/api/skill-gate", tags=["skill-gate"]) +agent_router = APIRouter(prefix="/api/agents", tags=["skill-gate"]) + +# Changing a gate: a person, or a skills.manage holder on an agent its owner +# owns — never on itself (trinity-enterprise#753, the #3236 shape). +_gate_writer = require_person_or_capability(CAPABILITY_SKILLS_MANAGE, self_person_only=True) @router.post("/check", response_model=SkillGateCheckResponse) @@ -37,3 +66,75 @@ async def check_skill_invocation( marker=body.marker, current_user=current_user, ) + + +def _context(request: Request, current_user: User): + return skill_gate_map_service.gate_context( + current_user, + ip=request.client.host if request.client else None, + endpoint=request.scope.get("path"), + request_id=getattr(request.state, "request_id", None), + ) + + +def _for_principal(gate: dict, current_user: User) -> dict: + """`set_by` is a username — an email for email-login users. People stay + with people (#715): a machine key (an agent, the system key) never reads who + set a gate, so a non-owner admin's address never reaches an agent's context.""" + if is_person_principal(current_user) and not getattr(current_user, "vouched_source_agent", None): + return gate + return {**gate, "set_by": None} + + +def _refused(e: skill_gate_map_service.SkillGateMapRefused) -> HTTPException: + if e.status_code == 404: # renamed or deleted after the access check: the uniform 404 + return HTTPException(status_code=404, detail="Agent not found") + from services.chat_execution_service import ERROR_CODE_HEADER + return HTTPException(status_code=e.status_code, detail=e.as_detail(), + headers={ERROR_CODE_HEADER: e.code}) + + +@agent_router.get("/{agent_name}/skill-gates", response_model=SkillGateMapResponse) +async def list_agent_skill_gates( + agent_name: str = Depends(get_skill_gate_readable_agent_by_name), + current_user: User = Depends(get_current_user), +): + """The agent's gated skills, who approves each and whether that approver + kind reaches anyone right now.""" + view = skill_gate_map_service.list_gates(agent_name) + view["gates"] = [_for_principal(g, current_user) for g in view["gates"]] + return view + + +@agent_router.put("/{agent_name}/skill-gates/{skill_name}", response_model=SkillGateWriteResponse) +async def set_agent_skill_gate( + skill_name: str, + body: SkillGateSetRequest, + request: Request, + current_user: User = Depends(_gate_writer), + agent_name: str = Depends(get_owned_agent_by_name), +): + """Gate a skill on the agent, or change its gate. Only the fields sent are + applied.""" + try: + out = await skill_gate_map_service.set_gate( + agent_name, skill_name, changes=body.model_dump(include=body.model_fields_set), + ctx=_context(request, current_user)) + except skill_gate_map_service.SkillGateMapRefused as e: + raise _refused(e) + return {**out, "gate": _for_principal(out["gate"], current_user)} + + +@agent_router.delete("/{agent_name}/skill-gates/{skill_name}", response_model=SkillGateClearResponse) +async def clear_agent_skill_gate( + skill_name: str, + request: Request, + current_user: User = Depends(_gate_writer), + agent_name: str = Depends(get_owned_agent_by_name), +): + """Clear the gate on a skill. Idempotent.""" + try: + return await skill_gate_map_service.clear_gate( + agent_name, skill_name, ctx=_context(request, current_user)) + except skill_gate_map_service.SkillGateMapRefused as e: + raise _refused(e) diff --git a/src/backend/routers/skills.py b/src/backend/routers/skills.py index 1dc6237a8..d2bde0739 100644 --- a/src/backend/routers/skills.py +++ b/src/backend/routers/skills.py @@ -28,6 +28,7 @@ get_skill_managed_agent_by_name, can_manage_agent_skills, acting_agent_name, + is_person_principal, ) from database import db from db_models import AgentSkill, SkillInfo, AgentSkillsUpdate @@ -160,6 +161,99 @@ async def _deliver_assigned_skills( } +def _conflict_names(agent_name: str) -> List[str]: + """Assigned names whose library package was NOT written because the agent + has its own skill of that name (#2914 `conflict`). Read BEFORE an + unassign: the own skill is what runs, so its gate is the own skill's and + stays (trinity-enterprise#753, decision 1).""" + try: + return [r.skill_name for r in db.get_agent_skills(agent_name) + if getattr(r, "delivery_status", None) == "conflict"] + except Exception: # noqa: BLE001 — a missed snapshot keeps nothing extra; never block the write + return [] + + +async def _sync_gates( + agent_name: str, + current_user: User, + request: Optional[Request] = None, + *, + removed: Optional[List[str]] = None, + conflict: Optional[List[str]] = None, + removal: Optional[Dict[str, Any]] = None, + prune: Optional[Dict[str, Any]] = None, + add: bool = True, +) -> Optional[Dict[str, Any]]: + """Bring the agent's skill gates in line with an assignment write + (trinity-enterprise#753). Every route here that writes `agent_skills` calls + it — `tests/unit/test_ent753_assignment_hooks.py` holds that. + + * `add` — library defaults for every assigned recommended skill. Called + BEFORE `_deliver_assigned_skills`, so a package never lands ungated. + * A gate on a `removed` name goes only once its package has left the agent + (`removal`, the route's own report; `prune`, the inject route's). A + deferred or failed removal keeps it — the files may still be there. + * An explicit gate goes only when a PERSON unassigned the skill. An agent's + unassign — a skills.manage holder, itself or a sibling — keeps it and says + so, so unassign-then-reassign cannot launder a gate away (plan-gate + ruling 2026-10-07). + + Returns `{gates_removed, gates_kept, gate_defaults}` (only what happened), + or None. Never raises: the assignment has committed. + """ + from services import skill_gate_map_service as gate_map + + ctx = gate_map.gate_context( + current_user, trigger="assignment", + ip=request.client.host if request is not None and request.client else None, + endpoint=request.scope.get("path") if request is not None else None, + request_id=getattr(request.state, "request_id", None) if request is not None else None, + ) + report: Dict[str, Any] = {} + names = [n for n in (removed or []) if n] + gone = gate_map.packages_gone(removal) + try: + dropped: List[str] = [] + if names and is_person_principal(current_user) and not getattr( + current_user, "vouched_source_agent", None): + dropped = await gate_map.drop_explicit_gates_on_unassign( + agent_name, [n for n in names if n.lower() in gone], ctx=ctx, keep=conflict or []) + if dropped: + report["gates_removed"] = dropped + if names: + kept = [n for n in await asyncio.to_thread(gate_map.explicit_gate_names, agent_name, names) + if n not in dropped] + if kept: + report["gates_kept"] = kept + except Exception as e: # noqa: BLE001 — an explicit gate left in place is the safe direction + logger.warning("[ent#753] gate removal after unassign failed for %s: %s", agent_name, e) + defaults = await gate_map.reconcile_library_gates( + agent_name, ctx=ctx, add=add, + drop_defaults=gate_map.drop_after_prune(prune) if prune is not None else gone) + if defaults: + report["gate_defaults"] = defaults + return report or None + + +def _merge_gate_reports(*reports: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """One `gates` block from a route's before-delivery and after-removal calls.""" + out: Dict[str, Any] = {} + for report in reports: + for key, value in (report or {}).items(): + if key == "gate_defaults" and key in out: + merged = dict(out[key]) + for k in ("applied", "removed", "unreachable"): + merged[k] = list(merged.get(k, [])) + list(value.get(k, [])) + merged["library_unreadable"] = bool(merged.get("library_unreadable")) or bool( + value.get("library_unreadable")) + out[key] = merged + elif isinstance(value, list) and key in out: + out[key] = list(out[key]) + [v for v in value if v not in out[key]] + else: + out[key] = value + return out or None + + # ============================================================================ # Skills Library Endpoints # ============================================================================ @@ -484,6 +578,7 @@ async def update_agent_skills( previous = set(db.get_agent_skill_names(agent_name)) except Exception: # noqa: BLE001 — never block the assignment itself previous = set() + conflict = _conflict_names(agent_name) if set_names is not None: try: @@ -532,12 +627,16 @@ async def update_agent_skills( # `deferred` (the start-path reconcile finishes it), which is the honest # order for a replace that mostly ADDS. added = sorted(current - previous) + gates_added = await _sync_gates(agent_name, current_user, request) # ent#753: gated before delivered delivery = await _deliver_assigned_skills(agent_name, added) removal = await _remove_unassigned_skills( agent_name, sorted(previous - current), current_user, request ) if added or (previous - current): await broadcast_skills_changed(agent_name) + gates = _merge_gate_reports(gates_added, await _sync_gates( + agent_name, current_user, request, removed=sorted(previous - current), + conflict=conflict, removal=removal, add=False)) return { "success": True, @@ -547,6 +646,7 @@ async def update_agent_skills( "sets": db.agent_skill_set_names(agent_name), "delivery": delivery, "removal": removal, + "gates": gates, } @@ -569,6 +669,10 @@ async def inject_skills( # ent#530 order: sets reconciled (DB) → inject (lock) → prune (outside the # lock, not reentrant), so a member dropped upstream leaves on a manual Sync. await asyncio.to_thread(skill_set_service.reconcile_agent, agent_name) + # ent#753: library defaults follow the rows the set reconcile just wrote, + # before anything is injected. A Sync is not a person's unassign, so + # explicit gates of dropped members stay. + gates = await _sync_gates(agent_name, current_user) try: result = await skill_service.inject_skills(agent_name, force=True) except SkillInjectionBusy as e: @@ -579,6 +683,9 @@ async def inject_skills( logger.warning("[ent#530] prune after manual inject failed for %s: %s", agent_name, e) # #2703: a Sync changes the listing too — open surfaces refetch. await broadcast_skills_changed(agent_name) + # ent#753: the prune removed what is no longer assigned — their defaults go. + result["gates"] = _merge_gate_reports(gates, await _sync_gates( + agent_name, current_user, prune=result.get("reconcile"), add=False)) return result @@ -612,6 +719,7 @@ async def assign_skill( # page's assign control has no Sync button beside it, so that re-click is # the only retry it has. Delivery is the start-path injection (unchanged # skills cost one metas read), so an idempotent re-assign is cheap. + gates = await _sync_gates(agent_name, current_user) # ent#753: gated before delivered delivery = await _deliver_assigned_skills(agent_name, [skill_name]) await broadcast_skills_changed(agent_name) if result is None: @@ -620,6 +728,7 @@ async def assign_skill( "message": "Skill already assigned", "skill_name": skill_name, "delivery": delivery, + "gates": gates, } return { @@ -627,6 +736,7 @@ async def assign_skill( "message": "Skill assigned", "skill": result, "delivery": delivery, + "gates": gates, } @@ -671,6 +781,7 @@ async def unassign_skill( "message": f"Still assigned through set {', '.join(retained_via)}; unassign the set to remove it.", } + conflict = [skill_name] if row is not None and getattr(row, "delivery_status", None) == "conflict" else [] removed = db.unassign_skill(agent_name, skill_name) removal = None @@ -682,12 +793,16 @@ async def unassign_skill( # deferred — the listing surfaces re-read the container either way and # the assignment lists read the row. await broadcast_skills_changed(agent_name) + gates = await _sync_gates(agent_name, current_user, request, + removed=[skill_name] if removed else [], conflict=conflict, + removal=removal) return { "success": True, "removed": removed, "skill_name": skill_name, "removal": removal, + "gates": gates, } @@ -733,12 +848,17 @@ async def assign_skill_set( if not validate_skill_name(set_name): raise HTTPException(status_code=422, detail=f"invalid_set_name: {set_name!r}") + conflict = _conflict_names(agent_name) try: result = await asyncio.to_thread( skill_set_service.assign, agent_name, set_name, current_user.username, acting_agent_name(current_user)) except skill_set_service.SetError as e: raise HTTPException(status_code=e.status_code, detail=e.detail) + # ent#753: gated before delivered. This route removes no package, so a gate + # on a member the re-plan dropped stays until the start path prunes it. + gates = await _sync_gates(agent_name, current_user, + removed=result.get("removed") or [], conflict=conflict) delivery = await _deliver_assigned_skills(agent_name, result["added"]) if result["added"] else None await broadcast_skills_changed(agent_name) # The assign response probes prerequisites: one exec, on a write the fence already gated. @@ -752,6 +872,7 @@ async def assign_skill_set( "delivery": delivery, "status": status, "suggested_schedules": result["entry"]["schedules"], + "gates": gates, } @@ -768,12 +889,15 @@ async def unassign_skill_set( if not validate_skill_name(set_name): raise HTTPException(status_code=422, detail=f"invalid_set_name: {set_name!r}") + conflict = _conflict_names(agent_name) result = await asyncio.to_thread(skill_set_service.unassign, agent_name, set_name, current_user.username) removal = None if result["removed"]: removal = await _remove_unassigned_skills(agent_name, result["removed"], current_user, request) if result["existed"]: await broadcast_skills_changed(agent_name) + gates = await _sync_gates(agent_name, current_user, request, + removed=result["removed"], conflict=conflict, removal=removal) return { "success": True, "set_name": set_name, @@ -783,6 +907,7 @@ async def unassign_skill_set( # next reconcile after it resolves finishes this (fail-closed, stated). "removal_deferred": result.get("removal_deferred", False), "removal": removal, + "gates": gates, } diff --git a/src/backend/services/agent_service/lifecycle.py b/src/backend/services/agent_service/lifecycle.py index 7e2f96fe7..c0116f4ed 100644 --- a/src/backend/services/agent_service/lifecycle.py +++ b/src/backend/services/agent_service/lifecycle.py @@ -222,6 +222,13 @@ async def inject_assigned_skills(agent_name: str) -> dict: # fail-closed) BEFORE the names are read, so a member added upstream is # injected and one removed upstream is pruned by the reconcile below. await asyncio.to_thread(skill_set_service.reconcile_agent, agent_name) + # trinity-enterprise#753: an assigned skill the library recommends gating is + # gated BEFORE it is injected (agents that held it before the recommendation + # included). Defaults of unassigned skills go only after the prune below has + # removed their packages. Explicit gates are untouched. Never raises. + from services import skill_gate_map_service + gate_ctx = skill_gate_map_service.GateContext(trigger="start") + await skill_gate_map_service.reconcile_library_gates(agent_name, ctx=gate_ctx) # Get assigned skills skill_names = db.get_agent_skill_names(agent_name) @@ -233,6 +240,9 @@ async def inject_assigned_skills(agent_name: str) -> dict: # agent was stopped — returning here would strand that package on the # agent permanently, which is the precise gap this closes. reconcile = await skill_service.reconcile_agent_skills(agent_name, []) + await skill_gate_map_service.reconcile_library_gates( + agent_name, ctx=gate_ctx, add=False, + drop_defaults=skill_gate_map_service.drop_after_prune(reconcile)) return {"status": "skipped", "reason": "no_skills", "reconcile": reconcile} logger.info(f"Injecting {len(skill_names)} skills into agent {agent_name}: {skill_names}") @@ -250,6 +260,9 @@ async def inject_assigned_skills(agent_name: str) -> dict: # take the same per-agent lock, so reconciling first (or inside) would # deadlock against the injection that just ran. Never raises. reconcile = await skill_service.reconcile_agent_skills(agent_name, skill_names) + await skill_gate_map_service.reconcile_library_gates( + agent_name, ctx=gate_ctx, add=False, + drop_defaults=skill_gate_map_service.drop_after_prune(reconcile)) warning_count = sum( len(r.get("warnings") or []) for r in result.get("results", {}).values() diff --git a/src/backend/services/ask_service.py b/src/backend/services/ask_service.py index ff4cb5373..61a1d725a 100644 --- a/src/backend/services/ask_service.py +++ b/src/backend/services/ask_service.py @@ -216,6 +216,7 @@ def answer( validate_response_choice(item, response, response_text=response_text) if not may_end(item, actor): raise AskNotAddressee(item["id"]) + from services.operator_queue_service import is_platform_minted updated = db.respond_to_operator_queue_item( item_id=item["id"], response=response, @@ -223,6 +224,9 @@ def answer( responded_by_id=responded_by_id, responded_by_email=actor.email, divergence_acknowledged=divergence_acknowledged, + # #2372: a platform alert has no agent audience — nobody will ever + # acknowledge it from a file, so the person's answer is its last event. + terminal=is_platform_minted(item), ) if not updated: raise AskNotFound(item["id"]) diff --git a/src/backend/services/chat_execution_service.py b/src/backend/services/chat_execution_service.py index ccf47a0a2..9bf63a22b 100644 --- a/src/backend/services/chat_execution_service.py +++ b/src/backend/services/chat_execution_service.py @@ -2460,12 +2460,22 @@ async def dispatch_parallel_task( # `message` behind a harmless `user_message`) — and the caller's # `system_prompt`, which the executor receives appended to its own. A # Chat-tab history that mentions a gated skill re-gates later turns: the - # safe direction. - gate_text = "\n".join( - t for t in (request.message, request.user_message, request.system_prompt) if t) + # safe direction. A part identical to an earlier one is read (and shown on + # the card) once. trinity#3274: the approved run gets the message and the + # system prompt apart (`frozen_replay`), never this join; and the key's + # name and the email, which the executor's prompt carries beside the + # request, are scanned as context. + gate_parts = [] + for part in (request.message, request.user_message, request.system_prompt): + if part and part not in gate_parts: + gate_parts.append(part) + gate_text = "\n".join(gate_parts) gate = await skill_gate_service.enforce( name, request_text=gate_text, + context_text=skill_gate_service.requester_context_text( + mcp_key_name=getattr(current_user, "mcp_key_name", None), + source_email=current_user.email or current_user.username), requester=skill_gate_service.requester_from_principal( current_user, source_agent=x_source_agent, # The platform-injected turn (#2392) first; the model-typed @@ -2474,18 +2484,21 @@ async def dispatch_parallel_task( execution_id=x_trinity_execution_id or getattr(request, "parent_execution_id", None)), triggered_by=derivation.triggered_by, occurrence_key=idempotency_key, - dispatch=skill_gate_service.frozen_dispatch( - triggered_by=derivation.triggered_by, - model=request.model, - timeout_seconds=request.timeout_seconds, - allowed_tools=request.allowed_tools, - source_user_id=current_user.id, - source_user_email=current_user.email or current_user.username, - source_agent_name=x_source_agent, - source_mcp_key_id=getattr(current_user, "mcp_key_id", None), - source_mcp_key_name=getattr(current_user, "mcp_key_name", None), - chain_depth=chain_depth, - ), + dispatch={ + **skill_gate_service.frozen_dispatch( + triggered_by=derivation.triggered_by, + model=request.model, + timeout_seconds=request.timeout_seconds, + allowed_tools=request.allowed_tools, + source_user_id=current_user.id, + source_user_email=current_user.email or current_user.username, + source_agent_name=x_source_agent, + source_mcp_key_id=getattr(current_user, "mcp_key_id", None), + source_mcp_key_name=getattr(current_user, "mcp_key_name", None), + chain_depth=chain_depth, + ), + **skill_gate_service.frozen_replay(request.message, request.system_prompt), + }, ) # RELIABILITY-006 (#525): idempotency begin/replay (shared with /chat, RD2). diff --git a/src/backend/services/dispatch_admission_service.py b/src/backend/services/dispatch_admission_service.py index 797f37cc0..3ee9370b7 100644 --- a/src/backend/services/dispatch_admission_service.py +++ b/src/backend/services/dispatch_admission_service.py @@ -328,6 +328,11 @@ async def admit_chat_request( gate = await skill_gate_service.enforce( name, request_text=request.message, + # trinity#3274: the key's name and the email reach the executor's + # prompt beside the request. + context_text=skill_gate_service.requester_context_text( + mcp_key_name=getattr(current_user, "mcp_key_name", None), + source_email=current_user.email or current_user.username), requester=skill_gate_service.requester_from_principal( current_user, source_agent=x_source_agent, execution_id=x_trinity_execution_id), triggered_by=gate_trigger, diff --git a/src/backend/services/fan_out_service.py b/src/backend/services/fan_out_service.py index a598f81b8..be7a1b68e 100644 --- a/src/backend/services/fan_out_service.py +++ b/src/backend/services/fan_out_service.py @@ -91,6 +91,7 @@ wait_for_fan_out_batch, ) from services.task_execution_service import get_task_execution_service +from services import skill_gate_service from services.skill_gate_errors import SkillGateError from db.write_params import ExecutionResult, TaskExecutionFields @@ -521,6 +522,8 @@ async def run_subtask(task: FanOutTaskInput) -> None: # system_prompt reaches this subtask's executor too, so # the skill gate reads it with the subtask's message. request_text="\n".join(t for t in (task.message, system_prompt) if t), + # trinity#3274: an approved subtask is sent the two apart. + gate_replay=skill_gate_service.frozen_replay(task.message, system_prompt), triggered_by="fan_out", source_user_id=source_user_id, source_user_email=source_user_email, diff --git a/src/backend/services/loop_service.py b/src/backend/services/loop_service.py index 801d0198d..1bbb397bf 100644 --- a/src/backend/services/loop_service.py +++ b/src/backend/services/loop_service.py @@ -64,6 +64,7 @@ from typing import Any, Optional from database import db +from db.loops import TERMINAL_STATUSES as _LOOP_TERMINAL_STATUSES from services.runtime_secret_scrub import get_staged_values, scrub_text from services.skill_gate_errors import SkillGateError from services.task_execution_service import get_task_execution_service @@ -76,6 +77,7 @@ # Truncate previous_response to its trailing 2000 chars per spec PREV_RESPONSE_TRUNCATE_CHARS = 2000 + # WebSocket manager injected from main.py _websocket_manager = None @@ -328,14 +330,33 @@ async def advance_on_terminal(self, execution_id: Optional[str]) -> bool: # The idempotency gate. Everything below it happens exactly once per # iteration, however many times the terminal is delivered. if not db.claim_loop_advance(loop_id, run["run_number"]): - logger.debug( - "[Loop] %s run %s already advanced (duplicate terminal for %s)", - loop_id, run["run_number"], execution_id, + # #3316: the claim and the run-row close are two writes. A fault + # (a `database is locked`, a process kill) between them leaves + # the loop claimed for run N while run N's row is still + # `running` — and every later delivery, including + # `reconcile_after_restart`, would lose this CAS forever. That + # state is recognisable: repair it by closing the run here. A + # live advance still mid-flight looks the same, so the close is + # itself a CAS (`finalize_loop_run` on `status='running'`) and + # only one of the two goes on to dispatch. + current = db.get_loop(loop_id) + if ( + current is None + or current.get("status") in _LOOP_TERMINAL_STATUSES + or current.get("runs_completed") != run["run_number"] + ): + logger.debug( + "[Loop] %s run %s already advanced (duplicate terminal for %s)", + loop_id, run["run_number"], execution_id, + ) + return False + logger.warning( + "[Loop] %s run %s claimed but never closed; closing it from " + "execution %s", loop_id, run["run_number"], execution_id, ) - return False + loop = current - await self._close_run(loop, run, execution_id) - return True + return await self._close_run(loop, run, execution_id) except Exception: # noqa: BLE001 — never break a terminal write logger.exception( "[Loop] advance failed for execution %s", execution_id @@ -448,7 +469,7 @@ def _execution_is_live(execution_id: Optional[str]) -> bool: status = status.value if hasattr(status, "value") else str(status) return status in ("queued", "running", "pending_retry") - async def _close_run(self, loop: dict, run: dict, execution_id: str) -> None: + async def _close_run(self, loop: dict, run: dict, execution_id: str) -> bool: """Finalize the run row from its execution, then decide what happens next. This is the body of the old `for` loop's iteration tail, verbatim in @@ -456,6 +477,9 @@ async def _close_run(self, loop: dict, run: dict, execution_id: str) -> None: post-run gates (`stop_signal` > `no_progress` on success; `_abort_after_failure` on failure) before falling through to the next-iteration gates in `_continue_or_finalize`. + + Returns False, doing nothing past the close, when another caller closed + the run row first (#3316 — the close is the second CAS of the advance). """ loop_id = loop["id"] run_number = run["run_number"] @@ -475,7 +499,7 @@ async def _close_run(self, loop: dict, run: dict, execution_id: str) -> None: # the same template. held = status == "skipped" and bool(db.get_gate_requests_by_origin_executions([execution_id])) - db.finalize_loop_run( + if not db.finalize_loop_run( run["id"], status="completed" if succeeded else ("skipped" if held else "failed"), response=response, @@ -483,12 +507,16 @@ async def _close_run(self, loop: dict, run: dict, execution_id: str) -> None: cost=cost, duration_ms=duration_ms, execution_id=execution_id, - ) + ): + logger.debug( + "[Loop] %s run %d already closed by another advance", loop_id, run_number, + ) + return False if held: await self._finalize(loop, status="stopped", stop_reason="approval_required", error=error) - return + return True runs = db.list_loop_runs(loop_id) derived = _DerivedState(runs) @@ -545,7 +573,7 @@ async def _close_run(self, loop: dict, run: dict, execution_id: str) -> None: status=self._promote(loop, "completed", derived), stop_reason="stop_signal_matched", ) - return + return True # #1157: a pending user-stop or a passed deadline OUTRANKS # no_progress — fall through so the next-iteration gates label it @@ -561,7 +589,7 @@ async def _close_run(self, loop: dict, run: dict, execution_id: str) -> None: await self._finalize( loop, status="stopped", stop_reason="no_progress" ) - return + return True else: logger.warning( "[Loop] %s iteration %d failed (%s)", @@ -573,9 +601,10 @@ async def _close_run(self, loop: dict, run: dict, execution_id: str) -> None: await self._finalize( loop, status=status_, stop_reason=reason, error=terminal_error, ) - return + return True await self._continue_or_finalize(loop_id) + return True async def _continue_or_finalize(self, loop_id: str, *, parked: bool = False) -> bool: """The old `for` loop's top-of-iteration gates, then dispatch. diff --git a/src/backend/services/operator_queue_service.py b/src/backend/services/operator_queue_service.py index d706be57c..bc38ac14f 100644 --- a/src/backend/services/operator_queue_service.py +++ b/src/backend/services/operator_queue_service.py @@ -916,7 +916,9 @@ def _clamp_ingested_item(req: dict, agent_name: str = "") -> dict: if opt_bytes is None or opt_bytes > OPERATOR_QUEUE_OPTIONS_MAX_BYTES: out["options"] = [_OPTIONS_DROPPED_MARKER] - if out.get("priority") not in _VALID_PRIORITIES: + # #3313: isinstance first — a list/object priority is unhashable, and the + # set membership test raised out of this never-raises clamp. + if not isinstance(out.get("priority"), str) or out["priority"] not in _VALID_PRIORITIES: out["priority"] = "medium" # Ignore the agent-supplied created_at (a future date pins the item atop the @@ -1080,7 +1082,8 @@ def _comparable_type(value) -> str: def _comparable_priority(value) -> str: - return value if value in _VALID_PRIORITIES else "medium" # the clamp's default + # #3313: isinstance first, as in the clamp — an unhashable value raised here. + return value if isinstance(value, str) and value in _VALID_PRIORITIES else "medium" def _comparable_addressee(value) -> Optional[str]: @@ -1771,7 +1774,15 @@ async def _poll_cycle(self): if running_agents: # Sync each agent concurrently (with a reasonable limit) tasks = [self._sync_agent(name) for name in running_agents] - await asyncio.gather(*tasks, return_exceptions=True) + results = await asyncio.gather(*tasks, return_exceptions=True) + # #3313: `return_exceptions=True` keeps one agent's failure from + # cancelling the others, but it also dropped the error unlogged. + for name, result in zip(running_agents, results): + if isinstance(result, Exception): + logger.error( + f"Operator queue sync failed for '{name}': " + f"{type(result).__name__}: {result}" + ) if self._changed_this_cycle: await self._broadcast_sync() @@ -2013,6 +2024,19 @@ async def _sync_agent(self, agent_name: str): req_id = req.get("id") if not req_id: continue + if not isinstance(req_id, str): + # #3313: only a string id can name a row. A list or object id + # reached the unguarded `in open_by_rid` below and raised out of + # the whole sync, so none of this agent's answers were written + # back. A pending one is held as `invalid_id`, as the create-side + # shape check would hold it; any other status names nothing. + if req.get("status", "pending") == "pending": + logger.warning( + f"Rejecting malformed operator-queue id from {agent_name}: {req_id!r}" + ) + held += 1 + hold_reason = _stronger_hold(hold_reason, HOLD_INVALID_ID) + continue # #1631: reject an agent-authored id that impersonates a platform id # prefix (hijack/suppress guard). Log once per (agent, id) so it @@ -2025,6 +2049,14 @@ async def _sync_agent(self, agent_name: str): _RESERVED_ID_PREFIXES ): key = (agent_name, req_id) + # #2372: only a PENDING entry can pre-create (and so suppress) + # a platform row. A non-pending one is what the pre-ent#499 + # write-back left in the file — the platform's own entry, not + # the agent's — and nothing removes it, so the row may already + # be pruned. Skipped quietly; the WARNING stays for the + # impersonation case. + if req.get("status", "pending") != "pending": + continue if key not in self._rejected_reserved: if len(self._rejected_reserved) >= _MAX_QUARANTINE_ENTRIES: self._rejected_reserved.clear() # safety valve @@ -2473,6 +2505,11 @@ async def _write_responses_to_agent( if not isinstance(req, dict): continue req_id = req.get("id") + if not isinstance(req_id, str): + # #3313: every map here is keyed on a row's string request_id, + # so a non-string id names no row — and a list/object one made + # the lookup below raise, aborting the whole write-back. + continue if req_id in response_map: resp = response_map[req_id] # #2989 review: "send again to answer anyway" must reach the file. diff --git a/src/backend/services/platform_prompt_service.py b/src/backend/services/platform_prompt_service.py index ad5968eff..1ab36b1dd 100644 --- a/src/backend/services/platform_prompt_service.py +++ b/src/backend/services/platform_prompt_service.py @@ -289,7 +289,7 @@ - Read the result with `get_execution_result(agent_name, execution_id)` (`get_fan_out_result` for a batch); `running` is not stuck. To finish later, call `set_reminder` with a message naming the `execution_id`, then end your turn. A receipt is not a result: never report the work as done. - Confirmed `failed` or `cancelled`: re-send word for word to retry; same `execution_id` back: `set_reminder`, end your turn. - An error without an `execution_id`, `agent_busy` included, is not proof that nothing ran. Look for your exact message in `list_recent_executions(agent_name)`: one match is your receipt; otherwise re-send it word for word, same options (for `agent_busy`, after `retry_after_seconds`). -- `pending_approval`: nothing ran. Do not retry or route it through another agent — the outcome will be sent to you. On `retryable: false`, do what its `message` says. +- `pending_approval`: nothing ran. Do not retry or route it through another agent; its `message` says whether you will hear the outcome. On `retryable: false`, do what it says. - For long work use `parallel=true, async=true`: the receipt comes back at once, and the run's end fires the target's `agent.task.completed` / `agent.task.failed`. A `subscribe_to_event` subscription to those wakes you for every run of that agent, so match the `execution_id`.""" PLATFORM_INSTRUCTIONS = PLATFORM_INSTRUCTIONS.replace("__DELEGATION_CONTRACT__", DELEGATION_CONTRACT) @@ -687,6 +687,13 @@ def _sanitize_field(value: Optional[str], max_len: int = MAX_FIELD_LEN) -> Optio return cleaned +def render_context_field(value: Optional[str]) -> Optional[str]: + """A requester-set field (a schedule's name, an MCP key's name, an email) + exactly as the execution-context block renders it — what the skill gate + scans beside the raw value (trinity#3274, `skill_gate_service.requester_context_text`).""" + return _sanitize_field(value) + + @dataclass class ExecutionContext: """Per-invocation execution metadata injected into the agent system prompt. diff --git a/src/backend/services/skill_gate_errors.py b/src/backend/services/skill_gate_errors.py index 648a965d9..67f8de758 100644 --- a/src/backend/services/skill_gate_errors.py +++ b/src/backend/services/skill_gate_errors.py @@ -12,6 +12,13 @@ APPROVAL_PENDING_CODE = "approval_pending" +# How the requester will learn the outcome (trinity#3233): a platform task to a +# requesting agent, an Inbox notice to a person, or nothing at all. Decided by +# `skill_gate_service.outcome_delivery`, the one rule `_notify` also follows. +OUTCOME_DELIVERY_AGENT_TASK = "agent_task" +OUTCOME_DELIVERY_INBOX = "inbox" +OUTCOME_DELIVERY_NONE = "none" + class SkillGateError(Exception): """Base: the request names a gated skill and was not dispatched.""" @@ -30,12 +37,15 @@ class SkillApprovalRequired(SkillGateError): code = APPROVAL_PENDING_CODE def __init__(self, *, request_id: str, agent_name: str, skills: List[str], - approver_role: str, expires_at: Optional[str]): + approver_role: str, expires_at: Optional[str], + outcome_delivery: str = OUTCOME_DELIVERY_NONE): self.request_id = request_id self.agent_name = agent_name self.skills = list(skills) self.approver_role = approver_role self.expires_at = expires_at + # Defaults to "none": an answer that does not know never promises. + self.outcome_delivery = outcome_delivery super().__init__(self.message) @property @@ -46,9 +56,14 @@ def message(self) -> str: names = f"{noun} {', '.join(self.skills)}" # No role either: the requester needs to know it is waiting, not who # decides (`approver_role` stays in `detail()` for API callers). And no - # promise of a reply — not every requester has a channel back. - return (f"Not run: {names} on {self.agent_name} needs approval before it " + # promise of a reply — not every requester has a channel back; one that + # has none is told so, and where the outcome will show (trinity#3233). + text = (f"Not run: {names} on {self.agent_name} needs approval before it " f"can run. Request {self.request_id} is waiting for a decision.") + if self.outcome_delivery == OUTCOME_DELIVERY_NONE: + text += (" Nothing will be sent back when it is decided: if it is approved, " + f"it runs as a new execution on {self.agent_name}; if not, nothing runs.") + return text def detail(self) -> Dict[str, Any]: return { @@ -59,6 +74,7 @@ def detail(self) -> Dict[str, Any]: "skills": self.skills, "approver_role": self.approver_role, "expires_at": self.expires_at, + "outcome_delivery": self.outcome_delivery, "message": self.message, } diff --git a/src/backend/services/skill_gate_map_service.py b/src/backend/services/skill_gate_map_service.py new file mode 100644 index 000000000..28dadc94a --- /dev/null +++ b/src/backend/services/skill_gate_map_service.py @@ -0,0 +1,523 @@ +"""The per-agent skill gate map — every write to it (trinity-enterprise#753). + +The map says, per agent, which skills need approval before they run and which +kind of person approves (`primary` | `approver`). The dispatch check (#751) and +the in-container hook (#752) read it through `skill_gate_service.list_skill_gates`; +this module is the only thing that writes it. + +Two kinds of row, two lifetimes: + +* **Explicit** (`set`) — a person or an orchestrator set it. It stays until a + person or a `skills.manage` holder clears it, or a PERSON unassigns the + library skill it is on and the skill's package has left the agent. An agent + unassigning a skill — itself or a sibling — the system key, the start path + and the library sweep leave it in place (reported as `gates_kept`), so + unassign-then-reassign cannot launder a gate away. An own skill's gate is + never touched by an assignment path at all: own skills have no assignment + row (decision 1, sticky until cleared). +* **Library default** (`library_default`) — the library recommends approval + (`approval: recommended`) for a skill the agent is assigned. Driven from + STATE by `reconcile_library_gates`: applied BEFORE an assigned package is + delivered (agents that already held the skill included — the backfill), and + removed once the skill is unassigned AND its package is gone (a removal the + route saw complete, or the start path's prune) — never while a deferred + removal may leave the files on the agent. It never loosens because the + metadata changed. Clearing a default on an assigned recommended skill leaves + a `cleared` tombstone so the reconcile does not re-apply it; the tombstone + goes with the assignment. + +Every write keeps the marker ordering contract of #752 +(`/opt/trinity/skill-gates-active`, which makes the hook fail closed when the +platform cannot answer): written BEFORE an agent's first gate is inserted, +removed only AFTER its last gate is gone — under the same per-agent lock as +every other marker write in this worker. A stopped agent is never exec'd; its +next start syncs it. Ephemeral agents hold no gates (decision 2). + +Who may call: the routers decide (`routers/skill_gate.py`, `routers/skills.py`). +The service records who did it and from where (`GateContext`). +""" +# A package whose removal reported one of these has left the agent. +_GONE_STATUSES = frozenset({"removed", "not_present"}) +import asyncio +import logging +from dataclasses import dataclass +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Set, Tuple + +from database import db +from db.skill_gates import ORIGIN_CLEARED, ORIGIN_LIBRARY_DEFAULT, ORIGIN_SET +from services import role_addressing, skill_gate_service +from services.skill_gate_service import SkillGate # noqa: F401 — re-exported for callers +from services.skill_packaging import SKILL_NAME_RE + +logger = logging.getLogger(__name__) + +APPROVER_KINDS = ("primary", "approver") +MIN_DEADLINE_HOURS = 1 +MAX_DEADLINE_HOURS = 168 +_ABSENT = object() + + +class SkillGateMapRefused(Exception): + """A gate change refused by name. Nothing was written.""" + + def __init__(self, status_code: int, code: str, message: str): + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + + def as_detail(self) -> Dict[str, str]: + return {"code": self.code, "message": self.message} + + +@dataclass(frozen=True) +class GateContext: + """Who changed the map and from where — the audit's "who, from where" (AC 7). + + `via` comes from the principal (`via_for`): `ui` (a signed-in session), `api` + (a person's user-scoped key), `orchestrator` (an agent key), `system`. + `trigger` is what caused the write: `direct` (a gate route), `assignment`, + `start` or `library_sync`. + """ + + actor: Any = None + via: str = "system" + trigger: str = "direct" + ip: Optional[str] = None + endpoint: Optional[str] = None + request_id: Optional[str] = None + + +def via_for(principal) -> str: + """Where a change came from, read off the AUTHENTICATED principal only. A + principal with no scope attribute is never the JWT value.""" + scope = getattr(principal, "mcp_scope", _ABSENT) if principal is not None else _ABSENT + if scope is _ABSENT: + return "system" + return {None: "ui", "user": "api", "agent": "orchestrator"}.get(scope, str(scope)) + + +def gate_context(principal, *, trigger: str = "direct", ip: Optional[str] = None, + endpoint: Optional[str] = None, request_id: Optional[str] = None) -> GateContext: + return GateContext(actor=principal, via=via_for(principal), trigger=trigger, ip=ip, + endpoint=endpoint, request_id=request_id) + + +def approver_kinds() -> List[str]: + """The approver kinds this install can resolve. `approver` needs a + registered assignments provider; without one only the agent's primary + approves.""" + from services import assignment_provider + return list(APPROVER_KINDS) if assignment_provider.get_provider() is not None else ["primary"] + + +# --------------------------------------------------------------------------- +# Reads +# --------------------------------------------------------------------------- + +def _public(row: Mapping[str, Any]) -> Dict[str, Any]: + return {k: row.get(k) for k in ("skill_name", "approver", "deadline_hours", "origin", + "set_by", "set_by_agent", "set_at")} + + +def _reachable(agent_name: str, kind: str) -> bool: + """Does `kind` reach anyone on this agent right now? The default admin often + has no email, so `primary` can reach nobody — every gated request is then + refused `role_unassigned` (skill_gate_service._approvers).""" + try: + return bool(role_addressing.resolve(agent_name, kind).people) + except Exception: # noqa: BLE001 — RoleRefused or an unreadable lookup: nobody, as far as we can tell + return False + + +def list_gates(agent_name: str) -> Dict[str, Any]: + """The agent's gate map as the API shows it.""" + rows = db.list_agent_skill_gates(agent_name) + reach: Dict[str, bool] = {} + gates = [] + for row in rows: + if row["origin"] == ORIGIN_CLEARED: + continue + if row["approver"] not in reach: + reach[row["approver"]] = _reachable(agent_name, row["approver"]) + gates.append({**_public(row), "approver_reachable": reach[row["approver"]]}) + return { + "agent_name": agent_name, + "gates": gates, + "cleared_defaults": [r["skill_name"] for r in rows if r["origin"] == ORIGIN_CLEARED], + "approver_kinds": approver_kinds(), + "default_deadline_hours": skill_gate_service.DEFAULT_DEADLINE_HOURS, + } + + +def explicit_gate_names(agent_name: str, names: Iterable[str]) -> List[str]: + """Which of `names` carry an explicit (`set`) gate on the agent.""" + wanted = {(n or "").lower() for n in names} + return sorted(r["skill_name"] for r in db.list_agent_skill_gates(agent_name) + if r["origin"] == ORIGIN_SET and r["skill_name"] in wanted) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +def _validate_name(skill_name: str) -> str: + if not isinstance(skill_name, str) or not SKILL_NAME_RE.match(skill_name): + raise SkillGateMapRefused( + 422, "invalid_skill_name", + "A skill name is 1 to 64 letters, digits, dots, dashes or underscores, " + "starting with a letter or digit.") + return skill_name.lower() + + +def _validate_changes(changes: Mapping[str, Any]) -> Dict[str, Any]: + out: Dict[str, Any] = {} + if "approver" in changes: + approver = changes["approver"] + if approver is None: + approver = "primary" + if not isinstance(approver, str) or approver not in APPROVER_KINDS: + raise SkillGateMapRefused( + 422, "invalid_approver", "The approver is 'primary' or 'approver'.") + if approver not in approver_kinds(): + raise SkillGateMapRefused( + 422, "approver_unavailable", + "Only the agent's primary can approve on this install.") + out["approver"] = approver + if "deadline_hours" in changes: + hours = changes["deadline_hours"] + if hours is not None and not (type(hours) is int + and MIN_DEADLINE_HOURS <= hours <= MAX_DEADLINE_HOURS): + raise SkillGateMapRefused( + 422, "invalid_deadline", + f"The deadline is a whole number of hours from {MIN_DEADLINE_HOURS} to " + f"{MAX_DEADLINE_HOURS}, or empty for the {skill_gate_service.DEFAULT_DEADLINE_HOURS}-hour " + "default.") + out["deadline_hours"] = hours + return out + + +def _refuse_ghost(agent_name: str) -> None: + info = db.get_agent_ephemeral_info(agent_name) + if isinstance(info, dict) and info.get("is_ephemeral"): + raise SkillGateMapRefused( + 409, "ephemeral_agent", "Skill gates can't be set on an ephemeral agent.") + + +def _is_ghost(agent_name: str) -> bool: + try: + _refuse_ghost(agent_name) + except SkillGateMapRefused: + return True + return False + + +# --------------------------------------------------------------------------- +# The write discipline: marker ordering under one per-agent lock +# --------------------------------------------------------------------------- + +async def _container_running(agent_name: str) -> bool: + """False only when Docker answered that the container is not running; an + unreadable state is tried (each exec is bounded) rather than skipped.""" + from services import docker_utils + try: + state = await docker_utils.agent_container_state_async(agent_name) + except Exception: # noqa: BLE001 + state = None + return state not in ("stopped", "missing") + + +MARKER_SYNCED, MARKER_FAILED, MARKER_SKIPPED = "synced", "failed", "skipped" + + +def _has_live_gates(agent_name: str) -> bool: + return any(r["origin"] != ORIGIN_CLEARED for r in db.list_agent_skill_gates(agent_name)) + + +async def _write_under_marker(agent_name: str, mutate, *, may_add: bool) -> Tuple[Any, str]: + """Run `mutate` (a sync DB write returning `(result, changed)`) under the + agent's marker lock, keeping #752's ordering contract. Returns + `(result, marker)`: `synced` when the marker was re-synced from the map, + `failed` when that exec did not complete, `skipped` when nothing changed or + the agent is not running (its next start syncs it). + + Whether this write adds the agent's FIRST gate is decided under the lock, + from the map as it is then — never from a read taken before it, which a + clear of the last gate could invalidate in between. Never awaits + `sync_gate_marker` — the lock is not reentrant.""" + running = await _container_running(agent_name) + marker = MARKER_SKIPPED + async with skill_gate_service.marker_lock(agent_name): + if may_add and running and not await asyncio.to_thread(_has_live_gates, agent_name): + # Before the insert: a gate that takes effect with no marker is a + # window in which the hook fails OPEN while the platform is down. + await skill_gate_service.write_marker(agent_name, True) + result, changed = await asyncio.to_thread(mutate) + if changed and running: + synced = await skill_gate_service.sync_marker_locked(agent_name) + marker = MARKER_FAILED if synced is None else MARKER_SYNCED + return result, marker + + +def _who(ctx: GateContext) -> Tuple[str, Optional[str]]: + actor = ctx.actor + if actor is None: + return "system", None + scope = getattr(actor, "mcp_scope", None) + agent = None + if scope == "agent": + agent = getattr(actor, "agent_name", None) or None + elif scope == "system": + from db.agents import SYSTEM_AGENT_NAME + agent = SYSTEM_AGENT_NAME + return getattr(actor, "username", None) or "system", agent + + +async def _audit(ctx: GateContext, action: str, agent_name: str, details: Dict[str, Any]) -> None: + """Best-effort: the write has committed; an audit failure never fails it. + An agent actor is the actor, its owner rides as `actor_email` (R29).""" + try: + from services.platform_audit_service import AuditEventType, platform_audit_service + actor = ctx.actor + if actor is None: + who: Dict[str, Any] = {} + elif getattr(actor, "mcp_scope", None) == "agent" and getattr(actor, "agent_name", None): + who = {"actor_agent_name": actor.agent_name, + "actor_email": getattr(actor, "email", None), + "mcp_key_id": getattr(actor, "mcp_key_id", None), + "mcp_key_name": getattr(actor, "mcp_key_name", None), + "mcp_scope": "agent"} + else: + who = {"actor_user": actor} + await platform_audit_service.log( + event_type=AuditEventType.CONFIGURATION, + event_action=action, + source="api" if actor is not None else "system", + **who, + actor_ip=ctx.ip, + endpoint=ctx.endpoint, + request_id=ctx.request_id, + target_type="agent", + target_id=agent_name, + details={**details, "via": ctx.via, "trigger": ctx.trigger}, + ) + except Exception: # noqa: BLE001 + logger.warning("[SkillGateMap] audit of %s on %s failed", action, agent_name, exc_info=True) + + +# --------------------------------------------------------------------------- +# Set / clear (the gate routes and MCP tools) +# --------------------------------------------------------------------------- + +async def set_gate(agent_name: str, skill_name: str, *, changes: Mapping[str, Any], + ctx: GateContext) -> Dict[str, Any]: + """Gate `skill_name` on `agent_name`, or change its gate. `changes` holds + only the fields the caller sent: on an update an omitted field keeps its + stored value; an explicit null deadline resets to the default. Any + well-formed name may be gated — one the agent does not have gates nothing + until a skill answers to it (decision 6).""" + key = _validate_name(skill_name) + fields = _validate_changes(changes) + _refuse_ghost(agent_name) + set_by, set_by_agent = _who(ctx) + + def mutate(): + # The merge with what is stored happens in the DB write, under the + # lock: two partial PUTs never drop each other's field. + previous, current, changed = db.write_skill_gate( + agent_name, key, changes=fields, origin=ORIGIN_SET, + set_by=set_by, set_by_agent=set_by_agent) + return (previous, current, changed), changed + + (previous, current, changed), marker = await _write_under_marker(agent_name, mutate, may_add=True) + if current is None: # renamed or deleted after the access check + raise SkillGateMapRefused(404, "agent_not_found", "Agent not found") + if changed: + await _audit(ctx, "skill_gate_set", agent_name, { + "skill": key, "approver": current["approver"], "deadline_hours": current["deadline_hours"], + "previous": _public(previous) if previous else None, + }) + warnings = _warnings(agent_name, current["approver"], marker) + gate = {**_public(current), "approver_reachable": "approver_unassigned" not in warnings} + return {"agent_name": agent_name, "skill_name": key, "gate": gate, + "changed": changed, "warnings": warnings} + + +def _warnings(agent_name: str, approver: str, marker: Optional[str]) -> List[str]: + """What the caller should know about a gate that was saved: the marker on a + running agent could not be written (the hook fails open during an outage + until the check's heal re-syncs it), or the approver kind reaches nobody.""" + out = [] + if marker == MARKER_FAILED: + out.append("marker_not_written") + if not _reachable(agent_name, approver): + out.append("approver_unassigned") + return out + + +async def clear_gate(agent_name: str, skill_name: str, *, ctx: GateContext) -> Dict[str, Any]: + """Clear the gate on `skill_name`. Idempotent. On a library-assigned skill + the row becomes a `cleared` tombstone so the library default stays off.""" + key = _validate_name(skill_name) + set_by, set_by_agent = _who(ctx) + recommended = await asyncio.to_thread(_recommended) + + def mutate(): + previous, action = db.clear_skill_gate(agent_name, key, set_by=set_by, + set_by_agent=set_by_agent, recommended=recommended) + return (previous, action), action is not None + + (previous, action), _ = await _write_under_marker(agent_name, mutate, may_add=False) + if action is not None: + await _audit(ctx, "skill_gate_cleared", agent_name, { + "skill": key, "cleared": action, "previous": _public(previous)}) + return {"agent_name": agent_name, "skill_name": key, "changed": action is not None, + "cleared": action} + + +# --------------------------------------------------------------------------- +# The library-assignment paths +# --------------------------------------------------------------------------- + +def _recommended() -> Optional[set]: + """Lowercased names the library recommends gating; None when unreadable.""" + from services.skill_service import skill_service + try: + return {str(s.get("name")).lower() for s in skill_service.list_skills() + if s.get("approval") == "recommended" and s.get("name")} + except Exception: # noqa: BLE001 — retried by the next reconcile (start, sync, assignment) + logger.warning("[SkillGateMap] library unreadable; gate defaults deferred", exc_info=True) + return None + + +@dataclass(frozen=True) +class AllExcept: + """`drop_defaults` for a caller that saw EVERY package an unassigned skill + left behind go — the start path's prune — except the names in `keep`.""" + + keep: frozenset = frozenset() + + +def packages_gone(removal: Optional[Mapping[str, Any]]) -> Set[str]: + """The names a package removal report (`skill_service.remove_skills`, as a + route relays it) says have left the agent. A deferred or failed removal + names none — its files may still be there.""" + if not isinstance(removal, Mapping): + return set() + return {str(name).lower() for name, r in (removal.get("results") or {}).items() + if isinstance(r, Mapping) and r.get("status") in _GONE_STATUSES} + + +def drop_after_prune(prune: Optional[Mapping[str, Any]]) -> Optional[AllExcept]: + """What the start path's prune (`reconcile_agent_skills`) lets the reconcile + drop: every unassigned default when the agent's skills were inventoried — + except a name whose package the prune could not remove (or that is the + agent's own, unmanaged copy). Nothing when the prune did not run.""" + if not isinstance(prune, Mapping) or prune.get("status") not in ("clean", "reconciled"): + return None + keep = {str(name).lower() for name, r in (prune.get("results") or {}).items() + if not (isinstance(r, Mapping) and r.get("status") in _GONE_STATUSES)} + return AllExcept(frozenset(keep)) + + +def _may_drop(drop_defaults) -> Callable[[str], bool]: + if isinstance(drop_defaults, AllExcept): + return lambda name: name not in drop_defaults.keep + allowed = {str(n).lower() for n in (drop_defaults or ())} + return lambda name: name in allowed + + +async def reconcile_library_gates(agent_name: str, *, ctx: GateContext, add: bool = True, + drop_defaults=None) -> Optional[Dict[str, Any]]: + """Bring the agent's library-default gates in line with what it is assigned. + + `add` applies the default to every assigned recommended skill — call it + BEFORE a package is delivered. `drop_defaults` says which unassigned + skills' defaults may go: the names whose package removal completed (a + route), `AllExcept(...)` after the start path's prune, or None (keep them — + the files may still be on the agent). `cleared` tombstones of unassigned + skills always go. + + Returns None when nothing changed (and the library was readable), else + `{applied, removed, unreachable, library_unreadable}`. Never raises: an + assignment has already committed, and the next reconcile retries. + """ + try: + return await _reconcile(agent_name, ctx, add=add, drop_defaults=drop_defaults) + except Exception: # noqa: BLE001 + logger.warning("[SkillGateMap] gate reconcile failed for %s", agent_name, exc_info=True) + return {"applied": [], "removed": [], "unreachable": [], "library_unreadable": False, + "status": "unavailable"} + + +async def _reconcile(agent_name: str, ctx: GateContext, *, add: bool, drop_defaults) -> Optional[Dict[str, Any]]: + # Cheap pre-read: an agent with no assignments and no library rows costs + # two queries — no library read, no container state, no lock. It only + # decides whether there is work; the write re-decides under the lock. + may_drop = _may_drop(drop_defaults) + assigned = {n.lower() for n in db.get_agent_skill_names(agent_name)} + rows = {r["skill_name"]: r for r in db.list_agent_skill_gates(agent_name)} + stale = [n for n, r in rows.items() if n not in assigned and ( + r["origin"] == ORIGIN_CLEARED or (r["origin"] == ORIGIN_LIBRARY_DEFAULT and may_drop(n)))] + if not (add and assigned) and not stale: + return None + recommended: Optional[Set[str]] = set() + if add and assigned and not _is_ghost(agent_name): + recommended = await asyncio.to_thread(_recommended) + missing = sorted((assigned & recommended) - set(rows)) if recommended else [] + if not stale and not missing: + return None if recommended is not None else _report([], [], [], unreadable=True) + + set_by, _ = _who(ctx) + + def mutate(): + removed, inserted = db.reconcile_library_skill_gates( + agent_name, recommended, set_by=set_by, may_drop=may_drop) + return (removed, inserted), bool(removed or inserted) + + (removed, inserted), _ = await _write_under_marker(agent_name, mutate, may_add=bool(missing)) + applied = [r["skill_name"] for r in inserted] + gone = [r["skill_name"] for r in removed] + if applied: + await _audit(ctx, "skill_gate_default_applied", agent_name, {"skills": applied}) + if gone: + await _audit(ctx, "skill_gate_default_removed", agent_name, { + "skills": gone, "origins": {r["skill_name"]: r["origin"] for r in removed}}) + unreachable = applied if applied and not _reachable(agent_name, "primary") else [] + if not applied and not gone and recommended is not None: + return None + return _report(applied, gone, unreachable, unreadable=recommended is None) + + +def _report(applied, removed, unreachable, *, unreadable: bool) -> Dict[str, Any]: + return {"applied": list(applied), "removed": list(removed), "unreachable": list(unreachable), + "library_unreadable": unreadable} + + +async def drop_explicit_gates_on_unassign(agent_name: str, names: Iterable[str], *, + ctx: GateContext, keep: Iterable[str] = ()) -> List[str]: + """A PERSON unassigned `names` and their packages have left the agent: their + explicit gates go with them (AC 6). Only names still unassigned when the + write runs (a concurrent re-assign keeps its gate), and never one in `keep` + — a name whose library row was in `conflict`, where the agent's own skill of + that name is what runs and its gate is the own skill's (decision 1). The + caller decides it was a person and that the package is gone.""" + names = [n for n in names if n] + keep = list(keep) + # Nothing explicit to drop costs no lock, no container state, no exec. + if not names or not set(explicit_gate_names(agent_name, names)) - {k.lower() for k in keep}: + return [] + + def mutate(): + removed = db.drop_unassigned_skill_gates(agent_name, names, keep=keep) + return removed, bool(removed) + + removed, _ = await _write_under_marker(agent_name, mutate, may_add=False) + gone = [r["skill_name"] for r in removed] + if gone: + await _audit(ctx, "skill_gate_removed_with_skill", agent_name, { + "skills": gone, + "previous": {r["skill_name"]: {"approver": r["approver"], + "deadline_hours": r["deadline_hours"]} for r in removed}, + }) + return gone diff --git a/src/backend/services/skill_gate_service.py b/src/backend/services/skill_gate_service.py index b59fff9d0..9e12ab8e2 100644 --- a/src/backend/services/skill_gate_service.py +++ b/src/backend/services/skill_gate_service.py @@ -38,10 +38,14 @@ from config import PORTAL_SOURCE_CHANNEL from database import db +from db.skill_gates import ORIGIN_CLEARED from models import TaskExecutionStatus from services import rate_limiter, role_addressing from services.skill_gate_errors import ( # noqa: F401 — re-exported for entries and tests APPROVAL_PENDING_CODE, + OUTCOME_DELIVERY_AGENT_TASK, + OUTCOME_DELIVERY_INBOX, + OUTCOME_DELIVERY_NONE, SkillApprovalRequired, SkillGateError, SkillGateRefused, @@ -98,12 +102,17 @@ class SkillGate: def list_skill_gates(agent_name: str) -> Dict[str, SkillGate]: - """The gated skills on `agent_name`, keyed by skill name. + """The gated skills on `agent_name`, keyed by (lowercased) skill name. - ent#753 owns the storage and replaces this body with its read. Until it - lands nothing is gated, so #751 ships inert. + Reads the gate map (trinity-enterprise#753, `agent_skill_gates`); a + `cleared` tombstone gates nothing. A failed read RAISES — never `{}`, which + reads as "nothing is gated" — so `read_gates` refuses instead (#752 note 1). """ - return {} + return { + row["skill_name"]: SkillGate(approver=row["approver"], deadline_hours=row["deadline_hours"]) + for row in db.list_agent_skill_gates(agent_name) + if row["origin"] != ORIGIN_CLEARED + } @dataclass(frozen=True) @@ -162,6 +171,7 @@ async def enforce( dispatch: Optional[Mapping[str, Any]] = None, refuse_only: bool = False, gates: Optional[Mapping[str, SkillGate]] = None, + context_text: Optional[str] = None, ) -> GateDecision: """Decide whether a request to `agent_name` may be dispatched now. @@ -173,6 +183,13 @@ async def enforce( `refuse_only` entries (no row, no identity) refuse a gated request instead of raising an approval. `gates` is the map a caller already read (`read_gates`), so the check stays one read per dispatch. + + `context_text` (`requester_context_text`) is the free text the requester + controls that the executor's system prompt carries BESIDE the request — a + schedule's name, an MCP key's name, the requester's email (trinity#3274). + It is scanned on its own (never joined to the request, which could spell a + name neither part holds), shown on the card when it is what matched, and + never stored as the request or replayed. """ if triggered_by == SKILL_GATE_TRIGGER: return GateDecision() @@ -181,6 +198,8 @@ async def enforce( return GateDecision() names = find_gated_invocations(request_text, gates) + beside = [n for n in find_gated_invocations(context_text, gates) if n not in names] + names = names + beside if not names: return GateDecision() @@ -202,12 +221,16 @@ async def enforce( f"{_skill_list(names)} on {agent_name} needs approval, which " "can't be requested from here. Ask in chat instead.", skills=names) - clean_text = sanitize_text(request_text or "") - if _json_bytes(clean_text) > CARD_INPUT_MAX_BYTES: + clean_text = _card_text(request_text, dispatch) + # The context lines go on the card only when a skill was named nowhere else + # — otherwise the approver would read a request that names no gated skill. + card_context = _card_context(context_text) if beside else "" + card_bytes = _json_bytes(clean_text + card_context) + if card_bytes > CARD_INPUT_MAX_BYTES: raise SkillGateRefused( 422, "request_too_long", f"This request is too long for an approver to read in full " - f"({_json_bytes(clean_text)} > {CARD_INPUT_MAX_BYTES} bytes). Shorten it and ask again.", + f"({card_bytes} > {CARD_INPUT_MAX_BYTES} bytes). Shorten it and ask again.", limit=CARD_INPUT_MAX_BYTES) request_id = _request_id(agent_name, requester.key, occurrence_key) @@ -241,7 +264,7 @@ async def enforce( raise _answer_for_record(record, role) ask = _ask_body(agent_name, request_id, names, clean_text, requester, fingerprints, - role, expires_at) + role, expires_at, card_context=card_context) from services.ask_service import AskRejected try: receipt = _raise_ask(agent_name, ask) @@ -258,7 +281,10 @@ async def enforce( "The approval could not be raised; nothing was run. Try again shortly.") from e db.attach_gate_ask(request_id, receipt["id"]) raise SkillApprovalRequired(request_id=request_id, agent_name=agent_name, skills=names, - approver_role=role, expires_at=receipt.get("expires_at")) + approver_role=role, expires_at=receipt.get("expires_at"), + outcome_delivery=outcome_delivery( + requester.kind, agent_name=requester.agent_name, + email=requester.email)) # --------------------------------------------------------------------------- @@ -311,6 +337,41 @@ def _json_bytes(text: str) -> int: return len(json.dumps(text).encode("utf-8")) +# On the card, the caller's own system prompt is named as such: it reaches the +# executor with system authority, not as the request's text. +SYSTEM_PROMPT_LABEL = "With these instructions as its system prompt:" + + +def _card_text(request_text: Optional[str], dispatch: Optional[Mapping[str, Any]]) -> str: + """What the card shows, and so what Approve runs (trinity#3274). + + A request frozen with replay parts (`/task`, fan-out — `frozen_replay`) + shows exactly those parts, each already sanitised on its own. Sanitising the + JOIN again can redact across the line between them (a credential pattern + that ends one part and starts the next) and hide from the approver a part + the run still sends. Every other request shows its sanitised request text, + which is also what its approved run sends.""" + replay = dispatch or {} + if REPLAY_MESSAGE not in replay: + return sanitize_text(request_text or "") + text = replay[REPLAY_MESSAGE] + if replay.get(REPLAY_SYSTEM_PROMPT): + text += f"\n\n{SYSTEM_PROMPT_LABEL}\n{replay[REPLAY_SYSTEM_PROMPT]}" + return text + + +CARD_CONTEXT_LINE_CHARS = 200 + + +def _card_context(context_text: Optional[str]) -> str: + """`requester_context_text` as the card shows it: sanitised, one bounded + line per value (the scan reads the values whole).""" + lines = [line if len(line) <= CARD_CONTEXT_LINE_CHARS + else line[:CARD_CONTEXT_LINE_CHARS] + "…" + for line in sanitize_text(context_text or "").splitlines() if line.strip()] + return "\n".join(lines) + + def _request_id(agent_name: str, requester_key: str, occurrence_key: Optional[str]) -> str: """One id per occurrence of one requester's request. The requester is part of it so another caller re-using the same Idempotency-Key on this agent @@ -369,19 +430,22 @@ async def _fingerprints_or_refuse(agent_name: str, names: List[str]) -> Dict[str return out -def _ask_body(agent_name, request_id, names, clean_text, requester, fingerprints, role, expires_at): +def _ask_body(agent_name, request_id, names, clean_text, requester, fingerprints, role, expires_at, + card_context: str = ""): """The card: the WHOLE request in `proposal.input` (what Approve runs), a - short preview in the question.""" + short preview in the question, and — when a skill was named only there — + the context the agent also receives (`requester_context_text`).""" skills = _skill_list(names) who = requester.display() preview = clean_text if len(clean_text) <= QUESTION_PREVIEW_CHARS \ else clean_text[:QUESTION_PREVIEW_CHARS] + "… (the full request is shown below)" + beside = f"Also sent to {agent_name} with this request:\n{card_context}\n\n" if card_context else "" return { "request_id": request_id, "type": "approval", "priority": "high", "title": f"Approve {skills} on {agent_name}"[:300], - "question": (f"{who} asked {agent_name} to run {skills}.\n\nRequest:\n{preview}\n\n" + "question": (f"{who} asked {agent_name} to run {skills}.\n\nRequest:\n{preview}\n\n{beside}" f"{APPROVE} runs it once, exactly as written. {REJECT} runs nothing."), "options": list(OPTIONS), "to": role, @@ -431,7 +495,10 @@ def _pending_from_record(record: Dict[str, Any], role: str) -> SkillApprovalRequ expires_at = (row or {}).get("expires_at") return SkillApprovalRequired(request_id=record["request_id"], agent_name=record["agent_name"], skills=record.get("skills") or [], approver_role=role, - expires_at=expires_at) + expires_at=expires_at, + outcome_delivery=outcome_delivery( + record.get("requester_kind"), agent_name=record.get("source_agent"), + email=record.get("requester_email"))) # --------------------------------------------------------------------------- @@ -603,6 +670,33 @@ def requester_from_principal(current_user, *, source_agent: Optional[str] = None return Requester(kind=KIND_OTHER, key=f"principal:{key_id or username}", mcp_key_id=key_id) +def requester_context_text(*, schedule_name: Optional[str] = None, + mcp_key_name: Optional[str] = None, + source_email: Optional[str] = None) -> str: + """The free text a requester sets that the executor's system prompt carries + beside the request — the schedule line and the `mcp key` / `user` parts of + the Triggered-by line (`platform_prompt_service`) — for + `enforce(context_text=...)` (trinity#3274). Every other `ExecutionContext` + field is platform-controlled (a parity test holds that list). + + Each value as given AND as the prompt renders it: the renderer collapses + `---` and cuts at 80 characters with `…` (which matching reads as `...`), + and either can make a name appear that the value alone does not hold.""" + from services.platform_prompt_service import render_context_field + + lines = [] + for label, value in (("Schedule name", schedule_name), ("MCP key name", mcp_key_name), + ("Requester email", source_email)): + if not value: + continue + raw = " ".join(str(value).split()) + lines.append(f"{label}: {raw}") + rendered = render_context_field(value) + if rendered and rendered != raw: + lines.append(f"{label}, as the agent reads it: {rendered}") + return "\n".join(lines) + + def requester_for_dispatch(*, triggered_by: str, source_user_email: Optional[str] = None, source_agent_name: Optional[str] = None, source_mcp_key_id: Optional[str] = None, @@ -934,28 +1028,47 @@ async def sync_gate_marker(agent_name: str) -> Optional[bool]: worker — the map is read under the lock — so a slow sync that read an older map cannot land after a newer one. """ + async with marker_lock(agent_name): + return await sync_marker_locked(agent_name) + + +async def sync_marker_locked(agent_name: str) -> Optional[bool]: + """`sync_gate_marker`'s body, for a caller that already holds + `marker_lock(agent_name)` — the gate-map writes (trinity-enterprise#753), + which must write the row and re-sync under ONE hold. The lock is not + reentrant: never call `sync_gate_marker` from inside it.""" + try: + gates = read_gates(agent_name) + except SkillGateRefused: + return None + want = bool(gates) + return want if await write_marker(agent_name, want) else None + + +async def write_marker(agent_name: str, create: bool) -> bool: + """One exec that writes (`create`) or removes the marker. True when it + completed; a failure is logged, never raised.""" + from services.docker_service import execute_command_in_container + try: + result = await execute_command_in_container( + container_name=f"agent-{agent_name}", command=marker_command(create), + timeout=MARKER_TIMEOUT_SECONDS, user="root") + except Exception: # noqa: BLE001 + logger.warning("[SkillGate] gate marker sync on %s failed", agent_name, exc_info=True) + return False + if result.get("timed_out") or result.get("exit_code") != 0: + logger.warning("[SkillGate] gate marker sync on %s did not complete (exit %s, timed out %s)", + agent_name, result.get("exit_code"), bool(result.get("timed_out"))) + return False + return True + + +def marker_lock(agent_name: str): + """The per-agent lock every marker write holds, in this worker.""" import asyncio loop_key = (id(asyncio.get_running_loop()), agent_name) - async with _marker_locks.setdefault(loop_key, asyncio.Lock()): - try: - gates = read_gates(agent_name) - except SkillGateRefused: - return None - want = bool(gates) - from services.docker_service import execute_command_in_container - try: - result = await execute_command_in_container( - container_name=f"agent-{agent_name}", command=marker_command(want), - timeout=MARKER_TIMEOUT_SECONDS, user="root") - except Exception: # noqa: BLE001 - logger.warning("[SkillGate] gate marker sync on %s failed", agent_name, exc_info=True) - return None - if result.get("timed_out") or result.get("exit_code") != 0: - logger.warning("[SkillGate] gate marker sync on %s did not complete (exit %s, timed out %s)", - agent_name, result.get("exit_code"), bool(result.get("timed_out"))) - return None - return want + return _marker_locks.setdefault(loop_key, asyncio.Lock()) # One lock per (event loop, agent): a worker has one loop; the loop id keeps a @@ -1035,6 +1148,8 @@ def spawn_gate_marker_sync(agent_name: str, *, only_if_gated: bool = True) -> bo "request_text": RESET, "gate_checked": RESET, "gate_requester": DROP, + # Read by the backstop into the record (`frozen_replay`), never re-frozen. + "gate_replay": DROP, } @@ -1047,6 +1162,25 @@ def frozen_dispatch(**fields: Any) -> Dict[str, Any]: return {k: v for k, v in fields.items() if DISPATCH_FIELDS[k] == FREEZE and v is not None} +# What an approved run SENDS, when that is not simply the scanned request text +# (trinity#3274). `/task` and fan-out scan the caller's message and system prompt +# together — both reach the executor — but the approved run gets each where the +# requester put it, never their concatenation (which repeated the command and +# turned a system prompt into user text). Stored in the record's `dispatch` +# beside the frozen fields; a record without them replays `request_text`. +REPLAY_MESSAGE = "replay_message" +REPLAY_SYSTEM_PROMPT = "replay_system_prompt" + + +def frozen_replay(message: Optional[str], system_prompt: Optional[str] = None) -> Dict[str, str]: + """The replay keys for one request, each sanitised the way the card's text + is (the approver reads the sanitised join), a missing system prompt left out.""" + out = {REPLAY_MESSAGE: sanitize_text(message or "")} + if system_prompt: + out[REPLAY_SYSTEM_PROMPT] = sanitize_text(system_prompt) + return out + + # --------------------------------------------------------------------------- # Ending → dispatch: the approval runs the frozen request exactly once # --------------------------------------------------------------------------- @@ -1233,15 +1367,21 @@ async def _dispatch_approved(record: Dict[str, Any], execution_id: str) -> str: agent = record["agent_name"] frozen = dict(record.get("dispatch") or {}) triggered_by = frozen.get("triggered_by") or record.get("triggered_by") or "manual" + # The message and the caller's system prompt where the requester put them + # (`frozen_replay`); a record frozen without them replays its request text. + if REPLAY_MESSAGE in frozen: + message = frozen[REPLAY_MESSAGE] + system_prompt = frozen.get(REPLAY_SYSTEM_PROMPT) + else: + message, system_prompt = record["request_text"], None # A request from a public-facing surface keeps its caller prompt (the # owner's public-channel instructions, #1205) — re-derived now rather than # frozen, so no per-person memory is stored on the record. - system_prompt = None - if triggered_by in _PUBLIC_FACING_TRIGGERS: + if system_prompt is None and triggered_by in _PUBLIC_FACING_TRIGGERS: from services.platform_prompt_service import build_public_channel_caller_prompt system_prompt = build_public_channel_caller_prompt(agent) request = ParallelTaskRequest( - message=record["request_text"], + message=message, model=frozen.get("model"), allowed_tools=frozen.get("allowed_tools"), system_prompt=system_prompt, @@ -1250,7 +1390,7 @@ async def _dispatch_approved(record: Dict[str, Any], execution_id: str) -> str: ) row = db.create_task_execution( agent_name=agent, - message=record["request_text"], + message=message, triggered_by=triggered_by, fields=TaskExecutionFields( source_user_id=frozen.get("source_user_id"), @@ -1278,7 +1418,7 @@ async def _dispatch_approved(record: Dict[str, Any], execution_id: str) -> str: agent_name=agent, execution_id=execution_id, max_concurrent=db.get_max_parallel_tasks(agent), - message_preview=record["request_text"][:100], + message_preview=message[:100], timeout_seconds=timeout, overflow_policy="queue_persistent", breaker_enabled=dispatch_breaker_active(agent), @@ -1370,23 +1510,66 @@ def outcome_text(record: Dict[str, Any], outcome: str, *, decider: Optional[str] _SELF_EVIDENT_OUTCOMES = frozenset({"approved", "denied", "cancelled"}) +def outcome_delivery(kind: Optional[str], *, agent_name: Optional[str] = None, + email: Optional[str] = None) -> str: + """How `_notify` tells a requester the outcome — the ONE rule, read by + `_notify` and by the pending answer, so the answer never promises what is + not sent (trinity#3233): a platform task to a requesting agent, an Inbox + notice to a person. Schedules, channels, public visitors, paid callers, + connectors and system keys get nothing after the request — their record and + the executor's Executions carry it.""" + if kind == KIND_AGENT and agent_name: + return OUTCOME_DELIVERY_AGENT_TASK + if kind == KIND_PERSON and (email or "").strip(): + return OUTCOME_DELIVERY_INBOX + return OUTCOME_DELIVERY_NONE + + +# Who decided, as a requester reads it whenever no display name may be shown. +APPROVER_LABEL = "the agent's approver" +DECIDER_NAME_MAX_CHARS = 80 + + +def _decider_label(record: Dict[str, Any], decider: Optional[str]) -> Optional[str]: + """Who decided, for the requester's notice — never an email (trinity#3274). + + The decider's display name only for a request a platform session or key + made (`source_user_id` frozen: only the `/chat` and `/task` seams and the + platform's own producers set it, never the Workspace, the inline connector + or a channel) and only when the name is usable; otherwise, and on any + lookup failure, "the agent's approver".""" + if not decider: + return None + frozen = record.get("dispatch") or {} + if frozen.get("source_user_id") is None or frozen.get("source_channel") == PORTAL_SOURCE_CHANNEL: + return APPROVER_LABEL + try: + user = db.get_user_by_email(decider) + except Exception: # noqa: BLE001 — a label, never a reason to fail the notice + return APPROVER_LABEL + name = " ".join(str((user or {}).get("name") or "").split())[:DECIDER_NAME_MAX_CHARS] + return name if name and "@" not in name else APPROVER_LABEL + + async def _notify(record: Dict[str, Any], outcome: str, *, decider: Optional[str] = None, execution_id: Optional[str] = None, detail: Optional[str] = None, notify_decider: bool = False) -> None: - """Agent requester → a platform task to that agent; person requester → an - Inbox notice addressed to them. Schedules, channels, public visitors, paid - callers and connectors have no channel after the request — their record - and the executor's Executions carry it. Once per record.""" + """Tell the requester, as `outcome_delivery` says: an agent by a platform + task, a person by an Inbox notice addressed to them, anyone else not at all. + Once per record. A person's notice names the decider by `_decider_label`, + never by email.""" if not db.mark_gate_request_notified(record["request_id"]): return await _audit_outcome(record, outcome, decider=decider, execution_id=execution_id) - text = outcome_text(record, outcome, decider=decider, execution_id=execution_id, - detail=detail, for_person=True) - kind = record.get("requester_kind") - asker = (record.get("requester_email") or "").strip().casefold() if kind == KIND_PERSON else "" + text = outcome_text(record, outcome, decider=_decider_label(record, decider), + execution_id=execution_id, detail=detail, for_person=True) + delivery = outcome_delivery(record.get("requester_kind"), agent_name=record.get("source_agent"), + email=record.get("requester_email")) + asker = (record.get("requester_email") or "").strip().casefold() \ + if delivery == OUTCOME_DELIVERY_INBOX else "" decided_own = bool(asker) and asker == (decider or "").strip().casefold() try: - if kind == KIND_AGENT and record.get("source_agent"): + if delivery == OUTCOME_DELIVERY_AGENT_TASK: # #715: an agent learns the outcome, never which person decided it. await _wake_requester_agent(record, outcome_text( record, outcome, execution_id=execution_id, detail=detail)) diff --git a/src/backend/services/skill_packaging.py b/src/backend/services/skill_packaging.py index d803d0466..4e29e40ea 100644 --- a/src/backend/services/skill_packaging.py +++ b/src/backend/services/skill_packaging.py @@ -37,6 +37,10 @@ # traversal-shaped name reaching path math or an exec argument. SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +# trinity-enterprise#753: the values `approval:` may take. `recommended` gates a +# library skill by default where it is assigned. +APPROVAL_VALUES = frozenset({"recommended"}) + # Dep names are interpolation-gated BEFORE any probe runs: a name failing these # is reported as frontmatter_invalid and never reaches the agent container. BINARY_NAME_RE = re.compile(r"^[A-Za-z0-9._+-]{1,64}$") @@ -179,7 +183,7 @@ def extract_contract(frontmatter: Optional[Dict[str, Any]]) -> Tuple[Dict[str, A Returns ``(contract, warnings)`` where contract is:: {description, automation, user_invocable, allowed_tools, - requires: {packages, binaries, env}, deprecated, superseded_by} + requires: {packages, binaries, env}, deprecated, superseded_by, approval} ``deprecated`` / ``superseded-by`` (trinity-enterprise#672) are the two lifecycle keys a library uses to retire a skill. ``superseded_by`` is the @@ -246,6 +250,17 @@ def extract_contract(frontmatter: Optional[Dict[str, Any]]) -> Tuple[Dict[str, A elif superseded_raw is not None: warnings.append("frontmatter_invalid:superseded-by") + # trinity-enterprise#753: `approval: recommended` gates the skill by default + # on every agent it is assigned to (the owner can clear it). A closed set; a + # non-string (YAML hands `yes` as a bool, an unquoted date as a date) or an + # unknown word is named, read as no recommendation, and never raises. + approval_raw = _first_present(sources, ("approval",)) + approval = None + if isinstance(approval_raw, str) and approval_raw.strip().lower() in APPROVAL_VALUES: + approval = approval_raw.strip().lower() + elif approval_raw is not None: + warnings.append("frontmatter_invalid:approval") + contract = { "description": description, "automation": automation, @@ -254,6 +269,7 @@ def extract_contract(frontmatter: Optional[Dict[str, Any]]) -> Tuple[Dict[str, A "requires": requires, "deprecated": deprecated, "superseded_by": superseded_by, + "approval": approval, } return contract, warnings diff --git a/src/backend/services/skill_service.py b/src/backend/services/skill_service.py index 91ff2e761..47f0aee99 100644 --- a/src/backend/services/skill_service.py +++ b/src/backend/services/skill_service.py @@ -1143,6 +1143,8 @@ def _parse_skill_info( # #672 lifecycle: derived on every parse, never persisted. "deprecated": False, "superseded_by": None, + # trinity-enterprise#753: `recommended` gates it by default where assigned. + "approval": None, } try: @@ -1160,6 +1162,7 @@ def _parse_skill_info( "contract_warnings": warnings, "deprecated": contract["deprecated"], "superseded_by": contract["superseded_by"], + "approval": contract["approval"], }) # Fallback: first non-header paragraph diff --git a/src/backend/services/skills_sync_service.py b/src/backend/services/skills_sync_service.py index 8c73864e9..846cb74a9 100644 --- a/src/backend/services/skills_sync_service.py +++ b/src/backend/services/skills_sync_service.py @@ -326,14 +326,27 @@ async def _reinject_agent(agent_name: str, sets_lib: Optional[dict] = None) -> D if sets_lib is not None: from services import skill_set_service _, dropped = await asyncio.to_thread(skill_set_service.reconcile_agent, agent_name, sets_lib) + # trinity-enterprise#753: a library change can newly recommend + # approval for a skill this agent holds — gated before the re-inject; + # defaults of members the sync dropped go after the prune removed + # their packages (never raises). + from services import skill_gate_map_service + gate_ctx = skill_gate_map_service.GateContext(trigger="library_sync") + await skill_gate_map_service.reconcile_library_gates(agent_name, ctx=gate_ctx) skill_names = await asyncio.to_thread(db.get_agent_skill_names, agent_name) if not skill_names: if dropped: - await skill_service.reconcile_agent_skills(agent_name, []) + prune = await skill_service.reconcile_agent_skills(agent_name, []) + await skill_gate_map_service.reconcile_library_gates( + agent_name, ctx=gate_ctx, add=False, + drop_defaults=skill_gate_map_service.drop_after_prune(prune)) return {"status": "skipped", "reason": "no_skills"} result = await skill_service.inject_skills(agent_name, skill_names, force=False) if dropped: - await skill_service.reconcile_agent_skills(agent_name, skill_names) + prune = await skill_service.reconcile_agent_skills(agent_name, skill_names) + await skill_gate_map_service.reconcile_library_gates( + agent_name, ctx=gate_ctx, add=False, + drop_defaults=skill_gate_map_service.drop_after_prune(prune)) # #2703: the sweep changes the listing — open surfaces refetch. from services.skill_service import broadcast_skills_changed await broadcast_skills_changed(agent_name) diff --git a/src/backend/services/task_execution_service.py b/src/backend/services/task_execution_service.py index 45b025658..16c2b6270 100644 --- a/src/backend/services/task_execution_service.py +++ b/src/backend/services/task_execution_service.py @@ -1475,6 +1475,10 @@ async def execute_task( # trinity-enterprise#751: True only when an admission seam already ran the # skill gate for this request (`/chat`, `/task` and what they reach). gate_checked: bool = False, + # trinity#3274: what an approved run sends when the producer scans more + # than the message (fan-out: the subtask's message and the batch's + # system prompt, apart) — `skill_gate_service.frozen_replay`. + gate_replay: Optional[dict] = None, # trinity-enterprise#751: the person the producer's entry AUTHENTICATED # (the Workspace's `PortalPrincipal`). The only way the backstop lets an # approver run their own gated request; None ⇒ the requester is derived @@ -1633,6 +1637,7 @@ async def execute_task( gate_requester=gate_requester, schedule_context=schedule_context, loop_id=loop_id, + gate_replay=gate_replay, fields=dict( source_user_id=source_user_id, source_user_email=source_user_email, @@ -1895,7 +1900,8 @@ async def execute_task( async def _skill_gate_backstop(self, *, agent_name, message, request_text, triggered_by, execution_id, fields, gate_requester=None, - schedule_context=None, loop_id=None) -> None: + schedule_context=None, loop_id=None, + gate_replay=None) -> None: """Run the skill gate for a producer that calls `execute_task` directly. Skipped for the approved run itself — a row the gate's own record names @@ -1933,11 +1939,17 @@ async def _skill_gate_backstop(self, *, agent_name, message, request_text, trigg decision = await skill_gate_service.enforce( agent_name, request_text=message if request_text is None else request_text, + # trinity#3274: the schedule's name, the key's name and the email + # reach the executor's system prompt beside the request. + context_text=skill_gate_service.requester_context_text( + schedule_name=(schedule_context or {}).get("name"), + mcp_key_name=fields["source_mcp_key_name"], + source_email=fields["source_user_email"]), requester=requester, triggered_by=triggered_by, occurrence_key=execution_id, origin_execution_id=execution_id, - dispatch=frozen, + dispatch={**frozen, **(gate_replay or {})}, gates=gates, ) # Only reachable with a producer-supplied, proven `gate_requester`. @@ -1952,7 +1964,9 @@ async def _skill_gate_backstop(self, *, agent_name, message, request_text, trigg except SkillGateError as exc: if execution_id: if isinstance(exc, SkillApprovalRequired): - error = f"Awaiting approval ({exc.request_id}): {exc.message}" + # The notice itself — it names the request id, and it is the + # text a public-link visitor reads off this row (#3274). + error = exc.message else: error = f"Refused by the skill gate ({exc.code}): {exc}" await _write_terminal_and_gate( diff --git a/src/backend/utils/skill_invocation.py b/src/backend/utils/skill_invocation.py index 97da14d2b..aeb3148b1 100644 --- a/src/backend/utils/skill_invocation.py +++ b/src/backend/utils/skill_invocation.py @@ -19,21 +19,44 @@ import unicodedata from typing import Iterable, List, Optional -# Zero-width and bidirectional-control characters. A model ignores them; a -# regex does not. Stripped before matching so a zero-width space (U+200B) after -# the slash or a bidi override in front of it cannot hide an invocation. -_INVISIBLE = re.compile("[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]") +# Unicode's Default_Ignorable_Code_Point set (soft hyphen, joiners, bidi and +# Mongolian controls, variation selectors, tag characters, fillers …). A model +# ignores them; a regex does not. The text is matched twice — with them removed +# (a run before a slash read as a space), and with each replaced by a space — +# so one cannot hide an invocation either way: removed, `/\u00adx` is `/x` and +# `/pay\u200d-x` is `/pay-x`; spaced, `/x\u00adyz` still ends `x` (trinity#3274). +_INVISIBLE_CHAR = ( + "(?:[\u00ad\u034f\u061c\u115f\u1160\u17b4\u17b5\u180b-\u180f\u200b-\u200f" + "\u202a-\u202e\u2060-\u206f\u3164\ufe00-\ufe0f\ufeff\uffa0\ufff0-\ufff8]" + # Each range beyond the Basic Multilingual Plane in a class of its own: + # CodeQL reads such a range as `\ufffd-\ufffd`, so several in one class look + # like overlapping ranges (py/overly-large-range). Same characters either way. + "|[\U0001bca0-\U0001bca3]|[\U0001d173-\U0001d17a]|[\U000e0000-\U000e0fff])") +_INVISIBLE = re.compile(_INVISIBLE_CHAR) +# A run of them right before a slash: removed, it could join a letter to the +# slash and turn `run\u00ad/x` into the path `run/x`, so it reads as a space. +# Matched only from the start of a run (the lookbehind): otherwise every offset +# inside a long run with no slash after it rescans the rest — quadratic time. +_INVISIBLE_BEFORE_SLASH = re.compile("(? str: - return _INVISIBLE.sub("", unicodedata.normalize("NFKC", text)).casefold() +def _normalise(text: str, invisible: str = "") -> str: + text = unicodedata.normalize("NFKC", text) + if not invisible: + text = _INVISIBLE_BEFORE_SLASH.sub(" ", text) + return _INVISIBLE.sub(invisible, text).casefold() def find_gated_invocations(text: Optional[str], gated_names: Iterable[str]) -> List[str]: @@ -45,10 +68,11 @@ def find_gated_invocations(text: Optional[str], gated_names: Iterable[str]) -> L names = list(gated_names or ()) if not text or not names: return [] - haystack = _normalise(text) + haystacks = [_normalise(text, ""), _normalise(text, " ")] found = [] for name in names: - match = re.search(_BEFORE + re.escape(_normalise(name)) + _AFTER, haystack) - if match: - found.append((match.start(), name)) + pattern = _BEFORE + re.escape(_normalise(name)) + _AFTER + starts = [m.start() for m in (re.search(pattern, h) for h in haystacks) if m] + if starts: + found.append((min(starts), name)) return [name for _, name in sorted(found)] diff --git a/src/frontend/src/components/ChatPanel.vue b/src/frontend/src/components/ChatPanel.vue index f8ce7c7aa..2a2c8e562 100644 --- a/src/frontend/src/components/ChatPanel.vue +++ b/src/frontend/src/components/ChatPanel.vue @@ -170,6 +170,8 @@ import { useSkillsStore } from '../stores/skills' import { ChatMessages, ChatInput, ChatEmptyState } from './chat' import { shouldCancelOnEscape, restoreDraft, cancelOutcome, isNoopCancel } from '../utils/turnCancel' import ModelSelector from './ModelSelector.vue' +import { apiErrorMessage } from '../utils/apiError' +import { isGateRefusal, pendingApprovalMessage } from '../utils/skillGate' import { getStatusFromStreamEvent, MIN_LABEL_DISPLAY_MS, HEARTBEAT_TIMEOUT_MS } from '../utils/execution-status' const props = defineProps({ @@ -412,8 +414,11 @@ const buildContextPrompt = (userMessage) => { return userMessage } - // Build conversation context (last 10 exchanges) - const recentMessages = messages.value.slice(-20) // Last 20 messages (10 exchanges) + // Build conversation context (last 10 exchanges). A held request and its + // notice stay on screen but out of the context (trinity#3274): resent, the + // gated command would hold every later turn — and ride along if one of + // those were approved. + const recentMessages = messages.value.filter(m => !m.held).slice(-20) // Last 20 messages (10 exchanges) let context = '### Previous conversation:\n\n' for (const msg of recentMessages) { @@ -586,6 +591,7 @@ const sendMessage = async (userMessage, files = []) => { content: userMessage, timestamp: new Date().toISOString() }) + const userEntry = messages.value[messages.value.length - 1] // Clear input message.value = '' @@ -623,6 +629,15 @@ const sendMessage = async (userMessage, files = []) => { headers: authStore.authHeader }) + // trinity#3274: the request names a gated skill — nothing ran and there is + // nothing to poll. The server's own message is the answer. + const held = pendingApprovalMessage(submitResponse) + if (held) { + userEntry.held = true + messages.value.push({ role: 'system', content: held, held: true, timestamp: new Date().toISOString() }) + return + } + const executionId = submitResponse.data.execution_id if (!executionId) { throw new Error('No execution_id returned from async task submission') @@ -672,7 +687,9 @@ const sendMessage = async (userMessage, files = []) => { } catch (err) { console.error('Chat error:', err) closeSSE() - error.value = err.response?.data?.detail || 'Failed to send message. Please try again.' + error.value = isGateRefusal(err) + ? apiErrorMessage(err) + : (err.response?.data?.detail || 'Failed to send message. Please try again.') // Remove the user message if send failed messages.value.pop() } finally { diff --git a/src/frontend/src/components/DashboardPanel.vue b/src/frontend/src/components/DashboardPanel.vue index 000ed03a7..a3d1162d9 100644 --- a/src/frontend/src/components/DashboardPanel.vue +++ b/src/frontend/src/components/DashboardPanel.vue @@ -452,6 +452,8 @@ import ScanlineReveal from './ScanlineReveal.vue' import LoadFailed from './LoadFailed.vue' import InlineError from './InlineError.vue' import { viewState, staleBannerMessage } from '../utils/loadingState' +import { apiErrorMessage } from '../utils/apiError' +import { isGateRefusal, pendingApprovalMessage, PENDING_NOTICE_TOAST } from '../utils/skillGate' const props = defineProps({ agentName: { @@ -469,7 +471,9 @@ const props = defineProps({ hasDeclaredMetrics: { type: Boolean, default: false - } + }, + // AgentDetail's toast host (`useNotification`), as A2aPanel takes it. + notify: { type: Function, default: null } }) const agentsStore = useAgentsStore() @@ -550,11 +554,18 @@ const checkUpdateDashboardPlaybook = async () => { const triggerUpdateDashboard = async () => { updatingDashboard.value = true try { - await axios.post(`/api/agents/${props.agentName}/task`, { + const response = await axios.post(`/api/agents/${props.agentName}/task`, { message: '/update-dashboard' }, { headers: authStore.authHeader }) + // trinity#3274: a gated playbook — nothing ran, so there is nothing to wait for. + const held = pendingApprovalMessage(response) + if (held) { + if (props.notify) props.notify(held, 'info', PENDING_NOTICE_TOAST) + updatingDashboard.value = false + return + } // Wait a bit then refresh the dashboard to show updated data setTimeout(() => { loadDashboard() @@ -562,6 +573,7 @@ const triggerUpdateDashboard = async () => { }, 5000) } catch (error) { console.error('Failed to trigger update-dashboard:', error) + if (isGateRefusal(error) && props.notify) props.notify(apiErrorMessage(error), 'error') updatingDashboard.value = false } } diff --git a/src/frontend/src/components/PlaybooksPanel.vue b/src/frontend/src/components/PlaybooksPanel.vue index f7c650b66..929d4c9c7 100644 --- a/src/frontend/src/components/PlaybooksPanel.vue +++ b/src/frontend/src/components/PlaybooksPanel.vue @@ -173,6 +173,8 @@ import { ref, computed, onMounted, watch } from 'vue' import axios from 'axios' import { useAuthStore } from '../stores/auth' import { useSkillsStore } from '../stores/skills' +import { apiErrorMessage } from '../utils/apiError' +import { isGateRefusal, pendingApprovalMessage, PENDING_NOTICE_TOAST } from '../utils/skillGate' const props = defineProps({ agentName: { @@ -182,7 +184,9 @@ const props = defineProps({ agentStatus: { type: String, default: 'stopped' - } + }, + // AgentDetail's toast host (`useNotification`), as A2aPanel takes it. + notify: { type: Function, default: null } }) const emit = defineEmits(['run-with-instructions']) @@ -260,6 +264,13 @@ async function runSkill(skill) { { headers: authStore.authHeader } ) + // trinity#3274: a gated playbook — nothing ran, there is no run to open. + const held = pendingApprovalMessage(response) + if (held) { + if (props.notify) props.notify(held, 'info', PENDING_NOTICE_TOAST) + return + } + const executionId = response.data.execution_id successMessage.value = `Playbook /${skill.name} started` @@ -270,7 +281,15 @@ async function runSkill(skill) { emit('run-with-instructions', `__NAVIGATE_TASKS__:${executionId}`) } catch (e) { console.error('Failed to run skill:', e) - errorMessage.value = e.response?.data?.detail || `Failed to run /${skill.name}` + // A gate refusal names something to act on: it goes to the agent page's + // toast, which keeps an error until it is dismissed (principle 18). + if (isGateRefusal(e) && props.notify) { + props.notify(apiErrorMessage(e), 'error') + return + } + errorMessage.value = isGateRefusal(e) + ? apiErrorMessage(e) + : (e.response?.data?.detail || `Failed to run /${skill.name}`) setTimeout(() => { errorMessage.value = '' }, 3000) } finally { running.value = null diff --git a/src/frontend/src/components/TasksPanel.vue b/src/frontend/src/components/TasksPanel.vue index 57826e6b7..6fc7e11b5 100644 --- a/src/frontend/src/components/TasksPanel.vue +++ b/src/frontend/src/components/TasksPanel.vue @@ -557,6 +557,7 @@ import { formatCost, formatCostCompact } from '../composables/useFormatters' import ModelSelector from './ModelSelector.vue' import LoadFailed from './LoadFailed.vue' import { apiErrorMessage } from '../utils/apiError' +import { isGateRefusal, pendingApprovalMessage } from '../utils/skillGate' // Template ref for highlighted task element const highlightedTaskRef = ref(null) @@ -825,10 +826,18 @@ async function runNewTask() { }) const durationMs = Date.now() - startMs + // trinity#3274: a gated skill — nothing ran and no execution exists, so the + // local row stays as the only record: the server's status and its notice. + const held = pendingApprovalMessage(response) // Update the local task with success const idx = pendingTasks.value.findIndex(t => t.id === taskId) - if (idx !== -1) { + if (idx !== -1 && held) { + pendingTasks.value[idx] = { + ...pendingTasks.value[idx], status: response.data.status, response: held, gateHeld: true + } + expandedTaskId.value = taskId + } else if (idx !== -1) { pendingTasks.value[idx] = { ...pendingTasks.value[idx], status: 'success', @@ -841,6 +850,8 @@ async function runNewTask() { } } catch (error) { const durationMs = Date.now() - startMs + // A gate refusal creates no execution either: keep the row and its reason. + const refused = isGateRefusal(error) // Update the local task with failure const idx = pendingTasks.value.findIndex(t => t.id === taskId) @@ -849,17 +860,20 @@ async function runNewTask() { ...pendingTasks.value[idx], status: 'failed', duration_ms: durationMs, - error: error.response?.data?.detail || error.message || 'Task failed' + error: refused ? apiErrorMessage(error) : (error.response?.data?.detail || error.message || 'Task failed'), + gateHeld: refused } + if (refused) expandedTaskId.value = taskId } } finally { taskLoading.value = false // Refresh server data - this will load the persisted execution await loadExecutions() loadQueueStatus() - // Remove the local pending task since it's now in server data + // Remove the local pending task since it's now in server data — unless the + // skill gate held it, which leaves no server row to replace it. const idx = pendingTasks.value.findIndex(t => t.id === taskId) - if (idx !== -1) { + if (idx !== -1 && !pendingTasks.value[idx].gateHeld) { pendingTasks.value.splice(idx, 1) } } diff --git a/src/frontend/src/components/chat/ChatBubble.vue b/src/frontend/src/components/chat/ChatBubble.vue index f47740cc5..d2ea36969 100644 --- a/src/frontend/src/components/chat/ChatBubble.vue +++ b/src/frontend/src/components/chat/ChatBubble.vue @@ -13,6 +13,8 @@

{{ formattedTime }}

+ +
import { computed, ref } from 'vue' import { renderMarkdown } from '../../utils/markdown' +import ChatSystemLine from './ChatSystemLine.vue' const props = defineProps({ role: { type: String, required: true, - validator: (value) => ['user', 'assistant'].includes(value) + validator: (value) => ['user', 'assistant', 'system'].includes(value) }, content: { type: String, diff --git a/src/frontend/src/components/chat/ChatSystemLine.vue b/src/frontend/src/components/chat/ChatSystemLine.vue new file mode 100644 index 000000000..5accadbb8 --- /dev/null +++ b/src/frontend/src/components/chat/ChatSystemLine.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/frontend/src/utils/skillGate.js b/src/frontend/src/utils/skillGate.js new file mode 100644 index 000000000..7907b38fe --- /dev/null +++ b/src/frontend/src/utils/skillGate.js @@ -0,0 +1,32 @@ +/** + * A run request that names a gated skill (trinity-enterprise#751) answers + * **202** `{status: "pending_approval", code: "approval_pending", request_id, + * skills, approver_role, expires_at, outcome_delivery, message}`: nothing ran, + * an approval was raised. It is not an error, and there is no execution to + * poll — the senders show the server's own `message` and stop (trinity#3274). + * A refusal arrives as an ordinary error, `detail: {status: "refused", code, + * message}` with `X-Trinity-Error-Code`; `apiErrorMessage` reads its message. + */ + +// The MCP layer's fallback for a body without a message (client.ts parseGateResult). +const FALLBACK_PENDING = 'Not run: this needs approval.' + +// How long a toast shows the notice: two sentences, not a one-line "done". +export const PENDING_NOTICE_TOAST = { timeout: 8000 } + +/** The backend's notice for a pending approval, or null for any other answer. */ +export function pendingApprovalMessage(response) { + const data = response?.data + if (response?.status !== 202 || data?.status !== 'pending_approval') return null + return typeof data.message === 'string' && data.message.trim() ? data.message : FALLBACK_PENDING +} + +/** True for a named skill-gate refusal (nothing ran, nothing was raised). */ +export function isGateRefusal(err) { + const headers = err?.response?.headers || {} + const code = typeof headers.get === 'function' + ? headers.get('x-trinity-error-code') + : headers['x-trinity-error-code'] + const detail = err?.response?.data?.detail + return Boolean(code) && detail?.status === 'refused' && detail?.code === code +} diff --git a/src/frontend/src/views/AgentDetail.vue b/src/frontend/src/views/AgentDetail.vue index a79575a52..0d030dc66 100644 --- a/src/frontend/src/views/AgentDetail.vue +++ b/src/frontend/src/views/AgentDetail.vue @@ -19,9 +19,12 @@ `bottom-24` clears the global HelpChatWidget FAB (bottom-6 + h-14 = 80px), which is why this is not the `bottom-4 right-4` used elsewhere. -->
{{ notification.message }} @@ -192,6 +195,7 @@ :agent-name="agent.name" :agent-status="agent.status" :has-declared-metrics="hasDeclaredMetrics" + :notify="showNotification" />
@@ -266,6 +270,7 @@
diff --git a/src/frontend/src/views/MobileAdmin.vue b/src/frontend/src/views/MobileAdmin.vue index 16af5de3b..14e5e68ec 100644 --- a/src/frontend/src/views/MobileAdmin.vue +++ b/src/frontend/src/views/MobileAdmin.vue @@ -575,7 +575,9 @@ v-for="(msg, i) in chatMessages" :key="i" class="chat-bubble" - :class="msg.role === 'user' ? 'bubble-user' : 'bubble-assistant'" + :class="msg.role === 'user' ? 'bubble-user' : msg.role === 'system' ? 'bubble-system' : 'bubble-assistant'" + :data-testid="msg.role === 'system' ? 'chat-system-line' : undefined" + :role="msg.role === 'system' ? 'status' : undefined" >
{{ msg.content }}
{{ formatTime(msg.timestamp) }}
@@ -666,6 +668,7 @@ import { useAgentsStore } from '../stores/agents' import { agentNameTooltip } from '../utils/agentName' import AskMarkdown from '../components/operator/AskMarkdown.vue' import { apiErrorMessage } from '../utils/apiError' +import { isGateRefusal, pendingApprovalMessage } from '../utils/skillGate' import { viewState, staleBannerMessage, listFrom } from '../utils/loadingState' import { queueResponseKind, buildQueueResponse, queueTypeLabel, @@ -1103,8 +1106,10 @@ async function selectSession(session) { } function buildContextPrompt(userMessage) { - // Include last 10 exchanges for context - const recent = chatMessages.value.slice(-20) + // Include last 10 exchanges for context. A held (gated-skill) request and + // its notice stay on screen but out of it (trinity#3274), or every later + // turn would ask for the approval again. + const recent = chatMessages.value.filter(m => !m.held).slice(-20) if (recent.length === 0) return userMessage let context = 'Previous conversation:\n' recent.forEach(m => { @@ -1129,6 +1134,7 @@ async function sendChatMessage() { content: message, timestamp: new Date().toISOString() }) + const userEntry = chatMessages.value[chatMessages.value.length - 1] scrollChatToBottom() chatExecutionStatus.value = 'running' @@ -1145,6 +1151,13 @@ async function sendChatMessage() { } const submitRes = await http.post(`/api/agents/${chatAgent.value}/task`, payload) + // trinity#3274: a gated skill — nothing ran, nothing to poll. + const held = pendingApprovalMessage(submitRes) + if (held) { + userEntry.held = true + chatMessages.value.push({ role: 'system', content: held, held: true, timestamp: new Date().toISOString() }) + return + } const executionId = submitRes.data.execution_id // Poll for completion @@ -1170,9 +1183,14 @@ async function sendChatMessage() { chatSessionId.value = chatSessions.value[0].id } } catch (e) { + // A refused gated request stays out of the next message's history too: + // resent, it would be refused again on every later turn (#3274). + const refused = isGateRefusal(e) + if (refused) userEntry.held = true chatMessages.value.push({ role: 'assistant', - content: `Error: ${e.response?.data?.detail || e.message || 'Failed to send message'}`, + content: `Error: ${refused ? apiErrorMessage(e) : (e.response?.data?.detail || e.message || 'Failed to send message')}`, + held: refused, timestamp: new Date().toISOString() }) } finally { @@ -2760,6 +2778,18 @@ watch(() => authStore.isAuthenticated, (isAuth) => { margin-top: 4px; } +/* trinity#3274: a platform line (a held request's notice), not a bubble — + centred and muted, gray-400 on this view's dark ground (gray-500 is below + AA there). A theme() token, not a literal. */ +.bubble-system { + align-self: center; + max-width: 100%; + padding: 4px 16px; + font-size: 12.5px; + text-align: center; + color: theme('colors.gray.400'); +} + .bubble-assistant .bubble-time { color: #6b7280; } diff --git a/src/frontend/src/views/PublicChat.vue b/src/frontend/src/views/PublicChat.vue index ccd305577..6645272f3 100644 --- a/src/frontend/src/views/PublicChat.vue +++ b/src/frontend/src/views/PublicChat.vue @@ -742,7 +742,9 @@ const pollExecution = async (executionId) => { ) const execution = response.data - if (execution.status === 'success' || execution.status === 'failed' || execution.status === 'cancelled') { + // `skipped` (trinity#3274): the skill gate held the turn — it will not + // run now, so waiting longer only ends in "timed out". + if (['success', 'failed', 'cancelled', 'skipped'].includes(execution.status)) { return execution } } catch (err) { @@ -823,6 +825,13 @@ const sendMessage = async (userMessage, files = []) => { chatError.value = execution.error || 'Failed to process your request. Please try again.' } else if (execution.status === 'cancelled') { chatError.value = 'Request was cancelled.' + } else if (execution.status === 'skipped' && execution.gate === 'held' && execution.error) { + // Waiting for an approval: the gate's notice (the server stores it as + // this turn's reply too). + messages.value.push({ role: 'system', content: execution.error }) + } else if (execution.status === 'skipped') { + // Refused by the gate: an error, never a waiting notice. + chatError.value = execution.error || 'Failed to process your request. Please try again.' } } else { chatError.value = 'Request timed out. Please try again.' diff --git a/src/frontend/tests/unit/agentDetailGateNotice.spec.js b/src/frontend/tests/unit/agentDetailGateNotice.spec.js new file mode 100644 index 000000000..b44d8729a --- /dev/null +++ b/src/frontend/tests/unit/agentDetailGateNotice.spec.js @@ -0,0 +1,44 @@ +/** + * trinity#3274 — the agent page hands its toast host to the two panels whose + * gated runs answer with a notice, and that host shows an `info` notice in the + * info family. It rendered every non-`success` type red, so a "waiting for + * approval" toast (and the existing "Restarting agent…" info toast) read as an + * error. + * + * What the panels DO with `notify` is mounted in `skillGateSenders.spec.js` + * (each calls `notify(message, 'info', …)` on a 202 `pending_approval`). + * + * @source-text-pin: AgentDetail.vue is never mounted by a spec — it runs five + * composables with timers and the agents store's fetch chain before any tab + * renders. What is pinned here is call-site shape only: that both panel + * elements receive `:notify="showNotification"`, and that the toast's class + * chain has an `info` arm in the `status-info` family ahead of the red + * fallback. A panel mounted on its own passes identically with the prop + * missing, which is the regression this guards. + */ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { stripComments } from './helpers/stripComments' + +const source = stripComments(readFileSync( + fileURLToPath(new URL('../../src/views/AgentDetail.vue', import.meta.url)), 'utf8')) + +function element(tag) { + const start = source.indexOf(`<${tag}`) + expect(start, `<${tag}> is no longer in AgentDetail.vue`).toBeGreaterThan(-1) + return source.slice(start, source.indexOf('/>', start)) +} + +describe('AgentDetail — the gated-run notice reaches a toast', () => { + it.each(['PlaybooksPanel', 'DashboardPanel'])('%s gets the toast host', (tag) => { + expect(element(tag)).toMatch(/:notify="showNotification"/) + }) + + it('the toast has an info arm in the status-info family before the red fallback', () => { + const info = source.indexOf("notification.type === 'info' ? 'bg-status-info-100") + const fallback = source.indexOf(": 'bg-status-danger-100", info) + expect(info).toBeGreaterThan(-1) + expect(fallback).toBeGreaterThan(info) + }) +}) diff --git a/src/frontend/tests/unit/mobileAdminSkillGate.spec.js b/src/frontend/tests/unit/mobileAdminSkillGate.spec.js new file mode 100644 index 000000000..3abf8bff9 --- /dev/null +++ b/src/frontend/tests/unit/mobileAdminSkillGate.spec.js @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +/** + * trinity#3274 — `/m` chat: a request naming a gated skill is "waiting for + * approval", not a 10-minute poll of `/executions/undefined`. + * + * `/task` answers 202 `pending_approval` with no execution. The chat shows the + * server's message as a platform line, polls nothing, and keeps the held + * request out of the history it sends with the next message (resent, the + * command would ask for an approval on every later turn). A refusal is named. + * + * MOUNTED through `sendChatMessage`, with `boundedHttp` stubbed by URL (the + * `mobileAdminNotAddressee.spec.js` harness). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: { tab: 'agents' } }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})) +vi.mock('@/utils/boundedHttp', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, +})) + +import { http } from '@/utils/boundedHttp' +import { useAuthStore } from '@/stores/auth' +import MobileAdmin from '@/views/MobileAdmin.vue' + +const NOTICE = 'Not run: the skill pay-invoice on fin needs approval before it can run. ' + + 'Request gate-abc is waiting for a decision.' +const REFUSED = 'The skill pay-invoice is not installed on fin.' + +let wrapper + +async function mountView(post) { + http.get.mockImplementation(async (url) => { + if (url === '/api/operator-queue') return { data: { items: [], count: 0 } } + if (url === '/api/notifications') return { data: { items: [], count: 0 } } + return { data: [] } + }) + http.post.mockImplementation(post) + const pinia = createPinia() + setActivePinia(pinia) + useAuthStore().isAuthenticated = true + wrapper = mount(MobileAdmin, { global: { plugins: [pinia], stubs: { LoadFailed: true, InlineError: true } } }) + await flushPromises() + wrapper.vm.chatAgent = 'fin' + return wrapper +} + +async function send(w, text) { + w.vm.chatInput = text + await w.vm.sendChatMessage() + await flushPromises() +} + +const polled = () => http.get.mock.calls.map(c => c[0]).filter(u => /\/executions\//.test(u)) + +describe('/m chat — a gated skill', () => { + beforeEach(() => { vi.clearAllMocks(); document.body.innerHTML = '' }) + afterEach(() => { wrapper?.unmount(); wrapper = null }) + + it('shows the notice as a platform line and polls nothing', async () => { + const w = await mountView(async () => ({ status: 202, data: { status: 'pending_approval', message: NOTICE } })) + await send(w, '/pay-invoice 100 EUR') + expect(w.find('[data-testid="chat-system-line"]').text()).toContain(NOTICE) + expect(polled()).toEqual([]) + }) + + it('leaves the held request out of the next message\'s history', async () => { + const w = await mountView(async () => ({ status: 202, data: { status: 'pending_approval', message: NOTICE } })) + await send(w, '/pay-invoice 100 EUR') + await send(w, 'what is on today?') + const second = http.post.mock.calls[1][1] + expect(second.message).not.toContain('/pay-invoice') + expect(second.message).not.toContain(NOTICE) + }) + + it('leaves a refused request out of the next message\'s history too', async () => { + let calls = 0 + const w = await mountView(async () => { + calls += 1 + if (calls === 1) { + throw Object.assign(new Error('Request failed with status code 409'), { + response: { status: 409, headers: { 'x-trinity-error-code': 'gated_skill_not_installed' }, + data: { detail: { status: 'refused', code: 'gated_skill_not_installed', message: REFUSED } } }, + }) + } + return { status: 202, data: { status: 'accepted', execution_id: 'e2' } } + }) + await send(w, '/pay-invoice 100 EUR') + w.vm.chatExecutionStatus = null + const second = w.vm.buildContextPrompt('what is on today?') + expect(second).not.toContain('/pay-invoice') + expect(second).not.toContain(REFUSED) + }) + + it('names a refusal', async () => { + const w = await mountView(async () => { + throw Object.assign(new Error('Request failed with status code 409'), { + response: { status: 409, headers: { 'x-trinity-error-code': 'gated_skill_not_installed' }, + data: { detail: { status: 'refused', code: 'gated_skill_not_installed', message: REFUSED } } }, + }) + }) + await send(w, '/pay-invoice 100 EUR') + expect(w.text()).toContain(`Error: ${REFUSED}`) + }) +}) diff --git a/src/frontend/tests/unit/skillGate.spec.js b/src/frontend/tests/unit/skillGate.spec.js new file mode 100644 index 000000000..6387f179a --- /dev/null +++ b/src/frontend/tests/unit/skillGate.spec.js @@ -0,0 +1,55 @@ +/** + * trinity#3274 — reading a gated-skill answer (`utils/skillGate.js`). + * + * A run request naming a gated skill answers 202 `pending_approval` (nothing + * ran; show the server's message, poll nothing) or a named refusal. These two + * readers are what every run sender branches on; the senders themselves are + * mounted in `skillGateSenders.spec.js`. + */ +import { describe, it, expect } from 'vitest' +import { isGateRefusal, pendingApprovalMessage } from '../../src/utils/skillGate' + +const NOTICE = 'Not run: the skill pay-invoice on fin needs approval before it can run.' + +describe('pendingApprovalMessage', () => { + it('is the server message on a 202 pending_approval', () => { + expect(pendingApprovalMessage({ status: 202, data: { status: 'pending_approval', message: NOTICE } })) + .toBe(NOTICE) + }) + + it('falls back to the MCP layer\'s wording when the body carries no message', () => { + expect(pendingApprovalMessage({ status: 202, data: { status: 'pending_approval' } })) + .toBe('Not run: this needs approval.') + }) + + it.each([ + [{ status: 202, data: { status: 'accepted', execution_id: 'e1' } }], // an ordinary async accept + [{ status: 200, data: { status: 'pending_approval', message: NOTICE } }], + [undefined], + ])('is null for anything else (%#)', (response) => { + expect(pendingApprovalMessage(response)).toBeNull() + }) +}) + +describe('isGateRefusal', () => { + const refusal = (code, headerCode = code, headers = 'plain') => ({ + response: { + status: 409, + headers: headers === 'plain' + ? { 'x-trinity-error-code': headerCode } + : { get: (k) => (k === 'x-trinity-error-code' ? headerCode : null) }, + data: { detail: { status: 'refused', code, message: 'Not installed.' } }, + }, + }) + + it('reads the header as a plain object or through get()', () => { + expect(isGateRefusal(refusal('gated_skill_not_installed'))).toBe(true) + expect(isGateRefusal(refusal('gated_skill_not_installed', undefined, 'get'))).toBe(true) + }) + + it('is false without the matching header, or for another error', () => { + expect(isGateRefusal(refusal('gated_skill_not_installed', 'other'))).toBe(false) + expect(isGateRefusal({ response: { status: 500, headers: {}, data: { detail: 'boom' } } })).toBe(false) + expect(isGateRefusal(new Error('network'))).toBe(false) + }) +}) diff --git a/src/frontend/tests/unit/skillGateSenders.spec.js b/src/frontend/tests/unit/skillGateSenders.spec.js new file mode 100644 index 000000000..6947dc321 --- /dev/null +++ b/src/frontend/tests/unit/skillGateSenders.spec.js @@ -0,0 +1,284 @@ +// @vitest-environment jsdom +/** + * trinity#3274 — the UI's run senders say "waiting for approval", not "error". + * + * A request naming a gated skill (trinity-enterprise#751) answers `/task` with + * 202 `pending_approval`: nothing ran, so there is no execution to poll. Each + * sender used to read `execution_id` off it — and threw "No execution_id + * returned", polled `/executions/undefined`, showed the JSON body as a result, + * or navigated to a run that does not exist. The public link never gets a 202 + * (its turn is held in the background) and polled its `skipped` row for 30 + * minutes before "Request timed out". Each now shows the server's own message + * and stops. + * + * MOUNTED, each sender through its own send function, with `axios` stubbed by + * URL. `/m` is mounted in `mobileAdminSkillGate.spec.js` (it uses `boundedHttp`). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { nextTick } from 'vue' + +const routes = vi.hoisted(() => ({ get: null, post: null })) + +vi.mock('axios', () => { + const instance = { + get: vi.fn((...a) => routes.get(...a)), + post: vi.fn((...a) => routes.post(...a)), + interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } }, + } + return { default: { ...instance, create: vi.fn(() => instance) } } +}) +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: { token: 'tok' }, query: {} }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})) +// uPlot reads `matchMedia` at module load (declaredMetricsTiles.spec.js). +vi.mock('../../src/components/SparklineChart.vue', () => ({ + default: { name: 'SparklineChart', template: '
' }, +})) + +import axios from 'axios' +import ChatPanel from '../../src/components/ChatPanel.vue' +import DashboardPanel from '../../src/components/DashboardPanel.vue' +import PlaybooksPanel from '../../src/components/PlaybooksPanel.vue' +import TasksPanel from '../../src/components/TasksPanel.vue' +import PublicChat from '../../src/views/PublicChat.vue' +import { useAgentsStore } from '../../src/stores/agents' + +const AGENT = 'fin' +const NOTICE = 'Not run: the skill pay-invoice on fin needs approval before it can run. ' + + 'Request gate-abc is waiting for a decision.' +const REFUSED = 'The skill pay-invoice is not installed on fin.' +const PENDING = { status: 202, data: { status: 'pending_approval', code: 'approval_pending', + request_id: 'gate-abc', skills: ['pay-invoice'], outcome_delivery: 'inbox', message: NOTICE } } +const refusal = () => Object.assign(new Error('Request failed with status code 409'), { + response: { status: 409, headers: { 'x-trinity-error-code': 'gated_skill_not_installed' }, + data: { detail: { status: 'refused', code: 'gated_skill_not_installed', message: REFUSED } } }, +}) + +let wrapper +const polled = () => axios.get.mock.calls.map(c => c[0]).filter(u => /\/executions\//.test(u)) + +function serve(get = {}, post = {}) { + routes.get = vi.fn(async (url) => { + for (const [pattern, answer] of Object.entries(get)) { + if (url.includes(pattern)) return typeof answer === 'function' ? answer(url) : answer + } + return { data: [] } + }) + routes.post = vi.fn(async (url, body) => { + for (const [pattern, answer] of Object.entries(post)) { + if (url.includes(pattern)) return typeof answer === 'function' ? answer(url, body) : answer + } + return { data: {} } + }) +} + +async function flush() { + for (let i = 0; i < 4; i++) { await nextTick(); await flushPromises() } +} + +beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + globalThis.fetch = vi.fn(async () => { throw new Error('no SSE in this spec') }) +}) +afterEach(() => { wrapper?.unmount(); wrapper = null; vi.useRealTimers() }) + +// --------------------------------------------------------------------------- +// ChatPanel — the agent page's Chat tab (async /task) +// --------------------------------------------------------------------------- + +describe('ChatPanel', () => { + async function chatPanel() { + wrapper = mount(ChatPanel, { + props: { agentName: AGENT, agentStatus: 'running' }, + global: { stubs: { ModelSelector: true } }, + }) + await flush() + return wrapper + } + + it('shows the notice as a platform line, keeps the message, and polls nothing', async () => { + serve({ '/chat/sessions': { data: [] } }, { '/task': PENDING }) + const w = await chatPanel() + await w.vm.sendMessage('/pay-invoice 100 EUR') + await flush() + + expect(w.find('[data-testid="chat-system-line"]').text()).toBe(NOTICE) + expect(w.text()).toContain('/pay-invoice 100 EUR') + expect(w.text()).not.toContain('No execution_id') + expect(polled()).toEqual([]) + }) + + it('leaves the held request out of the next turn\'s history', async () => { + serve({ '/chat/sessions': { data: [] } }, { '/task': PENDING }) + const w = await chatPanel() + await w.vm.sendMessage('/pay-invoice 100 EUR') + await flush() + await w.vm.sendMessage('what is on today?') + await flush() + + const second = axios.post.mock.calls.filter(c => c[0].endsWith('/task'))[1][1] + expect(second.message).not.toContain('/pay-invoice') + expect(second.message).not.toContain(NOTICE) + }) + + it('names a refusal instead of an object', async () => { + serve({ '/chat/sessions': { data: [] } }, { '/task': () => Promise.reject(refusal()) }) + const w = await chatPanel() + await w.vm.sendMessage('/pay-invoice 100 EUR') + await flush() + expect(w.text()).toContain(REFUSED) + expect(w.text()).not.toContain('"code"') // not the detail object as JSON + }) +}) + +// --------------------------------------------------------------------------- +// PublicChat — a public link (async; held in the background, row `skipped`) +// --------------------------------------------------------------------------- + +describe('PublicChat', () => { + it('stops at a skipped turn and shows its notice instead of polling to a timeout', async () => { + serve({ + '/api/public/link/': { data: { valid: true, agent_available: true, require_email: false, + agent_name: AGENT } }, + '/status': { data: { execution_id: 'e1', status: 'skipped', response: null, error: NOTICE, + gate: 'held' } }, + '/api/public/history/': { data: { messages: [] } }, + }, { '/api/public/chat/': { data: { status: 'accepted', execution_id: 'e1', async_mode: true } } }) + wrapper = mount(PublicChat) + await flush() + vi.useFakeTimers() + wrapper.vm.sendMessage('/pay-invoice 100 EUR') + // Two poll intervals: a sender that does not stop at `skipped` polls again + // and shows nothing — an assertion failure, not a test timeout. + await vi.advanceTimersByTimeAsync(10_000) + const pollsAfterSkip = polled().length + const line = wrapper.find('[data-testid="chat-system-line"]') + wrapper.vm.chatLoading = false // ends a poll loop that did not stop on its own + vi.useRealTimers() + await flush() + + expect(pollsAfterSkip).toBe(1) + expect(line.exists() && line.text()).toBe(NOTICE) + expect(wrapper.text()).not.toContain('timed out') + }) +}) + +describe('PublicChat — a refusal', () => { + it('shows a gate refusal as an error, not as a waiting notice', async () => { + const REFUSAL_ROW = 'Refused by the skill gate (gated_skill_not_installed): ' + REFUSED + serve({ + '/api/public/link/': { data: { valid: true, agent_available: true, require_email: false, + agent_name: AGENT } }, + '/status': { data: { execution_id: 'e1', status: 'skipped', response: null, + error: REFUSAL_ROW, gate: 'refused' } }, + '/api/public/history/': { data: { messages: [] } }, + }, { '/api/public/chat/': { data: { status: 'accepted', execution_id: 'e1', async_mode: true } } }) + wrapper = mount(PublicChat) + await flush() + vi.useFakeTimers() + wrapper.vm.sendMessage('/pay-invoice 100 EUR') + await vi.advanceTimersByTimeAsync(10_000) + wrapper.vm.chatLoading = false + vi.useRealTimers() + await flush() + + expect(wrapper.find('[data-testid="chat-system-line"]').exists()).toBe(false) + expect(wrapper.vm.chatError).toBe(REFUSAL_ROW) + }) +}) + +// --------------------------------------------------------------------------- +// TasksPanel — the Tasks tab (sync /task) +// --------------------------------------------------------------------------- + +describe('TasksPanel', () => { + async function tasksPanel(post) { + serve({}, { '/task': post }) + wrapper = mount(TasksPanel, { + props: { agentName: AGENT, agentStatus: 'running' }, + global: { stubs: { ModelSelector: true, SkeletonLoader: true, LoadFailed: true, RouterLink: true } }, + }) + await flush() + wrapper.vm.newTaskMessage = '/pay-invoice 100 EUR' + await wrapper.vm.runNewTask() + await flush() + return wrapper + } + + it('keeps the request as waiting for approval with the notice, not a success', async () => { + const w = await tasksPanel(PENDING) + expect(w.text()).toContain('pending_approval') + expect(w.text()).toContain(NOTICE) + expect(w.text()).not.toContain('"status"') // not the JSON body + }) + + it('keeps a refused request with its named reason (no server row exists)', async () => { + const w = await tasksPanel(() => Promise.reject(refusal())) + expect(w.text()).toContain(REFUSED) + expect(w.text()).not.toContain('"code"') // not the detail object as JSON + }) +}) + +// --------------------------------------------------------------------------- +// PlaybooksPanel — Run on a playbook (sync /task) +// --------------------------------------------------------------------------- + +describe('PlaybooksPanel', () => { + async function playbooks(post) { + serve({ '/playbooks': { data: { skills: [] } } }, { '/task': post }) + const notify = vi.fn() + wrapper = mount(PlaybooksPanel, { props: { agentName: AGENT, agentStatus: 'running', notify } }) + await flush() + await wrapper.vm.runSkill({ name: 'pay-invoice', user_invocable: true }) + await flush() + return { w: wrapper, notify } + } + + it('tells the notice and opens no run', async () => { + const { w, notify } = await playbooks(PENDING) + expect(notify).toHaveBeenCalledWith(NOTICE, 'info', { timeout: 8000 }) + expect(w.emitted('run-with-instructions')).toBeUndefined() + expect(w.text()).not.toContain('started') + }) + + it('names a refusal in the persistent error toast, not a 3-second one', async () => { + const { w, notify } = await playbooks(() => Promise.reject(refusal())) + expect(notify).toHaveBeenCalledWith(REFUSED, 'error') + expect(w.text()).not.toContain('"code"') // not the detail object as JSON + }) +}) + +// --------------------------------------------------------------------------- +// DashboardPanel — Update Dashboard (sync /task) +// --------------------------------------------------------------------------- + +describe('DashboardPanel', () => { + async function dashboard(post) { + serve({ '/playbooks': { data: { skills: [{ name: 'update-dashboard' }] } } }, { '/task': post }) + useAgentsStore().getAgentDashboard = vi.fn(async () => ({ + has_dashboard: true, stale: false, config: { title: 'Ops', sections: [] }, + last_modified: '2026-10-07T09:00:00Z', + })) + const notify = vi.fn() + wrapper = mount(DashboardPanel, { props: { agentName: AGENT, agentStatus: 'running', notify } }) + await flush() + await wrapper.vm.triggerUpdateDashboard() + await flush() + return { w: wrapper, notify } + } + + it('tells the notice and stops waiting', async () => { + const { w, notify } = await dashboard(PENDING) + expect(notify).toHaveBeenCalledWith(NOTICE, 'info', { timeout: 8000 }) + expect(w.vm.updatingDashboard).toBe(false) + }) + + it('tells a refusal as an error', async () => { + const { notify } = await dashboard(() => Promise.reject(refusal())) + expect(notify).toHaveBeenCalledWith(REFUSED, 'error') + }) +}) diff --git a/src/mcp-server/src/access.ts b/src/mcp-server/src/access.ts index 73671a2da..299a5a546 100644 --- a/src/mcp-server/src/access.ts +++ b/src/mcp-server/src/access.ts @@ -212,6 +212,8 @@ const TEARDOWN_HUMAN_ONLY = const REMINDER_SELF_GATE = "backend self-gate: reminders.py::_self_gate refuses an agent key naming another agent"; const SKILL_MANAGER_FENCE = "backend fence: routers/skills.py get_skill_managed_agent_by_name refuses an agent key whose agent does not hold the skills.manage capability — on a sibling AND on itself (abilityai/trinity-enterprise#596)"; +const SKILL_GATE_FENCE = + "backend fence: routers/skill_gate.py — writes take require_person_or_capability(skills.manage, self_person_only): a person who owns the agent or an admin, or a holder on an agent its owner owns, never itself; reads are own-gates-only for an agent key (get_skill_gate_readable_agent_by_name) (abilityai/trinity-enterprise#753)"; const CONNECTOR_SCOPE = "connector scope — the key is bound to one agent; backend _enforce_connector_scope (ent#46)"; const ROOMS_SERVICE = "room membership is the rooms service's decision (ent#169, ent#443), not a per-agent permission edge"; const EVENT_EDGE = "backend gates by agent_permissions edge itself (event_subscriptions.py, uniform 403)"; @@ -293,6 +295,9 @@ export const TOOL_ACCESS_POLICY: Readonly> = { sync_agent_skills: { kind: "baselined", owner: SKILL_MANAGER_FENCE }, unassign_skill_set: { kind: "baselined", owner: SKILL_MANAGER_FENCE }, get_agent_skills: { kind: "baselined", owner: ENT629 }, + list_skill_gates: { kind: "baselined", owner: SKILL_GATE_FENCE }, + set_skill_gate: { kind: "baselined", owner: SKILL_GATE_FENCE }, + clear_skill_gate: { kind: "baselined", owner: SKILL_GATE_FENCE }, run_skill: { kind: "none", why: "runs on the calling agent; a skill name, not an agent" }, list_runnable_skills: { kind: "none", why: "no agent target" }, // --- schedules.ts --- diff --git a/src/mcp-server/src/chat-gate.test.ts b/src/mcp-server/src/chat-gate.test.ts index 231f050a6..6b08c81fd 100644 --- a/src/mcp-server/src/chat-gate.test.ts +++ b/src/mcp-server/src/chat-gate.test.ts @@ -130,3 +130,74 @@ describe("#751 parseGateResult", () => { assert.equal(parseGateResult(202, null, "", "a"), undefined); }); }); + +describe("#3233 the pending answer promises only the delivery the backend makes", () => { + const body = (delivery?: string) => + JSON.stringify({ ...PENDING, ...(delivery ? { outcome_delivery: delivery } : {}) }); + + for (const delivery of ["agent_task", "inbox"]) { + it(`${delivery}: the outcome will be sent`, () => { + const out = parseGateResult(202, "approval_pending", body(delivery), "target")!; + assert.match(out.message, /the outcome will be sent to you\.$/); + assert.equal(out.outcome_delivery, delivery); + }); + } + + for (const delivery of ["none", undefined]) { + it(`${delivery ?? "absent"}: no promise, and where to look`, () => { + const out = parseGateResult(202, "approval_pending", body(delivery), "target")!; + assert.doesNotMatch(out.message, /sent to you/); + assert.match(out.message, /Do not retry or route it through another agent\./); + assert.match(out.message, /list_recent_executions\(agent_name="target"\)/); + }); + } +}); + +describe("#3274 the inline connector tier reads the gate result like the other tiers", () => { + const secret = process.env.INTERNAL_API_SECRET; + beforeEach(() => { + globalThis.fetch = realFetch; + process.env.INTERNAL_API_SECRET = "test-internal-secret"; // as inline-auth-transport.test.ts + }); + afterEach(() => { + globalThis.fetch = realFetch; + if (secret === undefined) delete process.env.INTERNAL_API_SECRET; + else process.env.INTERNAL_API_SECRET = secret; + }); + + function stubInline(respond: () => Response) { + globalThis.fetch = (async (input: unknown) => { + const url = String(input); + if (!url.endsWith("/api/internal/mcp-auth/chat")) throw new Error(`unstubbed fetch: ${url}`); + return respond(); + }) as typeof fetch; + } + + it("a 202 pending_approval is the structured result, not a reply", async () => { + stubInline(() => json({ ...PENDING, outcome_delivery: "inbox" }, 202, "approval_pending")); + const out = (await realClient().inlineConnectorChat("v@example.com", "target", "/pay-invoice 1")) as any; + assert.equal(out.status, "pending_approval"); + assert.equal(out.request_id, "gate-abc"); + assert.equal(out.retryable, false); + }); + + it("a named refusal keeps its code instead of a bare status", async () => { + stubInline(() => json({ detail: { status: "refused", code: "gated_skill_not_installed", + message: "The skill pay-invoice is not installed on target." } }, + 409, "gated_skill_not_installed")); + const out = (await realClient().inlineConnectorChat("v@example.com", "target", "/pay-invoice 1")) as any; + assert.equal(out.status, "refused"); + assert.equal(out.code, "gated_skill_not_installed"); + }); + + it("a gate 403 is a refusal; an access 403 is still 'no access'", async () => { + stubInline(() => json({ detail: { status: "refused", code: "approval_not_available_here", + message: "Ask in chat instead." } }, 403, "approval_not_available_here")); + const out = (await realClient().inlineConnectorChat("v@example.com", "target", "/pay-invoice 1")) as any; + assert.equal(out.code, "approval_not_available_here"); + + stubInline(() => json({ detail: "Forbidden" }, 403)); + await assert.rejects(realClient().inlineConnectorChat("v@example.com", "target", "hi"), + /You do not have access/); + }); +}); diff --git a/src/mcp-server/src/client.ts b/src/mcp-server/src/client.ts index b694447c0..760432267 100644 --- a/src/mcp-server/src/client.ts +++ b/src/mcp-server/src/client.ts @@ -430,6 +430,9 @@ export interface GateResult { skills?: string[]; approver_role?: string; expires_at?: string | null; + /** trinity#3233: how the backend will tell this caller the outcome — + * `agent_task`, `inbox`, or `none` (absent from an older backend = none). */ + outcome_delivery?: string; retryable: false; message: string; } @@ -449,6 +452,9 @@ export function parseGateResult( try { const parsed = JSON.parse(body) as Record; if (status === 202 && parsed?.status === "pending_approval") { + const delivery = typeof parsed.outcome_delivery === "string" ? parsed.outcome_delivery : undefined; + // trinity#3233: promise a delivery only when the backend says it makes one. + const delivered = delivery === "agent_task" || delivery === "inbox"; return { status: "pending_approval", agent, @@ -457,10 +463,14 @@ export function parseGateResult( skills: Array.isArray(parsed.skills) ? (parsed.skills as string[]) : undefined, approver_role: typeof parsed.approver_role === "string" ? parsed.approver_role : undefined, expires_at: typeof parsed.expires_at === "string" ? parsed.expires_at : null, + outcome_delivery: delivery, retryable: false, message: `${typeof parsed.message === "string" ? parsed.message : "Not run: this needs approval."} ` + - "Do not retry or route it through another agent — the outcome will be sent to you.", + (delivered + ? "Do not retry or route it through another agent — the outcome will be sent to you." + : "Do not retry or route it through another agent. To learn the outcome, look for a " + + `new run with list_recent_executions(agent_name="${agent}") later.`), }; } const d = ((parsed?.detail ?? parsed) as Record) || {}; @@ -3034,6 +3044,10 @@ export class TrinityClient { { email, agent, message }, Number(process.env.MCP_CHAT_TIMEOUT_MS || 25000) ); + // trinity#3274: a gated skill answers like it does on the other tiers — + // checked first, so a named refusal (even a 403) keeps its code. + const gate = await readGateResult(response, agent); + if (gate) return gate; if (response.status === 403) { throw new Error(`You do not have access to "${agent}".`); } diff --git a/src/mcp-server/src/connector.test.ts b/src/mcp-server/src/connector.test.ts index c8d05512d..5a008d6a1 100644 --- a/src/mcp-server/src/connector.test.ts +++ b/src/mcp-server/src/connector.test.ts @@ -58,8 +58,8 @@ describe("connector tools", () => { await tools.runPlaybook.execute({ name: "cso", input: "scan repo" }, { session: session("agent-1") }); assert.equal(chats.length, 1); assert.equal(chats[0].agent, "agent-1"); - assert.match(chats[0].message, /cso/); - assert.match(chats[0].message, /scan repo/); + // #3274: the slash form is what the skill gate reads, pinned exactly. + assert.equal(chats[0].message, "/cso scan repo"); }); it("run_playbook refuses a playbook the connector does not expose", async () => { diff --git a/src/mcp-server/src/delegation_contract.ts b/src/mcp-server/src/delegation_contract.ts index d80a27167..de3ae0736 100644 --- a/src/mcp-server/src/delegation_contract.ts +++ b/src/mcp-server/src/delegation_contract.ts @@ -29,7 +29,7 @@ export const DELEGATION_CONTRACT_LINES: readonly string[] = [ "- Read the result with `get_execution_result(agent_name, execution_id)` (`get_fan_out_result` for a batch); `running` is not stuck. To finish later, call `set_reminder` with a message naming the `execution_id`, then end your turn. A receipt is not a result: never report the work as done.", "- Confirmed `failed` or `cancelled`: re-send word for word to retry; same `execution_id` back: `set_reminder`, end your turn.", "- An error without an `execution_id`, `agent_busy` included, is not proof that nothing ran. Look for your exact message in `list_recent_executions(agent_name)`: one match is your receipt; otherwise re-send it word for word, same options (for `agent_busy`, after `retry_after_seconds`).", - "- `pending_approval`: nothing ran. Do not retry or route it through another agent — the outcome will be sent to you. On `retryable: false`, do what its `message` says.", + "- `pending_approval`: nothing ran. Do not retry or route it through another agent; its `message` says whether you will hear the outcome. On `retryable: false`, do what it says.", "- For long work use `parallel=true, async=true`: the receipt comes back at once, and the run's end fires the target's `agent.task.completed` / `agent.task.failed`. A `subscribe_to_event` subscription to those wakes you for every run of that agent, so match the `execution_id`.", ]; diff --git a/src/mcp-server/src/tools/skill-gates.test.ts b/src/mcp-server/src/tools/skill-gates.test.ts new file mode 100644 index 000000000..94c3a7e38 --- /dev/null +++ b/src/mcp-server/src/tools/skill-gates.test.ts @@ -0,0 +1,108 @@ +/** + * trinity-enterprise#753 — list_skill_gates / set_skill_gate / clear_skill_gate, + * the MCP surface of the per-agent skill gate map (Invariant #13). + * + * The tools are thin proxies; the backend decides who may (routers/skill_gate.py). + * What the tool layer itself owns, and these pin: + * - each tool reaches its route, names url-encoded; + * - set_skill_gate sends ONLY the fields the caller gave — an omitted field + * keeps its stored value on the backend, an explicit null resets the + * deadline — and nothing else (a stray key never reaches the body); + * - the zod schema refuses what the backend would (a bad approver, a deadline + * outside 1..168, a boolean or a string for hours) before a round trip; + * - every tool has a TOOL_ACCESS_POLICY row (server.ts refuses to register one + * without), and its description fits the 2,048-character cap. + * + * Drives the real execute() with a fake TrinityClient (requireApiKey=false). + * Runner: node:test → `node --import tsx --test src/tools/*.test.ts`. + */ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; +import { createSkillsTools } from "./skills.js"; +import { TOOL_ACCESS_POLICY } from "../access.js"; +import type { TrinityClient } from "../client.js"; + +type Recorded = { method: string; path: string; body?: unknown }; + +function makeTools(calls: Recorded[], answer: unknown = { ok: true }) { + const fake: Partial = { + getBaseUrl: () => "http://localhost:8000", + request: async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }); + return answer as never; + }, + }; + return createSkillsTools(fake as TrinityClient, false); +} + +describe("skill gate tools (trinity-enterprise#753)", () => { + it("list_skill_gates reads the agent's map", async () => { + const calls: Recorded[] = []; + const answer = { agent_name: "fin bot", gates: [], approver_kinds: ["primary"] }; + const out = JSON.parse(await makeTools(calls, answer).listSkillGates.execute({ agent_name: "fin bot" }, {})); + assert.deepEqual(calls, [{ method: "GET", path: "/api/agents/fin%20bot/skill-gates", body: undefined }]); + assert.deepEqual(out, answer); + }); + + it("set_skill_gate sends only the fields given", async () => { + const calls: Recorded[] = []; + const tools = makeTools(calls); + await tools.setSkillGate.execute({ agent_name: "fin", skill_name: "pay-invoice" }, {}); + await tools.setSkillGate.execute({ agent_name: "fin", skill_name: "pay-invoice", deadline_hours: 12 }, {}); + await tools.setSkillGate.execute({ agent_name: "fin", skill_name: "pay-invoice", deadline_hours: null }, {}); + await tools.setSkillGate.execute({ agent_name: "fin", skill_name: "pay-invoice", approver: "approver" }, {}); + await tools.setSkillGate.execute( + { agent_name: "fin", skill_name: "pay-invoice", approver: "primary", agent: "other" } as never, {}); + assert.ok(calls.every(c => c.method === "PUT" && c.path === "/api/agents/fin/skill-gates/pay-invoice")); + assert.deepEqual(calls.map(c => c.body), [ + {}, + { deadline_hours: 12 }, + { deadline_hours: null }, + { approver: "approver" }, + { approver: "primary" }, + ]); + }); + + it("clear_skill_gate deletes the gate", async () => { + const calls: Recorded[] = []; + await makeTools(calls).clearSkillGate.execute({ agent_name: "fin", skill_name: "pay/invoice" }, {}); + assert.deepEqual(calls, [{ method: "DELETE", path: "/api/agents/fin/skill-gates/pay%2Finvoice", body: undefined }]); + }); + + it("the schema refuses what the backend would", () => { + const p = makeTools([]).setSkillGate.parameters; + const ok = (v: unknown) => p.safeParse({ agent_name: "fin", skill_name: "s", ...(v as object) }).success; + assert.ok(ok({})); + assert.ok(ok({ approver: "primary", deadline_hours: 1 })); + assert.ok(ok({ deadline_hours: 168 })); + assert.ok(ok({ deadline_hours: null })); + for (const bad of [ + { approver: "owner" }, { approver: 1 }, + { deadline_hours: 0 }, { deadline_hours: 169 }, { deadline_hours: 2.5 }, + { deadline_hours: true }, { deadline_hours: "24" }, + ]) { + assert.ok(!ok(bad), `refused: ${JSON.stringify(bad)}`); + } + }); + + it("list_skills carries the library's approval recommendation", async () => { + const tools = makeTools([], [ + { name: "deploy", description: "d", path: "p", approval: "recommended" }, + { name: "notes", description: "d", path: "p" }, + ]); + const out = JSON.parse(await tools.listSkills.execute({}, {})); + assert.deepEqual(out.skills.map((s: { approval: unknown }) => s.approval), ["recommended", null]); + }); + + it("every tool has an access-policy row and fits the description cap", () => { + const tools = makeTools([]); + for (const tool of [tools.listSkillGates, tools.setSkillGate, tools.clearSkillGate]) { + const row = TOOL_ACCESS_POLICY[tool.name]; + assert.ok(row, `${tool.name} has a policy row`); + assert.equal(row.kind, "baselined"); + assert.ok(tool.description.length <= 2048, `${tool.name}: ${tool.description.length}`); + } + assert.ok(tools.clearSkillGate.description.includes("never on the calling agent itself")); + assert.ok(tools.setSkillGate.description.includes("never on the calling agent itself")); + }); +}); diff --git a/src/mcp-server/src/tools/skills.ts b/src/mcp-server/src/tools/skills.ts index 2914d914f..00c101051 100644 --- a/src/mcp-server/src/tools/skills.ts +++ b/src/mcp-server/src/tools/skills.ts @@ -8,6 +8,8 @@ * - assign_skill_to_agent: Assign a skill (or `set:`) to an agent * - unassign_skill_set: Remove a skill set from an agent (ent#530) * - sync_agent_skills: Inject assigned skills to a running agent + * - list_skill_gates / set_skill_gate / clear_skill_gate: which skills on an + * agent need approval (trinity-enterprise#753) */ import { z } from "zod"; @@ -43,6 +45,8 @@ interface SkillInfo { // <= 200 chars), not a resolved skill name, and is null unless deprecated. deprecated?: boolean; superseded_by?: string | null; + // trinity-enterprise#753: `recommended` — gated by default where assigned. + approval?: string | null; } /** @@ -149,6 +153,8 @@ export function createSkillsTools( "A skill the library is retiring carries `deprecated: true` and stays assignable; " + "`superseded_by` is the author's note on what replaces it (free text, not checked " + "against the library; null unless deprecated). " + + "`approval: \"recommended\"` means the skill needs approval by default on every agent it is " + + "assigned to (see list_skill_gates); null otherwise. " + "Use get_skill to get the full content of a specific skill.", parameters: z.object({}), execute: async (_params: unknown, context?: { session?: McpAuthContext }) => { @@ -190,7 +196,10 @@ export function createSkillsTools( // ent#672: named here because this map drops anything it does // not name — the lifecycle keys would stop at REST otherwise. deprecated: s.deprecated ?? false, - superseded_by: s.superseded_by ?? null + superseded_by: s.superseded_by ?? null, + // trinity-enterprise#753: `recommended` — the skill is gated by + // default on every agent it is assigned to (list_skill_gates). + approval: s.approval ?? null })) }, null, 2); }, @@ -526,6 +535,103 @@ export function createSkillsTools( }, }, + // ======================================================================== + // Skill gates — which skills on an agent need approval (trinity-enterprise#753) + // ======================================================================== + listSkillGates: { + name: "list_skill_gates", + description: + "List the skill gates on an agent: which of its skills need approval before they run, " + + "who approves each (`approver`: `primary` = the agent's primary person, or `approver` where " + + "the install offers it — see `approver_kinds`), and the approval deadline in hours " + + "(null = the 24-hour default). `origin` is `set` (set by a person or an orchestrator) or " + + "`library_default` (the skills library recommends approval for that skill). " + + "`approver_reachable: false` means nobody fills that approver kind right now, so a gated " + + "request would be refused until someone does. `cleared_defaults` are library defaults the " + + "owner cleared. Called with an agent key, this reads the calling agent's OWN gates; an " + + "agent holding the skill-management permission may also read an agent its owner owns.", + parameters: z.object({ + agent_name: z.string().describe("Name of the agent"), + }), + execute: async ( + { agent_name }: { agent_name: string }, + context?: { session?: McpAuthContext } + ) => { + const apiClient = getClient(context?.session); + const result = await apiClient.request( + "GET", + `/api/agents/${encodeURIComponent(agent_name)}/skill-gates` + ); + return JSON.stringify(result, null, 2); + }, + }, + + setSkillGate: { + name: "set_skill_gate", + description: + "Require approval before a skill runs on an agent, or change who approves it and how long " + + "they have. Once gated, a request that invokes the skill on that agent waits for an approval " + + "(the approver can run it directly), and the agent itself cannot load the skill outside an " + + "approved run. Per agent: the same skill can be gated on one agent and not another. " + + "`approver` is `primary` (default) or `approver` where the install offers it; " + + "`deadline_hours` is 1 to 168, or null for the 24-hour default. On an existing gate, a field " + + "you omit keeps its value. Any well-formed skill name may be gated — one the agent does not " + + "have gates nothing until it does. Refused by name: `invalid_skill_name`, " + + "`invalid_approver`, `approver_unavailable`, `invalid_deadline`, `ephemeral_agent`. " + + "`warnings` may say `approver_unassigned` (nobody fills that kind yet). " + + "Needs the agent's owner or an admin; with an agent key, the skill-management permission, " + + "only on an agent its owner owns, and never on the calling agent itself.", + parameters: z.object({ + agent_name: z.string().describe("Name of the agent the skill runs on"), + skill_name: z.string().describe("Name of the skill to gate"), + approver: z.enum(["primary", "approver"]).optional() + .describe("Who approves: `primary` (default) or `approver`"), + deadline_hours: z.number().int().min(1).max(168).nullable().optional() + .describe("Hours an approver has (1-168); null for the 24-hour default"), + }), + execute: async ( + args: { agent_name: string; skill_name: string; approver?: string; deadline_hours?: number | null }, + context?: { session?: McpAuthContext } + ) => { + const apiClient = getClient(context?.session); + // Only the fields the caller sent — an omitted field keeps its stored value. + const body: Record = {}; + if (args.approver !== undefined) body.approver = args.approver; + if (args.deadline_hours !== undefined) body.deadline_hours = args.deadline_hours; + const result = await apiClient.request( + "PUT", + `/api/agents/${encodeURIComponent(args.agent_name)}/skill-gates/${encodeURIComponent(args.skill_name)}`, + body + ); + return JSON.stringify(result, null, 2); + }, + }, + + clearSkillGate: { + name: "clear_skill_gate", + description: + "Remove the approval requirement from a skill on an agent. Idempotent: `changed` is false " + + "when it was not gated. Clearing a library default on a skill the agent still holds keeps it " + + "off (`cleared: tombstoned`) until the skill is unassigned. Needs the agent's owner or an " + + "admin; with an agent key, the skill-management permission, only on an agent its owner owns, " + + "and never on the calling agent itself — an agent cannot lift its own gate.", + parameters: z.object({ + agent_name: z.string().describe("Name of the agent the skill runs on"), + skill_name: z.string().describe("Name of the gated skill"), + }), + execute: async ( + { agent_name, skill_name }: { agent_name: string; skill_name: string }, + context?: { session?: McpAuthContext } + ) => { + const apiClient = getClient(context?.session); + const result = await apiClient.request( + "DELETE", + `/api/agents/${encodeURIComponent(agent_name)}/skill-gates/${encodeURIComponent(skill_name)}` + ); + return JSON.stringify(result, null, 2); + }, + }, + // ======================================================================== // run_skill - Execute a library skill on the dedicated skill runner // ======================================================================== diff --git a/tests/registry.json b/tests/registry.json index ad7915e4c..c127ecb75 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1,5 +1,133 @@ { "test_files": [ + { + "file": "unit/test_3313_operator_queue_sync_error_logged.py", + "feature": "abilityai/trinity#3313", + "added": "2026-10-07", + "categories": [ + "backend", + "operator-queue" + ], + "description": "An exception from one agent's operator-queue sync must be logged with the agent name, not dropped by `_poll_cycle`'s gather(return_exceptions=True) (#3313). Drives the real `_poll_cycle` over two agents where one sync raises: both run, the failing one is logged at ERROR with its exception type, the healthy one is not. Mutation-proven: reverting the logging goes red. No Docker, no network." + }, + { + "file": "unit/test_2372_platform_alert_ack.py", + "feature": "#2372", + "added": "2026-10-07", + "categories": [ + "backend", + "operator-queue", + "migrations", + "unit" + ], + "description": "#2372: answering a platform-minted alert (reserved request_id prefix) lands it `acknowledged` + `acknowledged_at` through the real ask sink; an agent's own ask still lands `responded`; a gate- approval keeps disposition `answered`; Clear All hides the acknowledged alert. Sync loop over a pre-ent#499 file: a non-pending reserved entry is skipped with no #1631 WARNING and no writes, with its row gone; a pending reserved entry still WARNs and is never created. Heal migration `platform_alert_responded_heal`: flips stuck responded platform rows (incl. lookalike ` Poison-x`), leaves `valX1` and agent rows, idempotent, frozen prefix list equals the live tuple, registered on both tracks. No Docker." + }, + { + "file": "unit/test_3274_gate_context.py", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "backend", + "security", + "unit" + ], + "description": "trinity#3274 item 1: the free text a requester sets beside the request — a schedule's name, an MCP key's name, the requester's email, each rendered into the executor's system prompt — is scanned by the skill gate as given AND as the prompt renders it (`---` collapsed, the 80-char cut with `…`), apart from the request (never joined), shown on the approver's card only when it is what matched, never stored as the request or replayed. Driven through the execute_task backstop, /task and /chat; a parity test classifies every ExecutionContext string field as scanned or platform-controlled. Mutation-proven at each call site." + }, + { + "file": "unit/test_3274_gate_replay.py", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "backend", + "security", + "unit" + ], + "description": "trinity#3274 item 5: an approved /task or fan-out run is sent the caller's message as the message and its system prompt as the system prompt (frozen_replay), never their concatenation; a record frozen before the split replays its request text. Driven through the /task seam, the fan-out runner and the backstop into the real _dispatch_approved over the real row writer. Mutation-proven." + }, + { + "file": "unit/test_3274_gate_notice.py", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "backend", + "security", + "unit" + ], + "description": "trinity#3274 items 8 + 10 (#3233): outcome_delivery is the one rule for who is told the outcome (agent task / Inbox / none), carried on the 202 and its retry, and a requester nobody tells (a system key) is told so; _notify follows the same rule for every requester kind. A requester's notice never names the decider by email: a platform session or key reads the decider's display name, the Workspace and any unusable name or failed lookup read \"the agent's approver\". A held schedule row's error is the notice itself. Mutation-proven." + }, + { + "file": "unit/test_3274_gate_wiring.py", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "backend", + "security", + "unit" + ], + "description": "trinity#3274 item 4: the three skill-gate wiring lines, each reached through the code that runs it — get_current_user stamps is_event_loopback on the EVT-001 loopback token (never a person); main._start_maintenance_services (ast-sliced and executed against stub services) registers the ending observer; OperatorQueueSyncService._poll_cycle awaits skill_gate_service.sweep(). Deleting each line turns its test red." + }, + { + "file": "unit/test_3274_channel_quote.py", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "backend", + "security", + "unit" + ], + "description": "trinity#3274 item 2: a Telegram group reply is scanned with the quote line it carries (the Workspace twin scans its quote), through ChannelMessageRouter._run_agent_task; a reply to the bot quotes nothing, a one-to-one message is scanned as typed, and one helper renders the quote for the prompt and the scan. History, sender labels and the group title stay unscanned (decided). Mutation-proven." + }, + { + "file": "unit/test_3274_public_skipped_status.py", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "backend", + "api", + "unit" + ], + "description": "trinity#3274 item 9: GET /api/public/executions/{token}/{id}/status returns the skill gate's notice for a skipped row the public link started (triggered_by public), so PublicChat stops polling and shows it; a skipped row another trigger started, and a running row, carry no text. Mutation-proven." + }, + { + "file": "frontend/tests/unit/skillGate.spec.js", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "frontend", + "unit" + ], + "description": "trinity#3274: utils/skillGate.js — pendingApprovalMessage reads the server message off a 202 pending_approval (fallback the MCP wording) and is null for an ordinary async accept; isGateRefusal matches a named refusal by its X-Trinity-Error-Code header (plain object or get())." + }, + { + "file": "frontend/tests/unit/skillGateSenders.spec.js", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "frontend", + "unit" + ], + "description": "trinity#3274 item 9, MOUNTED: ChatPanel shows a held request's notice as a platform line, polls nothing, and leaves the held lines out of the next turn's history; PublicChat stops at a skipped turn and shows its notice instead of polling to a timeout; TasksPanel keeps a held or refused request as its own row (no server row exists); PlaybooksPanel and DashboardPanel hand the notice to the agent page's toast and open nothing; refusals are named, never an object. Mutation-proven per sender." + }, + { + "file": "frontend/tests/unit/mobileAdminSkillGate.spec.js", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "frontend", + "unit" + ], + "description": "trinity#3274 item 9, MOUNTED: /m chat shows a held request's notice as a platform line, polls nothing, keeps the held lines out of the next message's history, and names a refusal. Mutation-proven." + }, + { + "file": "frontend/tests/unit/agentDetailGateNotice.spec.js", + "feature": "#3274", + "added": "2026-10-07", + "categories": [ + "frontend", + "unit" + ], + "description": "trinity#3274 (@source-text-pin): AgentDetail hands its toast host (:notify) to PlaybooksPanel and DashboardPanel, and its toast has an info arm in the status-info family ahead of the red fallback." + }, { "file": "unit/test_ec_backlog_admission_edges.py", "feature": "edge-cases-2026-10-06", @@ -4944,6 +5072,73 @@ "unit" ], "description": "MCP list_operator_queue completeness (trinity-enterprise#815); real tool execute() over a fake TrinityClient, node:test. A broad agent-key read takes its permits first through the strict permissions read and passes {self} ∪ permitted as agent_names; a failed read is permissions_unavailable (retryable, no list call, no self-read substitute); over 500 names or an 8 KB request target is permit_set_too_large, never an unfiltered read. The output always carries count, total, has_more, next_cursor, next_offset and items; an older backend's missing fields become null with a warning; backend warnings pass through; a belt drop nulls total and warns. User and system scopes and scoped reads make no extra permissions call. cursor is opt-in (no cursor → offset 0 as before), passed through unchanged, and a two-page walk re-reads and re-sends agent_names on every page. The description states the contract within the 1,800-character headroom." + }, + { + "file": "unit/test_ent753_gate_storage.py", + "feature": "abilityai/trinity-enterprise#753", + "added": "2026-10-07", + "categories": [ + "backend", + "database", + "security", + "unit" + ], + "description": "trinity-enterprise#753: the agent_skill_gates table on both tracks (the registered SQLite entry run twice; the Alembic revision extends the single head), the PK in tables.py (ON CONFLICT on a tables.py-built database), the write merging unsent fields under the lock, writes only to a live agent, rename/soft-delete/purge through the AGENT_REFS cascade, facade signature parity, and the read seam: list_skill_gates skips tombstones and a failed read raises so read_gates refuses 503 (never {}); a gate written mixed-case holds through the real matcher." + }, + { + "file": "unit/test_ent753_gate_map_service.py", + "feature": "abilityai/trinity-enterprise#753", + "added": "2026-10-07", + "categories": [ + "backend", + "security", + "unit" + ], + "description": "trinity-enterprise#753: skill_gate_map_service over the real per-test DB with Docker, the library and the audit stubbed. Named refusals (name, approver, approver_unavailable on OSS, deadline incl. JSON true and \"24\", ghost 409); update semantics; tombstones only for assigned recommended skills; the state-driven reconcile (backfill, tighten-only, unreadable library retried, ghosts skipped, zero cost with nothing to do, a concurrent worker's insert audited once); a default kept until its package is gone (deferred removal, route report, start prune); the person-only drop of explicit gates (still-unassigned only, conflict kept); marker ordering (written before the first gate, removed after the last, never on a stopped agent, failed write saves with a warning) and two critical-section races held inside the lock; two partial PUTs at once keep both fields; audit who/from-where. Mutation-checked." + }, + { + "file": "unit/test_ent753_gate_routes.py", + "feature": "abilityai/trinity-enterprise#753", + "added": "2026-10-07", + "categories": [ + "backend", + "api", + "security", + "unit" + ], + "description": "trinity-enterprise#753: GET/PUT/DELETE /api/agents/{agent_name}/skill-gates over the real routes and dependencies (get_current_user overridden through the dependant tree). Write matrix: owner session/user key/admin/holder on a sibling pass; system, connector, ops, a non-holder agent, a holder on itself and a scopeless principal are refused 403 on the principal alone; an admin-owned holder on another user's agent and a non-owner person get the same 404 as a missing agent. Read matrix incl. byte-identical 404s and principal-only 403s. A machine key never reads set_by. Named codes and X-Trinity-Error-Code on the wire; PUT→GET round trip; census entries." + }, + { + "file": "unit/test_ent753_assignment_hooks.py", + "feature": "abilityai/trinity-enterprise#753", + "added": "2026-10-07", + "categories": [ + "backend", + "security", + "unit" + ], + "description": "trinity-enterprise#753: an AST guard that every agent_skills writer in src/backend (facade under any alias, the set service, by call or by reference) reaches the gate reconcile, with the discovered sites pinned and mutation cases; and behaviour through routers/skills.py, the start path and the library sweep over the real DB: a recommended skill is gated before its package is delivered (assign, replace, set assign, manual Sync), a person's completed unassign drops explicit gates while an agent's, the system key's or a deferred removal keeps them (no unassign-reassign laundering), defaults go only after the package is gone (route report or prune), and existing holders are backfilled on start and sweep." + }, + { + "file": "unit/test_ent753_approval_metadata.py", + "feature": "abilityai/trinity-enterprise#753", + "added": "2026-10-07", + "categories": [ + "backend", + "unit" + ], + "description": "trinity-enterprise#753: the approval: frontmatter key (top level or the trinity: block) parsed into the skill contract from the closed set {recommended}; YAML bool/int/date/mapping/list and unknown words are named frontmatter_invalid:approval and never raise; the value rides list_skills entries and SkillInfo." + }, + { + "file": "mcp-server/src/tools/skill-gates.test.ts", + "feature": "abilityai/trinity-enterprise#753", + "added": "2026-10-07", + "categories": [ + "mcp-server", + "typescript", + "unit" + ], + "description": "trinity-enterprise#753: list_skill_gates / set_skill_gate / clear_skill_gate reach their routes with names url-encoded; set_skill_gate sends only the fields given (an omitted field keeps its stored value, null resets the deadline, stray keys dropped); the zod schema refuses a bad approver or a deadline outside 1-168 (incl. bool/string); list_skills carries the library's approval recommendation; each tool has a TOOL_ACCESS_POLICY row and fits the 2,048-character description cap." } ] } diff --git a/tests/unit/_gate_world.py b/tests/unit/_gate_world.py new file mode 100644 index 000000000..6212d0574 --- /dev/null +++ b/tests/unit/_gate_world.py @@ -0,0 +1,121 @@ +"""The gated-skill test world for the trinity#3274 suites. + +The fixture body of ``test_ent751_gate_entries.world``, as a function: a real +per-test database (``db_harness``), the real ask sink, and a gate map holding +``pay-invoice`` on ``FIN``. Stubbed: the gate map itself (trinity-enterprise#753), +the in-container fingerprint exec, capacity, and the dispatch bodies past the +admission seams. Not a test module (no ``test_`` prefix), so nothing here is +collected on its own. +""" +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import services.ask_service as _ASK +import services.chat_execution_service as _CE +import services.dispatch_admission_service as _DISPATCH +import services.operator_queue_service as _OQS +import services.role_addressing as _ROLES +import services.skill_gate_service as _GATE +import services.task_execution_service as _TES +from database import db +from db_models import UserCreate +from models import ChatMessageRequest, ParallelTaskRequest, User +from services.rate_limiter import RateLimitResult + +OWNER, OWNER_EMAIL = "gate3274-owner", "owner-3274@example.com" +OTHER, OTHER_EMAIL = "gate3274-other", "other-3274@example.com" +REQ, FIN = "gate3274-mkt", "gate3274-fin" + + +def build(monkeypatch, *, owner_name=None): + db.create_user(UserCreate(username=OWNER, role="user", email=OWNER_EMAIL, name=owner_name)) + db.create_user(UserCreate(username=OTHER, role="user", email=OTHER_EMAIL)) + db.register_agent_owner(REQ, OWNER) + db.register_agent_owner(FIN, OWNER) + owner = db.get_user_by_username(OWNER) + other = db.get_user_by_username(OTHER) + + capacity = SimpleNamespace( + acquire=AsyncMock(return_value=SimpleNamespace(state="admitted", queue_position=None)), + release=AsyncMock()) + monkeypatch.setattr(_DISPATCH, "get_capacity_manager", lambda: capacity) + monkeypatch.setattr(_DISPATCH, "dispatch_breaker_active", lambda _n: False) + monkeypatch.setattr(_TES, "get_capacity_manager", lambda: capacity) + monkeypatch.setattr(_TES, "dispatch_breaker_active", lambda _n: False) + monkeypatch.setattr(_CE, "_dispatch_sync", AsyncMock(return_value={"status": "success"})) + monkeypatch.setattr(_CE, "_dispatch_async", AsyncMock(return_value={"status": "accepted"})) + + class _Audit: + async def log(self, **kw): + return "evt" + + class _WS: + async def broadcast(self, message): + json.loads(message) + + monkeypatch.setattr(_ASK, "platform_audit_service", _Audit()) + monkeypatch.setattr(_ASK, "_websocket_manager", _WS()) + monkeypatch.setattr(_ASK, "_observers", []) + monkeypatch.setattr(_ROLES, "owner_email", lambda agent: OWNER_EMAIL) + monkeypatch.setattr(_OQS, "_workspace_attachment", lambda agent, email, **_: (None, False)) + monkeypatch.setattr(_OQS.rate_limiter, "check", lambda *a, **k: RateLimitResult(True, 10, 0, 60)) + monkeypatch.setattr(_GATE.rate_limiter, "check", lambda *a, **k: RateLimitResult(True, 10, 0, 60)) + monkeypatch.setattr(db, "get_operator_resume_enabled", lambda agent: False, raising=False) + import services.platform_audit_service as _PAS + monkeypatch.setattr(_PAS, "platform_audit_service", _Audit()) + + async def _read(agent, names): + return {n: {"fingerprint": f"fp-{n}", "kind": "own"} for n in names} + + world = SimpleNamespace(owner_id=owner["id"], other_id=other["id"], capacity=capacity, + gates={"pay-invoice": _GATE.SkillGate(approver="primary")}, + woken=[]) + + async def _wake(record, text): + world.woken.append((record["source_agent"], text)) + + monkeypatch.setattr(_GATE, "list_skill_gates", lambda agent: world.gates if agent == FIN else {}) + monkeypatch.setattr(_GATE, "read_skill_fingerprints", _read) + monkeypatch.setattr(_GATE, "_wake_requester_agent", _wake) + return world + + +def agent_key(world, agent=REQ, **kw): + return User(id=world.owner_id, username=OWNER, email=OWNER_EMAIL, role="user", + agent_name=agent, mcp_scope="agent", mcp_key_id="k-" + agent, **kw) + + +def other_person(world, **kw): + """A signed-in person who is NOT the approver (the owner is).""" + return User(id=world.other_id, username=OTHER, email=OTHER_EMAIL, role="user", **kw) + + +def system_key(world): + return User(id=world.owner_id, username=OWNER, email=OWNER_EMAIL, role="admin", + mcp_scope="system", mcp_key_id="k-system", mcp_key_name="system") + + +def task(principal, message="/pay-invoice 100 EUR", idem="idem-task", **kw): + return asyncio.run(_CE.dispatch_parallel_task( + request=ParallelTaskRequest(message=message, **kw), + name=FIN, current_user=principal, container=SimpleNamespace(status="running"), + x_source_agent=None, x_via_mcp="true", idempotency_key=idem, + x_event_trigger=None, x_internal_secret=None)) + + +def chat(principal, message="/pay-invoice 100 EUR", idem="idem-chat"): + return asyncio.run(_DISPATCH.admit_chat_request( + name=FIN, request=ChatMessageRequest(message=message), current_user=principal, + x_source_agent=None, x_via_mcp="true", idempotency_key=idem)) + + +def execute(**kw): + kw.setdefault("agent_name", FIN) + kw.setdefault("triggered_by", "schedule") + return asyncio.run(_TES.TaskExecutionService().execute_task(**kw)) + + +def card(request_id): + return db.get_operator_queue_item_for_agent_by_request_id(FIN, request_id) diff --git a/tests/unit/_route_census.py b/tests/unit/_route_census.py index 0c9bbc227..85f8b689d 100644 --- a/tests/unit/_route_census.py +++ b/tests/unit/_route_census.py @@ -569,6 +569,16 @@ def _resolve_endpoint( # run's clearance; the agent comes from the key (get_self_agent), every person is refused. "routers/skill_gate.py::check_skill_invocation": ( "POST /api/skill-gate/check", "the in-container hook's check: own gates and own run only, agent from the key (get_self_agent)"), + # trinity-enterprise#753: the per-agent skill gate map. The writes take the #3236 shape + # (require_person_or_capability("skills.manage", self_person_only=True)): a person, or a + # skills.manage holder on an agent its owner owns — never itself; then the owner fence. + # The read: an agent key reads its own gates (the hook pulls), a holder an agent its owner owns. + "routers/skill_gate.py::list_agent_skill_gates": ( + "GET /api/agents/{agent_name}/skill-gates", "ent#753 read: anyone with access; an agent key its own gates, a skills.manage holder an agent its owner owns"), + "routers/skill_gate.py::set_agent_skill_gate": ( + "PUT /api/agents/{agent_name}/skill-gates/{skill_name}", "ent#753 skills.manage USE on an owner-owned agent, never itself; persons pass assert_person + owner fence"), + "routers/skill_gate.py::clear_agent_skill_gate": ( + "DELETE /api/agents/{agent_name}/skill-gates/{skill_name}", "ent#753 skills.manage USE on an owner-owned agent, never itself; persons pass assert_person + owner fence"), # ent#703: the agent's pull loop reads its own switch every cycle with its own key. # The write (PUT .../git/pull-sync) is a setting, so it is person-only. "routers/git.py::get_pull_sync_config": ( diff --git a/tests/unit/test_2372_platform_alert_ack.py b/tests/unit/test_2372_platform_alert_ack.py new file mode 100644 index 000000000..de6bfa4a9 --- /dev/null +++ b/tests/unit/test_2372_platform_alert_ack.py @@ -0,0 +1,245 @@ +"""#2372 — acknowledging a platform-minted alert is its terminal event. + +A row whose `request_id` carries a reserved platform prefix +(`operator_queue_service._RESERVED_ID_PREFIXES`) has no agent audience: the +agent never asked, is not waiting, and never acknowledges it. Before this fix an +operator's answer parked such a row in `responded` for the 90-day floor, and a +pre-ent#499 agent file still carrying it was refused at every boot with the +#1631 impersonation WARNING. + +Pinned here: + * answering a platform-minted row lands it `acknowledged` (+ `acknowledged_at`); + an agent's own ask still lands `responded`; a skill-gate approval keeps the + `answered` ending its resolver reads; + * the acknowledged row is cleared by Clear All; + * a leftover platform entry in the agent's file that is no longer `pending` + is skipped without the WARNING, with or without its row, and writes nothing; + a `pending` reserved entry still gets the WARNING and is never created; + * the heal migration moves stuck `responded` platform rows to `acknowledged` + once, matches ids the way `is_platform_minted` does, and is registered on + both tracks. +""" +import asyncio +import json +import logging +import os +import sys +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +_BACKEND = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "src", "backend")) +if _BACKEND not in sys.path: + sys.path.insert(0, _BACKEND) + +pytestmark = pytest.mark.unit + +import services.operator_queue_service as oqs # noqa: E402 +from services import ask_service # noqa: E402 +from services.operator_queue_service import OperatorQueueSyncService # noqa: E402 +from services.rate_limiter import RateLimitResult # noqa: E402 + + +# --------------------------------------------------------------------------- +# AC 1 + AC 5 — the answer sink, against the unit island's real SQLite +# --------------------------------------------------------------------------- + +class TestAnswerSink: + @pytest.fixture + def agent(self): + return f"agent-2372-{uuid.uuid4().hex[:6]}" + + @pytest.fixture + def real_db(self): + from database import db + return db + + def _create(self, real_db, agent, rid, *, type_="alert", options=None): + item = {"id": rid, "type": type_, "status": "pending", "title": "t", "question": "q"} + if options is not None: + item["options"] = options + real_db.create_operator_queue_item(agent, item) + return real_db.get_operator_queue_item_for_agent_by_request_id(agent, rid) + + def _answer(self, row, response="acknowledged"): + return ask_service.answer(row, response=response, response_text=None, + actor=ask_service.Actor(email="op@example.com")).rows[0] + + def test_a_platform_alert_lands_acknowledged(self, real_db, agent): + row = self._create(real_db, agent, f"base-image-stale-{agent}-20260731") + out = self._answer(row) + assert out["status"] == "acknowledged" + assert out["acknowledged_at"] + assert out["responded_by_email"] == "op@example.com" + assert out["disposition"] == "answered" + assert real_db.get_operator_queue_item(row["id"])["status"] == "acknowledged" + + def test_an_agents_own_ask_still_lands_responded(self, real_db, agent): + row = self._create(real_db, agent, "mine-1", type_="question") + out = self._answer(row, response="yes") + assert out["status"] == "responded" + assert not out.get("acknowledged_at") + + def test_a_gate_approval_keeps_the_answered_ending(self, real_db, agent): + # skill_gate_service.resolve reads `disposition` first, then maps + # responded/acknowledged alike — the status change cannot reach it. + row = self._create(real_db, agent, f"gate-{agent}-1", type_="approval", + options=["Approve", "Deny"]) + out = self._answer(row, response="Approve") + assert out["status"] == "acknowledged" + assert out["disposition"] == "answered" + assert out["response"] == "Approve" + + def test_clear_all_hides_an_acknowledged_platform_alert(self, real_db, agent): + row = self._create(real_db, agent, f"queue-flood-{agent}-x") + self._answer(row) + assert real_db.clear_resolved_operator_queue_items(agent_name=agent) == 1 + assert real_db.get_operator_queue_item(row["id"])["cleared_at"] + + +# --------------------------------------------------------------------------- +# AC 2 + AC 3 — the sync loop against a pre-ent#499 file +# --------------------------------------------------------------------------- + +def _fake_db(index=None): + db = MagicMock() + db.count_operator_queue_pending_for_agent.return_value = 0 + db.get_operator_queue_responded_for_agent.return_value = [] + db.get_operator_queue_terminal_for_agent.return_value = [] + db.get_operator_queue_sync_index_for_agent.return_value = index or { + "open": [], "terminal": {}, "foreign": []} + db.set_operator_queue_sync_state.return_value = False + db.set_operator_queue_delivery_state.return_value = False + db.create_operator_queue_item_with_outcome.side_effect = ( + lambda agent, item, **kw: (db.create_operator_queue_item(agent, item, **kw), True)) + db.mark_operator_queue_unconfirmed.return_value = 0 + db.refresh_operator_queue_last_confirmed.return_value = 0 + db.get_setting_value.return_value = "24" + return db + + +def _wire(monkeypatch, db, requests): + monkeypatch.setattr(oqs, "db", db) + state = {"content": json.dumps({"requests": requests}), "writes": []} + client = MagicMock() + + async def _read(path, timeout=5.0): + return {"success": True, "content": state["content"]} + + async def _write(path, content, **kw): + state["writes"].append(json.loads(content)) + state["content"] = content + return {"success": True} + + client.read_file = AsyncMock(side_effect=_read) + client.write_file = AsyncMock(side_effect=_write) + monkeypatch.setattr(oqs, "AgentClient", lambda name: client) + monkeypatch.setattr(oqs.rate_limiter, "check", lambda *a, **k: RateLimitResult(True, 10, 0, 60)) + oqs.reset_alert_budget_state() + return OperatorQueueSyncService(), state + + +def _impersonation_warnings(caplog): + return [r for r in caplog.records + if r.levelno >= logging.WARNING and "reserved platform prefix" in r.getMessage()] + + +_LEFTOVER = {"id": "base-image-stale-trinity-system-20260731", "status": "responded", + "title": "Stale base image", "question": "q", "response": "acknowledged"} + + +class TestLeftoverFileEntry: + def test_no_warning_and_no_write_with_the_row_gone(self, monkeypatch, caplog): + # The row was pruned by retention; the entry the old write-back left stays. + db = _fake_db() + svc, state = _wire(monkeypatch, db, [dict(_LEFTOVER)]) + caplog.set_level(logging.DEBUG, logger=oqs.logger.name) + for _ in range(2): + asyncio.run(svc._sync_agent("trinity-system")) + assert _impersonation_warnings(caplog) == [] + db.create_operator_queue_item.assert_not_called() + db.mark_operator_queue_acknowledged.assert_not_called() + assert state["writes"] == [] + + def test_an_entry_the_agent_flipped_to_acknowledged_touches_nothing(self, monkeypatch, caplog): + db = _fake_db() + svc, state = _wire(monkeypatch, db, [{**_LEFTOVER, "status": "acknowledged"}]) + caplog.set_level(logging.DEBUG, logger=oqs.logger.name) + asyncio.run(svc._sync_agent("trinity-system")) + assert _impersonation_warnings(caplog) == [] + db.mark_operator_queue_acknowledged.assert_not_called() + db.set_operator_queue_sync_state.assert_not_called() + assert state["writes"] == [] + + def test_a_pending_reserved_entry_is_still_an_impersonation(self, monkeypatch, caplog): + db = _fake_db() + svc, _ = _wire(monkeypatch, db, [{"id": "poison-fake", "status": "pending", + "title": "t", "question": "q"}]) + caplog.set_level(logging.DEBUG, logger=oqs.logger.name) + asyncio.run(svc._sync_agent("agent-x")) + assert len(_impersonation_warnings(caplog)) == 1 + db.create_operator_queue_item.assert_not_called() + + +# --------------------------------------------------------------------------- +# AC 4 — the heal migration +# --------------------------------------------------------------------------- + +class TestHealMigration: + @pytest.fixture + def conn(self): + import sqlite3 + conn = sqlite3.connect(":memory:") + conn.execute("""CREATE TABLE operator_queue ( + id TEXT PRIMARY KEY, agent_name TEXT, request_id TEXT, type TEXT, + status TEXT, created_at TEXT, acknowledged_at TEXT)""") + return conn + + def _add(self, conn, id_, rid, status="responded"): + conn.execute("INSERT INTO operator_queue (id, agent_name, request_id, type, status, created_at) " + "VALUES (?, 'a', ?, 'alert', ?, '2026-07-31T00:00:00Z')", (id_, rid, status)) + + def _rows(self, conn): + return {i: (s, bool(a)) for i, s, a in + conn.execute("SELECT id, status, acknowledged_at FROM operator_queue")} + + def test_moves_stuck_platform_rows_to_acknowledged(self, conn): + from db.migrations import _migrate_platform_alert_responded_heal + self._add(conn, "stale", "base-image-stale-trinity-system-1") + self._add(conn, "lookalike", " Poison-x") # legacy row; is_platform_minted says platform + self._add(conn, "val", "val_abc") + self._add(conn, "own-val", "valX1") # `_` is a literal, never a wildcard + self._add(conn, "own", "mine-1") + self._add(conn, "pending", "queue-flood-a-1", status="pending") + + _migrate_platform_alert_responded_heal(conn.cursor(), conn) + + rows = self._rows(conn) + assert rows["stale"] == ("acknowledged", True) + assert rows["lookalike"] == ("acknowledged", True) + assert rows["val"] == ("acknowledged", True) + assert rows["own-val"] == ("responded", False) + assert rows["own"] == ("responded", False) + assert rows["pending"] == ("pending", False) + + def test_is_idempotent(self, conn): + from db.migrations import _migrate_platform_alert_responded_heal + self._add(conn, "stale", "sync-failing-a-1") + _migrate_platform_alert_responded_heal(conn.cursor(), conn) + first = conn.execute("SELECT acknowledged_at FROM operator_queue").fetchone()[0] + _migrate_platform_alert_responded_heal(conn.cursor(), conn) + assert conn.execute("SELECT acknowledged_at FROM operator_queue").fetchone()[0] == first + + def test_frozen_prefixes_cover_every_live_reserved_prefix(self): + # The heal's list is frozen on purpose (a one-time fix), so it must be + # complete on the day it ships. + from db.migrations import PLATFORM_ALERT_HEAL_PREFIXES + assert set(PLATFORM_ALERT_HEAL_PREFIXES) == set(oqs._RESERVED_ID_PREFIXES) + + def test_is_registered_on_both_tracks(self): + from pathlib import Path + from db.migrations import MIGRATIONS + assert "platform_alert_responded_heal" in [name for name, _ in MIGRATIONS] + versions = Path(_BACKEND) / "migrations" / "versions" + assert any("platform_alert_responded_heal" in p.name for p in versions.glob("*.py")) diff --git a/tests/unit/test_2523_loops_terminal_driven.py b/tests/unit/test_2523_loops_terminal_driven.py index c5687c7b4..ec919026f 100644 --- a/tests/unit/test_2523_loops_terminal_driven.py +++ b/tests/unit/test_2523_loops_terminal_driven.py @@ -173,15 +173,19 @@ def start_loop_run(self, lid, run_number, *, execution_id=None): return rid def finalize_loop_run(self, rid, **kw): + # CAS on status='running', as the real one (#3316). for runs in self.runs.values(): for r in runs: if r["id"] == rid: + if r["status"] != "running": + return False for k, v in kw.items(): if k == "execution_id" and v is None: continue r[k] = v r["completed_at"] = "now" - return + return True + return False def list_loop_runs(self, lid): return [dict(r) for r in sorted(self.runs.get(lid, []), key=lambda r: r["run_number"])] diff --git a/tests/unit/test_2915_operator_queue_sync_honesty.py b/tests/unit/test_2915_operator_queue_sync_honesty.py index f2bf5ceb0..1487e4b19 100644 --- a/tests/unit/test_2915_operator_queue_sync_honesty.py +++ b/tests/unit/test_2915_operator_queue_sync_honesty.py @@ -1063,7 +1063,8 @@ def _respond(item_id, response, response_text, responded_by_id, responded_by_ema resp = c.post("/api/operator-queue/op-2/respond", json={"response": "approve", "acknowledge_divergence": True}) assert resp.status_code == 200, resp.text - assert recorded == {"divergence_acknowledged": True} + # #2372: an agent's own ask is not terminal on answer. + assert recorded == {"divergence_acknowledged": True, "terminal": False} def test_the_portal_answer_records_the_acknowledgement_on_the_row(self, real_db, monkeypatch): import client_portal.service as portal_service diff --git a/tests/unit/test_3245_async_replay_after_failure.py b/tests/unit/test_3245_async_replay_after_failure.py index 4a011c531..1424242d8 100644 --- a/tests/unit/test_3245_async_replay_after_failure.py +++ b/tests/unit/test_3245_async_replay_after_failure.py @@ -35,8 +35,11 @@ def store(monkeypatch, tmp_path): monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 't3245.db'}") from db import engine as eng eng.dispose_engines() - from db.tables import metadata, idempotency_keys, schedule_executions - metadata.create_all(eng.get_engine(), tables=[idempotency_keys, schedule_executions]) + from db.tables import metadata, idempotency_keys, schedule_executions, agent_skill_gates + # trinity-enterprise#753: the dispatch gate reads the skill gate map; an + # unreadable map refuses (503), so the table must exist (empty = ungated). + metadata.create_all(eng.get_engine(), tables=[idempotency_keys, schedule_executions, + agent_skill_gates]) from db.idempotency import IdempotencyOperations from db.schedules import ScheduleOperations yield SimpleNamespace( diff --git a/tests/unit/test_3274_channel_quote.py b/tests/unit/test_3274_channel_quote.py new file mode 100644 index 000000000..5b7b8d8cb --- /dev/null +++ b/tests/unit/test_3274_channel_quote.py @@ -0,0 +1,107 @@ +""" +Gated skills — a Telegram reply's quoted message is part of the request +(trinity#3274, item 2; the Workspace twin is `client_portal/service.py`, +`request_text=reply_prefix + client_text`). + +In a group, a tagged reply puts the quoted message in front of the executor +(`[Replying to Bob: "…"]`, `telegram_group_context.reply_quote_line`). Replying +"@bot do this" to an untagged `/pay-invoice 100 EUR` used to dispatch with the +command only in the quote, unscanned. The gate now reads the quote line plus +what the sender typed — never the group's history block (decided: one past +mention would hold every later tagged turn; the in-container hook is the +backstop), and never the sender label or the group title. + +Driven through `ChannelMessageRouter._run_agent_task` with the dispatch stubbed +where its lazy import finds it (`test_ent751_gate_callers` harness). +""" +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +pytestmark = pytest.mark.unit + +BOT_ID = "77" + + +@pytest.fixture +def dispatch(monkeypatch): + import services.task_execution_service as tes + from services.skill_gate_errors import SkillApprovalRequired + + mock = AsyncMock(side_effect=SkillApprovalRequired( + request_id="gate-q", agent_name="finance", skills=["pay-invoice"], + approver_role="primary", expires_at=None)) + monkeypatch.setattr(tes, "dispatch_and_await_terminal", mock) + return mock + + +def _router(monkeypatch): + import adapters.message_router as mr + + monkeypatch.setattr(mr, "get_task_execution_service", lambda: MagicMock()) + monkeypatch.setattr(mr, "build_public_channel_caller_prompt", lambda *a, **kw: None) + monkeypatch.setattr(mr, "build_voice_capability_prompt", lambda *a, **kw: None) + monkeypatch.setattr(mr, "_get_channel_allowed_tools", lambda: ["WebSearch"]) + monkeypatch.setattr(mr.db, "get_public_channel_model", lambda agent: None) + channel = MagicMock() + channel.get_source_identifier.return_value = "telegram:9001:424242" + channel.send_response = AsyncMock() + channel.indicate_done = AsyncMock() + return mr, channel, mr.ChannelMessageRouter.__new__(mr.ChannelMessageRouter) + + +def _group_reply(text, quoted, *, quoted_from=None): + raw = {"from": {"id": 5, "first_name": "Ada", "username": "ada"}, + "reply_to_message": {"text": quoted, + "from": quoted_from or {"id": 6, "first_name": "Bob", "username": "bob"}}} + return SimpleNamespace(text=text, channel_id="-100", thread_id=None, files=[], sender_id="5", + metadata={"raw_message": raw, "bot_id": BOT_ID, "chat_title": "Ops", + "username": "ada"}) + + +async def _run(router, channel, message, *, is_group=True): + return await router._run_agent_task( + channel, message, "finance", "bot-token", "telegram", is_group, + container=None, upload_dir=None, + context_prompt="[composed prompt: sender, history, quote, text]", + verified_email=None, image_data=[]) + + +@pytest.mark.asyncio +async def test_a_group_reply_is_scanned_with_the_message_it_quotes(monkeypatch, dispatch): + _, channel, router = _router(monkeypatch) + await _run(router, channel, _group_reply("@finance_bot do this", "/pay-invoice 100 EUR")) + scanned = dispatch.await_args.kwargs["request_text"] + assert scanned == '[Replying to Bob (@bob): "/pay-invoice 100 EUR"]\n@finance_bot do this' + channel.send_response.assert_awaited_once() # the held notice is the reply + + +@pytest.mark.asyncio +async def test_a_reply_to_the_bot_itself_quotes_nothing(monkeypatch, dispatch): + _, channel, router = _router(monkeypatch) + msg = _group_reply("thanks", "/pay-invoice done", quoted_from={"id": int(BOT_ID), "is_bot": True}) + await _run(router, channel, msg) + assert dispatch.await_args.kwargs["request_text"] == "thanks" + + +@pytest.mark.asyncio +async def test_a_one_to_one_message_is_scanned_as_typed(monkeypatch, dispatch): + """Outside a group the prompt carries no quote line, so neither does the scan.""" + _, channel, router = _router(monkeypatch) + await _run(router, channel, _group_reply("hello", "/pay-invoice 100 EUR"), is_group=False) + assert dispatch.await_args.kwargs["request_text"] == "hello" + + +def test_the_scanned_quote_is_the_line_the_prompt_carries(): + """One helper renders the quote for both the prompt and the scan.""" + import adapters.message_router as mr + + msg = _group_reply("@finance_bot do this", "/pay-invoice 100 EUR") + assert mr._reply_quote(msg) in mr._format_group_sender(msg).splitlines() diff --git a/tests/unit/test_3274_gate_context.py b/tests/unit/test_3274_gate_context.py new file mode 100644 index 000000000..b76cad2e0 --- /dev/null +++ b/tests/unit/test_3274_gate_context.py @@ -0,0 +1,159 @@ +""" +Gated skills — the text a requester controls beside the request (trinity#3274, item 1). + +The executor's system prompt carries the schedule's name, the MCP key's name and +the requester's email next to the request (`platform_prompt_service` +`_render_triggered_by` / `_render_schedule_line`). Each is free text the +requester sets, so the gate scans it too — a schedule named `/pay-invoice +weekly` with a neutral message used to dispatch every tick unapproved. It is +scanned as given AND as the prompt renders it (the renderer collapses `---` and +truncates with `…`, both of which can make a name appear), apart from the request +(never joined to it), shown on the approver's card when it is what matched, and +never replayed as the approved run's message. + +Driven through each seam that reads it: the `execute_task` backstop (schedules), +`/task` (`chat_execution_service.dispatch_parallel_task`) and `/chat` +(`dispatch_admission_service.admit_chat_request`). +""" +import asyncio +import dataclasses +import sys +import typing +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from db_harness import db_backend # noqa: E402,F401 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # `_gate_world`, as `_route_census` + +import _gate_world as W # noqa: E402 +import services.skill_gate_service as _GATE # noqa: E402 +from database import db # noqa: E402 +from utils.skill_invocation import find_gated_invocations # noqa: E402 + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def world(db_backend, monkeypatch): + return W.build(monkeypatch) + + +def _schedule_row(schedule_id="sched-3274"): + from db.write_params import ExecutionSource + return db.create_schedule_execution(schedule_id, W.FIN, "Do the weekly run", "schedule", + ExecutionSource()) + + +# --------------------------------------------------------------------------- +# The seams +# --------------------------------------------------------------------------- + +def test_a_schedule_whose_name_invokes_a_gated_skill_asks_though_its_message_does_not(world): + row = _schedule_row() + with pytest.raises(_GATE.SkillApprovalRequired) as info: + W.execute(message="Do the weekly run", execution_id=row.id, + schedule_context={"name": "/pay-invoice weekly", "cron": "0 3 * * 1"}) + record = db.get_gate_request(info.value.request_id) + # The request is what the schedule sends; the name is context, never replayed. + assert record["request_text"] == "Do the weekly run" + question = W.card(info.value.request_id)["question"] + assert f"Also sent to {W.FIN} with this request:\nSchedule name: /pay-invoice weekly" in question + + +def test_a_neutral_schedule_name_and_message_dispatch_as_before(world, monkeypatch): + reached = [] + + async def _stop(self, **kw): + reached.append(kw) + from types import SimpleNamespace + return False, SimpleNamespace(stopped=True) + + # The class `W.execute` runs (the db fixture re-imports modules per test). + monkeypatch.setattr(W._TES.TaskExecutionService, "_admission_gate", _stop) + W.execute(message="Do the weekly run", execution_id=_schedule_row("sched-n").id, + schedule_context={"name": "Weekly run", "cron": "0 3 * * 1"}) + assert reached and db.count_pending_gate_requests(W.FIN) == 0 + + +def test_task_from_a_key_named_after_a_gated_skill_asks(world): + principal = W.agent_key(world, mcp_key_name="/pay-invoice bot") + with pytest.raises(_GATE.SkillApprovalRequired) as info: + W.task(principal, message="weekly report") + question = W.card(info.value.request_id)["question"] + assert "MCP key name: /pay-invoice bot" in question + assert db.get_gate_request(info.value.request_id)["request_text"] == "weekly report" + + +def test_chat_from_a_key_named_after_a_gated_skill_asks(world): + with pytest.raises(_GATE.SkillApprovalRequired): + W.chat(W.agent_key(world, mcp_key_name="/pay-invoice bot"), message="weekly report") + + +def test_a_requester_email_that_invokes_a_gated_skill_asks(world): + with pytest.raises(_GATE.SkillApprovalRequired): + W.execute(message="hello", triggered_by="public", source_user_email="/pay-invoice@example.com") + + +def test_the_card_carries_no_context_line_when_the_request_itself_names_the_skill(world): + principal = W.agent_key(world, mcp_key_name="/pay-invoice bot") + with pytest.raises(_GATE.SkillApprovalRequired) as info: + W.task(principal, message="/pay-invoice 100 EUR") + assert "Also sent to" not in W.card(info.value.request_id)["question"] + + +# --------------------------------------------------------------------------- +# What is scanned +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name", [ + "run /pay---invoice", # the renderer collapses `---` to `-` + "x" * 66 + " /pay-invoicexyz", # cut at 79 chars + `…`, which NFKC reads as `...` +]) +def test_the_value_is_scanned_as_the_prompt_renders_it(name): + assert find_gated_invocations(name, {"pay-invoice"}) == [] # the raw value misses + context = _GATE.requester_context_text(schedule_name=name) + assert find_gated_invocations(context, {"pay-invoice"}) == ["pay-invoice"] + + +def test_request_and_context_are_scanned_apart_never_joined(): + """Joined, `/pay` + `-invoice` would spell a gated name neither part holds.""" + async def _go(): + return await _GATE.enforce( + W.FIN, request_text="see /pay", context_text="-invoice", + requester=_GATE.Requester(kind="other", key="t"), triggered_by="agent", + gates={"pay-invoice": _GATE.SkillGate()}) + assert asyncio.run(_go()).ungated + + +def test_every_free_text_field_the_prompt_renders_is_scanned_or_named_platform_controlled(): + """A new `ExecutionContext` field the executor reads must be classified here: + scanned by `requester_context_text`, or platform-controlled with a reason.""" + from services.platform_prompt_service import ExecutionContext + + scanned = {"schedule_name", "source_mcp_key_name", "source_user_email"} + platform = { + "agent_name": "slug, validated at creation", + "mode": "derived from triggered_by", + "triggered_by": "a platform trigger label", + "source_agent_name": "an agent slug", + "model": "a model id the platform resolved", + "schedule_cron": "validated cron", + "schedule_next_run": "a timestamp the scheduler computed", + "platform_url": "platform config", + "timestamp": "the clock", + "execution_id": "a platform id", + "primary_user_display": "assignment provider display name (ent#500)", + "role_id": "an assignment id", + } + strings = {f.name for f in dataclasses.fields(ExecutionContext) + if typing.get_type_hints(ExecutionContext)[f.name] == typing.Optional[str]} + assert strings == scanned | set(platform), strings ^ (scanned | set(platform)) + context = _GATE.requester_context_text(schedule_name="/a", mcp_key_name="/b", + source_email="/c@example.com") + assert find_gated_invocations(context, {"a", "b", "c"}) == ["a", "b", "c"] diff --git a/tests/unit/test_3274_gate_notice.py b/tests/unit/test_3274_gate_notice.py new file mode 100644 index 000000000..a99ccc903 --- /dev/null +++ b/tests/unit/test_3274_gate_notice.py @@ -0,0 +1,177 @@ +""" +Gated skills — what the requester is told (trinity#3274 items 8 and 10, trinity#3233). + +* **Where the outcome goes.** `outcome_delivery` is the ONE rule for whether the + backend tells a requester the outcome: a task to a requesting agent, an Inbox + notice to a person, nothing to anyone else (a system key, a connector, a + schedule, a channel user, a public visitor, a paid caller). The pending answer + carries it, and its message says so when nothing will come back — it used to + promise nothing while the MCP layer promised delivery to everyone (#3233). +* **Never the approver's email.** The requester's notice named the decider by + email, including to an external Workspace client. Now: the decider's display + name for a request a platform session or key made, "the agent's approver" for + everyone else and whenever no usable name exists. + +Driven through `enforce`, the `/task` seam, the real ask sink and `resolve`. +""" +import asyncio +import sys +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from db_harness import db_backend # noqa: E402,F401 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # `_gate_world`, as `_route_census` + +import _gate_world as W # noqa: E402 +import services.ask_service as _ASK # noqa: E402 +import services.skill_gate_service as _GATE # noqa: E402 +from config import PORTAL_SOURCE_CHANNEL # noqa: E402 +from database import db # noqa: E402 +from services.skill_gate_errors import SkillApprovalRequired # noqa: E402 + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def world(db_backend, monkeypatch): + return W.build(monkeypatch, owner_name="Dana Lee") + + +# --------------------------------------------------------------------------- +# #3233 — the pending answer says whether anything will come back +# --------------------------------------------------------------------------- + +def test_a_system_key_is_told_where_the_outcome_will_be_and_promised_nothing(world): + with pytest.raises(SkillApprovalRequired) as info: + W.task(W.system_key(world)) + body = info.value.detail() + assert body["outcome_delivery"] == "none" + assert body["message"].endswith( + f"Nothing will be sent back when it is decided: if it is approved, it runs as a new " + f"execution on {W.FIN}; if not, nothing runs.") + assert info.value.request_id in body["message"] + + +@pytest.mark.parametrize("principal, delivery", [ + (W.agent_key, "agent_task"), + (W.other_person, "inbox"), +]) +def test_a_requester_the_backend_will_tell_is_not_told_otherwise(world, principal, delivery): + with pytest.raises(SkillApprovalRequired) as info: + W.task(principal(world)) + assert info.value.detail()["outcome_delivery"] == delivery + assert "Nothing will be sent back" not in info.value.message + + +def test_a_retry_of_the_same_request_keeps_its_delivery(world): + for _ in range(2): + with pytest.raises(SkillApprovalRequired) as info: + W.task(W.system_key(world), idem="idem-retry") + assert info.value.detail()["outcome_delivery"] == "none" + + +def test_a_schedule_rows_skipped_text_is_the_notice_itself(world): + from db.write_params import ExecutionSource + row = db.create_schedule_execution("s1", W.FIN, "/pay-invoice", "schedule", ExecutionSource()) + with pytest.raises(SkillApprovalRequired) as info: + W.execute(message="/pay-invoice", execution_id=row.id) + assert db.get_execution(row.id).error == info.value.message + + +_KINDS = [ + dict(requester_kind="agent", source_agent=W.REQ), + dict(requester_kind="person", requester_email="asker-3274@example.com"), + dict(requester_kind="schedule"), dict(requester_kind="channel", requester_email="c@example.com"), + dict(requester_kind="public", requester_email="v@example.com"), dict(requester_kind="paid"), + dict(requester_kind="connector"), dict(requester_kind="other"), +] + + +@pytest.mark.parametrize("kind", _KINDS, ids=[k["requester_kind"] for k in _KINDS]) +def test_notify_tells_exactly_the_requesters_the_rule_names(world, kind): + rid = f"gate-kind-{kind['requester_kind']}" + rec, _ = db.create_gate_request(request_id=rid, agent_name=W.FIN, skills=["pay-invoice"], + request_text="/pay-invoice", requester_key="k:" + rid, + dispatch={"triggered_by": "agent"}, **kind) + asyncio.run(_GATE._notify(rec, "denied", decider=W.OWNER_EMAIL)) + told = bool(world.woken) or bool(_notes()) + rule = _GATE.outcome_delivery(rec["requester_kind"], agent_name=rec.get("source_agent"), + email=rec.get("requester_email")) + assert told == (rule != "none"), (rule, world.woken, _notes()) + + +# --------------------------------------------------------------------------- +# Item 8 — the decider is never named by email +# --------------------------------------------------------------------------- + +def _notes(): + from db.engine import get_engine + from sqlalchemy import text + with get_engine().connect() as conn: + return [dict(r._mapping) for r in conn.execute( + text("SELECT addressed_to_email, question FROM operator_queue " + "WHERE agent_name = :a AND request_id LIKE :p"), + {"a": W.FIN, "p": _GATE.NOTE_PREFIX + "%"})] + + +def _decide(rid, response=_GATE.REJECT, before_resolve=None): + row = db.get_operator_queue_item_for_agent_by_request_id(W.FIN, rid) + _ASK.answer(row, response=response, response_text=None, actor=_ASK.Actor(email=W.OWNER_EMAIL)) + if before_resolve: + before_resolve() + asyncio.run(_GATE.resolve(rid)) + (note,) = _notes() + return note["question"] + + +def test_a_platform_requester_reads_the_deciders_display_name(world): + with pytest.raises(SkillApprovalRequired) as info: + W.task(W.other_person(world)) + text = _decide(info.value.request_id) + assert "rejected by Dana Lee;" in text + assert "@" not in text + + +def test_a_decider_with_no_usable_name_is_the_agents_approver(world): + db.update_user(W.OWNER, {"name": "dana@example.com"}) # an address is not a name + with pytest.raises(SkillApprovalRequired) as info: + W.task(W.other_person(world)) + text = _decide(info.value.request_id) + assert "rejected by the agent's approver;" in text and "@" not in text + + +def test_a_workspace_client_reads_the_agents_approver_even_when_a_name_exists(world): + requester = _GATE.Requester(kind="person", key="person:client@example.com", + email="client@example.com") + + async def _go(): + await _GATE.enforce(W.FIN, request_text="/pay-invoice 2 EUR", requester=requester, + triggered_by="public", gates=world.gates, + # A user id too: the Workspace arm must decide on its own, + # not ride on the missing-id arm before it. + dispatch={"triggered_by": "public", "source_channel": PORTAL_SOURCE_CHANNEL, + "source_user_id": world.other_id, + "source_user_email": "client@example.com"}) + with pytest.raises(SkillApprovalRequired) as info: + asyncio.run(_go()) + text = _decide(info.value.request_id) + assert "rejected by the agent's approver;" in text + assert "Dana Lee" not in text and W.OWNER_EMAIL not in text + + +def test_an_unreadable_account_falls_back_to_the_agents_approver(world, monkeypatch): + with pytest.raises(SkillApprovalRequired) as info: + W.task(W.other_person(world)) + + def _boom(email): + raise RuntimeError("db down") + + text = _decide(info.value.request_id, + before_resolve=lambda: monkeypatch.setattr(db, "get_user_by_email", _boom)) + assert "rejected by the agent's approver;" in text diff --git a/tests/unit/test_3274_gate_replay.py b/tests/unit/test_3274_gate_replay.py new file mode 100644 index 000000000..26cd359c2 --- /dev/null +++ b/tests/unit/test_3274_gate_replay.py @@ -0,0 +1,161 @@ +""" +Gated skills — an approved run is sent the way it was asked (trinity#3274, item 5). + +`/task` and fan-out scan the caller's message and system prompt together (both +reach the executor), but the approved run used to receive that JOIN as its +message: the command could appear twice and the caller's system prompt arrived +as user text. The seams now freeze the two apart (`frozen_replay`), and +`_dispatch_approved` sends the message as the message and the system prompt as +the system prompt. A record frozen before this change replays its request text, +as before. + +Driven: the `/task` seam (`chat_execution_service.dispatch_parallel_task`), the +fan-out runner (`FanOutService.execute` → `execute_task`), the `execute_task` +backstop, and the real `_dispatch_approved` over the real row writer. +""" +import asyncio +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from db_harness import db_backend # noqa: E402,F401 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # `_gate_world`, as `_route_census` + +import _gate_world as W # noqa: E402 +import services.skill_gate_service as _GATE # noqa: E402 +from database import db # noqa: E402 + +pytestmark = pytest.mark.unit + +_DISPATCH_APPROVED = _GATE._dispatch_approved + + +@pytest.fixture +def world(db_backend, monkeypatch): + world = W.build(monkeypatch) + import services.capacity_manager as _CM + import services.chat_execution_service as _CE + import services.task_execution_service as _TES + world.spawned = [] + cap = SimpleNamespace(calls=[]) + + async def _acquire(**kw): + cap.calls.append(kw) + return SimpleNamespace(state="admitted", queue_position=None) + + cap.acquire = _acquire + monkeypatch.setattr(_CM, "get_capacity_manager", lambda: cap) + monkeypatch.setattr(_TES, "dispatch_breaker_active", lambda n: False) + + async def _run_async_task(**kw): + world.spawned.append(kw) + + monkeypatch.setattr(_CE, "run_async_task", _run_async_task) + return world + + +def _approve_run(request_id, execution_id): + async def _go(): + await _DISPATCH_APPROVED(db.get_gate_request(request_id), execution_id) + await asyncio.sleep(0) + asyncio.run(_go()) + + +def test_an_approved_task_gets_its_message_and_its_system_prompt_apart(world): + with pytest.raises(_GATE.SkillApprovalRequired) as info: + W.task(W.agent_key(world), message="/pay-invoice 100 EUR", + system_prompt="Answer in one line.", user_message="/pay-invoice 100 EUR") + rid = info.value.request_id + _approve_run(rid, "exec-replay-1") + request = world.spawned[0]["request"] + assert request.message == "/pay-invoice 100 EUR" + assert request.system_prompt == "Answer in one line." + assert db.get_execution("exec-replay-1").message == "/pay-invoice 100 EUR" + # The approver still saw everything that reached the executor — once each. + card = W.card(rid)["question"] + assert card.count("/pay-invoice 100 EUR") == 1 and "Answer in one line." in card + + +def test_the_card_shows_exactly_what_the_approved_run_sends(world): + """#3274 review: the card sanitised the JOIN while the run sent each part + sanitised alone. A credential pattern spanning the line between them + (`… Basic` + `/transfer+…`) redacted the system prompt from the card only, + so Approve ran an instruction the approver never saw.""" + world.gates = {"transfer": _GATE.SkillGate()} + with pytest.raises(_GATE.SkillApprovalRequired) as info: + W.task(W.agent_key(world), message="Weekly report. Use auth Basic", + system_prompt="/transfer+9000+EUR+to+acct+666") + rid = info.value.request_id + question = W.card(rid)["question"] + _approve_run(rid, "exec-card-1") + request = world.spawned[0]["request"] + assert request.message in question + assert f"{_GATE.SYSTEM_PROMPT_LABEL}\n{request.system_prompt}" in question + + +def test_an_approved_task_without_a_system_prompt_sends_none(world): + with pytest.raises(_GATE.SkillApprovalRequired) as info: + W.task(W.agent_key(world), message="/pay-invoice 7 EUR") + _approve_run(info.value.request_id, "exec-replay-2") + assert world.spawned[0]["request"].system_prompt is None + + +def test_a_record_frozen_before_the_split_replays_its_request_text(world): + rec, _ = db.create_gate_request( + request_id="gate-legacy", agent_name=W.FIN, skills=["pay-invoice"], + request_text="/pay-invoice 1\nAnswer in one line.", requester_kind="agent", + requester_key=f"agent:{W.REQ}", source_agent=W.REQ, dispatch={"triggered_by": "agent"}) + _approve_run("gate-legacy", "exec-legacy") + assert world.spawned[0]["request"].message == "/pay-invoice 1\nAnswer in one line." + + +def test_a_backstop_producer_passes_its_replay_through_execute_task(world): + """Fan-out reaches the gate through the backstop; its replay rides + `execute_task(gate_replay=...)` onto the record.""" + row = db.create_task_execution(agent_name=W.FIN, message="weekly report", triggered_by="fan_out") + with pytest.raises(_GATE.SkillApprovalRequired) as info: + W.execute(message="weekly report", triggered_by="fan_out", execution_id=row.id, + request_text="weekly report\nFirst run /pay-invoice 3 EUR.", + system_prompt="First run /pay-invoice 3 EUR.", + gate_replay=_GATE.frozen_replay("weekly report", "First run /pay-invoice 3 EUR.")) + _approve_run(info.value.request_id, "exec-fan-replay") + request = world.spawned[0]["request"] + assert (request.message, request.system_prompt) == ("weekly report", "First run /pay-invoice 3 EUR.") + + +def test_frozen_replay_sanitises_each_part_and_omits_a_missing_system_prompt(): + out = _GATE.frozen_replay("call it with Bearer " + "x" * 24, None) # a sanitiser pattern + assert set(out) == {_GATE.REPLAY_MESSAGE} + assert "x" * 24 not in out[_GATE.REPLAY_MESSAGE] + + +def test_fan_out_freezes_each_subtask_with_the_batch_prompt_apart(monkeypatch): + """The runner line itself: each subtask hands `execute_task` its own replay.""" + from unittest.mock import MagicMock + from services import fan_out_service as fos + from unit.test_ent751_gate_callers import _FanOutDB # the #751 fan-out harness + + fake_db = _FanOutDB() + calls = [] + + class _TaskService: + async def execute_task(self, **kw): + calls.append(kw) + fake_db.rows[kw["execution_id"]].update(status="success", response="ok") + return MagicMock(status="success", execution_id=kw["execution_id"], error_code=None) + + monkeypatch.setattr(fos, "db", fake_db) + monkeypatch.setattr(fos, "get_task_execution_service", lambda: _TaskService()) + monkeypatch.setitem(sys.modules, "database", SimpleNamespace(db=fake_db)) + fos._inflight_batches.clear() + asyncio.run(fos.FanOutService().execute( + agent_name=W.FIN, max_concurrency=1, timeout_seconds=5, system_prompt="Be brief.", + tasks=[fos.FanOutTaskInput(id="a", message="weekly report")])) + assert calls[0]["gate_replay"] == _GATE.frozen_replay("weekly report", "Be brief.") diff --git a/tests/unit/test_3274_gate_wiring.py b/tests/unit/test_3274_gate_wiring.py new file mode 100644 index 000000000..a9cc22323 --- /dev/null +++ b/tests/unit/test_3274_gate_wiring.py @@ -0,0 +1,140 @@ +""" +Gated skills — the three wiring lines the #3208 validation found unpinned +(trinity#3274, item 4). Each test reaches the LINE, through the code that runs it: + +1. `dependencies.get_current_user` stamps `is_event_loopback=bool(loopback)` on + the EVT-001 loopback principal. Without it the loopback (which resolves to + `admin`) is a person and could approve its own gated request. +2. `main._start_maintenance_services` calls `register_ending_observer()`. + Without it an approval is recorded and nothing runs until the sweep. +3. `OperatorQueueSyncService._poll_cycle` awaits `skill_gate_service.sweep()`. + Without it an ending no observer saw is never consumed. + +Mutation-proved: deleting each line turns its test red. +""" +import ast +import asyncio +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# 1. dependencies.py — the loopback flag +# --------------------------------------------------------------------------- + +def _resolve(token, *, method="POST", path="/api/agents/orch/task"): + """`test_ent614_source_agent_attribution._resolve_principal`.""" + import dependencies as dep + + req = SimpleNamespace(method=method, scope={"path": path}) + with patch.object(dep, "db") as db: + db.get_user_by_username.return_value = { + "id": 1, "username": "admin", "email": "admin@example.com", "role": "admin", + "suspended_at": None} + return asyncio.run(dep.get_current_user(req, token)) + + +def test_the_loopback_token_resolves_to_a_principal_that_is_never_a_person(): + from services import event_dispatch_service as eds + from services.skill_gate_service import requester_from_principal + + user = _resolve(eds._get_internal_token("worker-a")) + assert user.is_event_loopback is True + requester = requester_from_principal(user) + assert requester.is_person is False and requester.key == "event-loopback" + + +def test_an_ordinary_session_token_is_a_person(): + from datetime import datetime, timedelta + + from config import ALGORITHM, SECRET_KEY + from jose import jwt + from services.skill_gate_service import requester_from_principal + + tok = jwt.encode({"sub": "admin", "exp": datetime.utcnow() + timedelta(minutes=5)}, + SECRET_KEY, algorithm=ALGORITHM) + user = _resolve(tok, method="GET", path="/api/agents") + assert user.is_event_loopback is False + assert requester_from_principal(user).is_person is True + + +# --------------------------------------------------------------------------- +# 2. main.py — the ending observer is registered at startup +# --------------------------------------------------------------------------- + +def _maintenance_phase(): + """`_start_maintenance_services` sliced out of `main.py` and compiled on + its own: importing `main` builds the whole app (and needs the OTel + exporter). Its globals are stub services plus the real `logger`.""" + source = (_BACKEND / "main.py").read_text(encoding="utf-8") + fns = [n for n in ast.parse(source).body + if isinstance(n, ast.AsyncFunctionDef) and n.name == "_start_maintenance_services"] + assert len(fns) == 1, "main.py no longer defines _start_maintenance_services" + module = ast.Module(body=fns, type_ignores=[]) + started = [] + stub = lambda name: SimpleNamespace(start=lambda: started.append(name)) # noqa: E731 + namespace = { + "logger": MagicMock(), + "log_archive_service": stub("log_archive"), + "audit_retention_service": stub("audit_retention"), + "db_vacuum_service": stub("db_vacuum"), + "db_backup_service": stub("db_backup"), + "operator_queue_service": stub("operator_queue"), + } + exec(compile(module, str(_BACKEND / "main.py"), "exec"), namespace) + return namespace["_start_maintenance_services"], started, namespace["logger"] + + +def test_startup_registers_the_skill_gate_ending_observer(monkeypatch): + import services.ask_service as ask_service + import services.skill_gate_service as sgs + + monkeypatch.setattr(ask_service, "_observers", []) + phase, started, logger = _maintenance_phase() + asyncio.run(phase()) + assert "operator_queue" in started # the slice really ran + assert ask_service._observers == [sgs.on_ending] + logger.error.assert_not_called() + + +# --------------------------------------------------------------------------- +# 3. operator_queue_service.py — the poll cycle runs the sweep +# --------------------------------------------------------------------------- + +def test_every_leader_poll_cycle_runs_the_skill_gate_sweep(monkeypatch): + import services + import services.operator_queue_service as oqs + + sweep = AsyncMock() + fake_gate = SimpleNamespace(sweep=sweep) + # `_poll_cycle` does `from services import skill_gate_service` at call time: + # own both the package attribute and the `sys.modules` key (#1446 / #1595). + monkeypatch.setattr(services, "skill_gate_service", fake_gate, raising=False) + monkeypatch.setitem(sys.modules, "services.skill_gate_service", fake_gate) + + db = MagicMock() + db.mark_operator_queue_unconfirmed.return_value = 0 + db.mark_operator_queue_undelivered_for_stopped_agents.return_value = [] + monkeypatch.setattr(oqs, "db", db) + monkeypatch.setattr(oqs, "ask_service", SimpleNamespace(expire=lambda: SimpleNamespace(rows=[]))) + monkeypatch.setitem(sys.modules, "services.docker_service", + SimpleNamespace(agent_container_states=lambda: {})) + ws = MagicMock() + ws.broadcast = AsyncMock() + monkeypatch.setattr(oqs, "_websocket_manager", ws) + svc = oqs.OperatorQueueSyncService() + svc._try_acquire_leadership = lambda: True + svc._sync_agent = AsyncMock() + + asyncio.run(svc._poll_cycle()) + sweep.assert_awaited_once() diff --git a/tests/unit/test_3274_public_skipped_status.py b/tests/unit/test_3274_public_skipped_status.py new file mode 100644 index 000000000..e4b867d22 --- /dev/null +++ b/tests/unit/test_3274_public_skipped_status.py @@ -0,0 +1,62 @@ +""" +Gated skills — a public-link visitor's held request ends the wait (trinity#3274, item 9). + +The public link's async path never answers 202: it pre-creates the row and the +`execute_task` backstop holds the request in the background, closing the row +`skipped` with the gate's notice. The status route returned no text for a +`skipped` row, and `PublicChat.vue` polled it for 30 minutes before saying +"Request timed out". It now returns the notice for a `skipped` row the public +link itself started (`triggered_by == "public"`) — the same access check as +every other status read (the link token, and the row on the link's agent). + +Driven: `routers.public.public_execution_status`, with the link check, the rate +limit and the row read stubbed. +""" +import asyncio +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +pytestmark = pytest.mark.unit + +NOTICE = "Not run: the skill pay-invoice on finance needs approval before it can run." + + +def _status(monkeypatch, *, status, triggered_by="public", agent="finance", record=None): + import routers.public as pub + + row = SimpleNamespace(id="e1", agent_name=agent, status=status, triggered_by=triggered_by, + response=None, error=NOTICE) + monkeypatch.setattr(pub, "_get_client_ip", lambda request: "1.2.3.4") + monkeypatch.setattr(pub, "check_public_link_rate_limit", lambda ip: None) + monkeypatch.setattr(pub, "_validate_public_link", lambda token: {"agent_name": "finance"}) + monkeypatch.setattr(pub.db, "get_execution", lambda eid: row) + monkeypatch.setattr(pub.db, "get_gate_requests_by_origin_executions", + lambda ids: {"e1": record} if record else {}) + return asyncio.run(pub.public_execution_status("tok", "e1", SimpleNamespace())) + + +def test_a_held_public_turn_answers_with_the_gates_notice(monkeypatch): + out = _status(monkeypatch, status="skipped", record={"state": "pending"}) + assert (out["status"], out["error"], out["gate"]) == ("skipped", NOTICE, "held") + + +def test_a_refused_public_turn_is_named_a_refusal_not_a_wait(monkeypatch): + """A refusal creates no pending record; the page shows it as an error.""" + out = _status(monkeypatch, status="skipped", record=None) + assert (out["error"], out["gate"]) == (NOTICE, "refused") + + +def test_a_skipped_row_another_trigger_started_says_nothing_here(monkeypatch): + out = _status(monkeypatch, status="skipped", triggered_by="schedule", record={"state": "pending"}) + assert (out["error"], out["gate"]) == (None, None) + + +def test_a_running_row_still_carries_no_text(monkeypatch): + assert _status(monkeypatch, status="running")["error"] is None diff --git a/tests/unit/test_3313_operator_queue_sync_error_logged.py b/tests/unit/test_3313_operator_queue_sync_error_logged.py new file mode 100644 index 000000000..5ffc86cf8 --- /dev/null +++ b/tests/unit/test_3313_operator_queue_sync_error_logged.py @@ -0,0 +1,75 @@ +""" +#3313 — an agent's sync failure is logged, not silently dropped. + +`_poll_cycle` runs every running agent's `_sync_agent` under +`asyncio.gather(..., return_exceptions=True)`. That keeps one agent's failure +from cancelling the others, but it also discarded the exception with no log +line, so a sync that raised every 5 s (an unhashable id in the agent's file) +stopped that agent's answer delivery with nothing in the logs. + +Driven through the real `OperatorQueueSyncService._poll_cycle`; only its +collaborators (leadership, expiry, skill-gate sweep, Docker, db) are stubbed. +The per-entry fixes are pinned in `test_ec_operator_queue_edges.py` +(`TestUnhashableEntryValues`). +""" +from __future__ import annotations + +import asyncio +import logging +import os +import sys +import types +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("sqlalchemy") + +os.environ.setdefault("REDIS_URL", "redis://u:p@localhost:6379") +os.environ.setdefault("SECRET_KEY", "test-secret") + +_BACKEND = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "src", "backend")) +if _BACKEND not in sys.path: + sys.path.insert(0, _BACKEND) + +import services # noqa: E402 +import services.docker_service as docker_service # noqa: E402 +import services.operator_queue_service as oqs # noqa: E402 + + +def _wire(monkeypatch, synced): + svc = oqs.OperatorQueueSyncService() + monkeypatch.setattr(svc, "_try_acquire_leadership", lambda: True) + monkeypatch.setattr(oqs.ask_service, "expire", lambda: MagicMock(rows=[])) + + async def _sweep(): + return None + gate = types.SimpleNamespace(sweep=_sweep) + monkeypatch.setitem(sys.modules, "services.skill_gate_service", gate) + monkeypatch.setattr(services, "skill_gate_service", gate, raising=False) + + monkeypatch.setattr(docker_service, "agent_container_states", + lambda: {"bad": "running", "good": "running"}) + db = MagicMock() + db.mark_operator_queue_unconfirmed.return_value = 0 + db.mark_operator_queue_undeliverable_not_running.return_value = [] + monkeypatch.setattr(oqs, "db", db) + + async def _sync_agent(name): + synced.append(name) + if name == "bad": + raise TypeError("unhashable type: 'list'") + monkeypatch.setattr(svc, "_sync_agent", _sync_agent) + return svc + + +def test_a_failing_agent_sync_is_logged_with_the_agent_name(monkeypatch, caplog): + synced = [] + svc = _wire(monkeypatch, synced) + with caplog.at_level(logging.ERROR, logger=oqs.logger.name): + asyncio.run(svc._poll_cycle()) + + assert sorted(synced) == ["bad", "good"] # one failure cancels no other agent + errors = [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR] + assert any("'bad'" in m and "TypeError" in m for m in errors), errors + assert not any("'good'" in m for m in errors), errors diff --git a/tests/unit/test_ec_loop_service_edges.py b/tests/unit/test_ec_loop_service_edges.py index 57f89d177..e6e0c3d2b 100644 --- a/tests/unit/test_ec_loop_service_edges.py +++ b/tests/unit/test_ec_loop_service_edges.py @@ -845,14 +845,6 @@ def test_m62_missing_counts_as_not_live(self, env, eid): class TestRestartMidAdvance: - @pytest.mark.xfail( - strict=True, - reason=( - "BUG: a fault between claim_loop_advance and finalize_loop_run strands " - "the loop forever — runs_completed moved, run row still 'running', and " - f"neither a redelivered terminal nor reconcile_after_restart can re-claim — #3316" - ), - ) def test_m56_fault_after_claim_is_recoverable_on_restart(self, env): ls, db, ts = env calls = {"n": 0} @@ -880,6 +872,29 @@ def _fault_once(*a, **k): assert loop["status"] in ("completed", "completed_with_errors", "stopped", "failed") \ or len(runs) >= 2 + def test_m56_repair_that_loses_the_close_does_not_dispatch(self, env): + """#3316: the repair path must stay exactly-once. A repairing delivery + whose run-row close is beaten by another advance (closed between its read + and its write) must not dispatch run 2 — only the closer does.""" + ls, db, ts = env + calls = {"n": 0} + + def _hook(run_id, **kw): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("database is locked") # strands run 1 + if calls["n"] == 2: + # A concurrent advance closes run 1 first. + db.ops.finalize_loop_run(run_id, **kw) + + db.hooks["finalize_loop_run"] = _hook + ts.script = [{"response": "r1"}, "queued", "queued"] + lid = _start(ls, max_runs=3) + + assert _run(ls.LoopService().advance_on_terminal("exec_1")) is False + assert [r["run_number"] for r in _runs(db, lid)] == [1] + assert len(ts.calls) == 1 + def test_m56_control_fault_after_finalize_run_is_rearmed(self, env): """Control for m56: the same fault one write LATER (update_loop_progress) leaves no open run, so reconcile re-arms and the loop completes.""" diff --git a/tests/unit/test_ec_loop_service_properties.py b/tests/unit/test_ec_loop_service_properties.py index fa913c668..c8954b1de 100644 --- a/tests/unit/test_ec_loop_service_properties.py +++ b/tests/unit/test_ec_loop_service_properties.py @@ -304,10 +304,15 @@ def start_loop_run(self, lid, n, *, execution_id=None): return rid def finalize_loop_run(self, rid, **kw): + # CAS on status='running', as the real one (#3316). for runs in self.runs.values(): for r in runs: if r["id"] == rid: + if r["status"] != "running": + return False r.update({k: v for k, v in kw.items() if not (k == "execution_id" and v is None)}) + return True + return False def list_loop_runs(self, lid): return [dict(r) for r in sorted(self.runs.get(lid, []), key=lambda r: r["run_number"])] diff --git a/tests/unit/test_ec_operator_queue_edges.py b/tests/unit/test_ec_operator_queue_edges.py index a48881d70..5db4b8e63 100644 --- a/tests/unit/test_ec_operator_queue_edges.py +++ b/tests/unit/test_ec_operator_queue_edges.py @@ -565,9 +565,6 @@ def _harness(): import test_2915_operator_queue_sync_honesty as h return h - @pytest.mark.xfail(strict=True, reason="#3313: " + BUG.format( - "an unhashable `priority` (list/object) makes `_clamp_ingested_item` raise TypeError, " - "breaking its never-raises contract; the ask is quarantined and never surfaced")) @pytest.mark.parametrize("priority", [ pytest.param(["high"], id="r145-priority-list"), pytest.param({"level": "high"}, id="r146-priority-object"), @@ -584,9 +581,6 @@ def test_the_clamp_never_raises_on_an_unhashable_priority(self, priority): out = oqs._clamp_ingested_item({"id": "u-1", "status": "pending", "priority": priority}) assert out["priority"] == "medium" - @pytest.mark.xfail(strict=True, reason="#3313: " + BUG.format( - "one pending entry with an unhashable `id` raises out of `_sync_agent` before the " - "write-back, so NONE of that agent's answers are delivered, silently, every cycle")) def test_an_unhashable_id_does_not_stop_the_agents_answers(self, monkeypatch): """r147. REACHABILITY: in `_sync_agent`'s loop the reserved-prefix, native, seen and terminal checks are each `isinstance(req_id, str)`- @@ -606,9 +600,6 @@ def test_an_unhashable_id_does_not_stop_the_agents_answers(self, monkeypatch): asyncio.run(svc._sync_agent("a")) client.write_file.assert_awaited_once() - @pytest.mark.xfail(strict=True, reason="#3313: " + BUG.format( - "rewriting an INGESTED entry's priority to a list raises out of `changed_fields`, " - "halting the agent's whole sync (no answers delivered) every cycle, silently")) def test_an_unhashable_priority_rewrite_is_a_change_not_a_crash(self, monkeypatch): """r148. REACHABILITY: for an entry whose id matches an open pending row, `_sync_agent` calls `changed_fields(row, req)` outside any try; diff --git a/tests/unit/test_ent530_skill_sets.py b/tests/unit/test_ent530_skill_sets.py index 47959edef..f708cb8b3 100644 --- a/tests/unit/test_ent530_skill_sets.py +++ b/tests/unit/test_ent530_skill_sets.py @@ -840,8 +840,13 @@ def test_agent_start_reconciles_sets_before_reading_the_names(monkeypatch): AsyncMock(side_effect=lambda *a, **k: calls.append("inject") or {"success": True})) monkeypatch.setattr(lifecycle.skill_service, "reconcile_agent_skills", AsyncMock(side_effect=lambda *a, **k: calls.append("prune") or {})) + # trinity-enterprise#753: library-default gates follow the rows the set + # reconcile just wrote, before the names are read for injection. + from services import skill_gate_map_service + monkeypatch.setattr(skill_gate_map_service, "reconcile_library_gates", + AsyncMock(side_effect=lambda *a, **k: calls.append("gates"))) asyncio.run(lifecycle.inject_assigned_skills(AGENT)) - assert calls[:3] == ["sets", "names", "inject"] and "prune" in calls + assert calls[:4] == ["sets", "gates", "names", "inject"] and "prune" in calls def test_manual_inject_reconciles_sets_then_injects_then_prunes(app_client, monkeypatch): diff --git a/tests/unit/test_ent751_gate_callers.py b/tests/unit/test_ent751_gate_callers.py index eb5706c3d..878e989f1 100644 --- a/tests/unit/test_ent751_gate_callers.py +++ b/tests/unit/test_ent751_gate_callers.py @@ -439,12 +439,16 @@ def start_loop_run(self, lid, run_number, *, execution_id=None): return rid def finalize_loop_run(self, rid, **kw): + # CAS on status='running', as the real one (#3316). for runs in self.runs.values(): for r in runs: if r["id"] == rid: + if r["status"] != "running": + return False r.update({k: v for k, v in kw.items() if not (k == "execution_id" and v is None)}) r["completed_at"] = "now" - return + return True + return False def list_loop_runs(self, lid): return [dict(r) for r in sorted(self.runs.get(lid, []), key=lambda r: r["run_number"])] diff --git a/tests/unit/test_ent751_gate_http_mapping.py b/tests/unit/test_ent751_gate_http_mapping.py index 3ff891795..6aa9c88c9 100644 --- a/tests/unit/test_ent751_gate_http_mapping.py +++ b/tests/unit/test_ent751_gate_http_mapping.py @@ -80,7 +80,21 @@ def test_the_notice_names_no_role_and_promises_only_a_decision(): from services.skill_gate_errors import SkillApprovalRequired e = SkillApprovalRequired(request_id="gate-x", agent_name="testfix", skills=["pay-invoice"], - approver_role="primary", expires_at=None) + approver_role="primary", expires_at=None, outcome_delivery="inbox") assert e.message == ("Not run: the skill pay-invoice on testfix needs approval before it " "can run. Request gate-x is waiting for a decision.") assert e.detail()["approver_role"] == "primary" # the role stays machine-readable + + +def test_a_requester_nobody_will_tell_is_told_so_and_where_the_outcome_shows(): + """trinity#3233: the default is "none" — an answer that does not know who + will be told never promises a delivery.""" + from services.skill_gate_errors import SkillApprovalRequired + + e = SkillApprovalRequired(request_id="gate-x", agent_name="testfix", skills=["pay-invoice"], + approver_role="primary", expires_at=None) + assert e.message == ("Not run: the skill pay-invoice on testfix needs approval before it " + "can run. Request gate-x is waiting for a decision. Nothing will be " + "sent back when it is decided: if it is approved, it runs as a new " + "execution on testfix; if not, nothing runs.") + assert e.detail()["outcome_delivery"] == "none" diff --git a/tests/unit/test_ent751_skill_invocation.py b/tests/unit/test_ent751_skill_invocation.py index 3110f5cbd..abf1b67aa 100644 --- a/tests/unit/test_ent751_skill_invocation.py +++ b/tests/unit/test_ent751_skill_invocation.py @@ -38,6 +38,34 @@ ("\u202e/pay-invoice", ["pay-invoice"]), # bidi override before the slash ("/deploy.prod now", ["deploy.prod"]), ("/deploy.prod.", ["deploy.prod"]), + # trinity#3274: `-`, `_` and a digit before the slash no longer make a path, + # and a trailing `_`/`-` (markdown emphasis, a dash) ends the name. + ("-/pay-invoice", ["pay-invoice"]), + ("_/pay-invoice_", ["pay-invoice"]), + ("1/pay-invoice", ["pay-invoice"]), + ("then /pay-invoice- done", ["pay-invoice"]), + # trinity#3274: every Default_Ignorable code point is invisible to a model. + ("/\u00adpay-invoice", ["pay-invoice"]), # soft hyphen after the slash + ("/\u180epay-invoice", ["pay-invoice"]), # Mongolian vowel separator + ("/\u034fpay-invoice", ["pay-invoice"]), # combining grapheme joiner + ("/\ufe0fpay-invoice", ["pay-invoice"]), # variation selector 16 + ("/\U000e0100pay-invoice", ["pay-invoice"]), # variation selector 17 + ("/\U000e0020pay-invoice", ["pay-invoice"]), # tag space + ("/pay\u00ad-invoice", ["pay-invoice"]), # soft hyphen inside the name + # ... and stripping one must never join a letter to the slash: a model + # reads `run\u00ad/x` as `run /x` just as easily as `run/x`. + ("run\u00ad/pay-invoice", ["pay-invoice"]), + ("a\ufe0f/pay-invoice", ["pay-invoice"]), + ("x\u2065/pay-invoice", ["pay-invoice"]), + ("run\u200b/pay-invoice", ["pay-invoice"]), # missed before #3274 + ("/pay-invoice\u00adx", ["pay-invoice"]), # the name ends at the invisible + ("run\u00ad/pay\u200d-invoice", ["pay-invoice"]), # one before the slash, one in the name + # A run with no letter or digit right after a dot still ends the name, as + # in #751 (only `.x`, or `_`/`-` runs into a letter, continue it). + ("/pay-invoice...now", ["pay-invoice"]), + ("/pay-invoice\u2026now", ["pay-invoice"]), # an ellipsis (NFKC: `...`) + ("run /pay-invoice..then", ["pay-invoice"]), + ("/pay-invoice._x", ["pay-invoice"]), ], ) def test_invocations_that_name_a_gated_skill_are_found(text, expected): @@ -51,6 +79,9 @@ def test_invocations_that_name_a_gated_skill_are_found(text, expected): "/pay-invoice-v2", # a different skill "/pay-invoice.v2", # a different skill (a dot followed by a name char) "/pay_invoice", # a different skill + "/pay-invoice_v2", # a different skill (an underscore run continues the name) + "/pay-invoice--v2", # a different skill (a hyphen run continues the name) + "/deploy.prod-staging", # a different skill "/deploy.production", # a different skill "docs/pay-invoice", # a path, not an invocation "https://example.com/pay-invoice", @@ -81,3 +112,40 @@ def test_an_empty_gate_set_matches_nothing(): def test_none_text_matches_nothing(): assert find_gated_invocations(None, GATED) == [] + + +def test_a_long_run_of_invisibles_is_matched_in_linear_time(): + """#3274 review: the before-slash rule rescanned a run of invisibles from + every offset (50k took ~10 s, on the event loop, from a public-link + message). A run is now matched only from its start.""" + import time + + find_gated_invocations("\u200b" * 2_000 + "x", GATED) # warm the cache + start = time.perf_counter() + find_gated_invocations("\u200b" * 50_000 + "x", GATED) + assert time.perf_counter() - start < 1.0 + + +# The #751 matcher, kept as the reference: #3274 may only WIDEN what matches +# (a false negative runs a gated skill unapproved). +import re as _re +import unicodedata as _ud + +_INVISIBLE_751 = _re.compile("[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]") + + +def _matched_by_751(text, name): + norm = lambda t: _INVISIBLE_751.sub("", _ud.normalize("NFKC", t)).casefold() # noqa: E731 + return bool(_re.search(r"(? bool: + for node in ast.walk(fn): + if isinstance(node, ast.Attribute) and node.attr in HOOKS: + return True + if isinstance(node, ast.Name) and node.id in HOOKS: + return True + return False + + +def _aliases(tree: ast.Module): + """Names the set service and the DB facade are bound to in this module, and + set-service functions imported by name (`from services.skill_set_service + import x as y`).""" + mods, fns, dbs = {"skill_set_service"}, {}, {"db", "_db"} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.module == "database": + for a in node.names: + if a.name == "db": + dbs.add(a.asname or a.name) + elif node.module == "services": + for a in node.names: + if a.name == "skill_set_service": + mods.add(a.asname or a.name) + elif node.module == "services.skill_set_service": + for a in node.names: + if a.name in SET_SERVICE_WRITERS: + fns[a.asname or a.name] = a.name + elif isinstance(node, ast.Import): + for a in node.names: + if a.name == "services.skill_set_service" and a.asname: + mods.add(a.asname) + return mods, fns, dbs + + +def scan_source(relpath: str, source: str): + """[(site, [writer refs], hooked?)] for every function in one module.""" + tree = ast.parse(source) + mods, fns, dbs = _aliases(tree) + out = [] + + def visit(node, prefix=""): + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.ClassDef): + visit(child, prefix + child.name + ".") + elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + refs = _writer_refs(child, mods, fns, dbs) + if refs: + out.append((f"{relpath}::{prefix}{child.name}", refs, _hook_refs(child))) + visit(tree) + return out + + +def scan_backend(): + sites = [] + for path in sorted(_BACKEND.rglob("*.py")): + rel = path.relative_to(_BACKEND).as_posix() + if rel in EXEMPT_FILES or rel.startswith(EXEMPT_DIRS) or "/__pycache__/" in rel: + continue + sites.extend(scan_source(rel, path.read_text())) + return sites + + +def test_every_assignment_writer_reaches_the_gate_reconcile(): + sites = scan_backend() + print("\n".join(f"{s}: {sorted(set(r))} hooked={h}" for s, r, h in sites)) + families = {ref.split(".")[0] + "." + ref.split(".")[1] for _, refs, _ in sites for ref in refs} + for must in ("db.set_agent_skills", "db.assign_skill", "db.unassign_skill", + "skill_set_service.assign", "skill_set_service.unassign", + "skill_set_service.replace", "skill_set_service.reconcile_agent"): + assert must in families, f"the guard no longer finds {must} — is it still looking?" + unhooked = [s for s, _, hooked in sites if not hooked] + assert unhooked == [], f"writes agent_skills without reconciling gates: {unhooked}" + assert {s for s, _, _ in sites} == EXPECTED_SITES + + +def test_the_router_helper_itself_reaches_the_reconcile(): + tree = ast.parse((_BACKEND / "routers" / "skills.py").read_text()) + helper = next(n for n in ast.walk(tree) + if isinstance(n, ast.AsyncFunctionDef) and n.name == "_sync_gates") + assert any(isinstance(n, ast.Attribute) and n.attr == "reconcile_library_gates" + for n in ast.walk(helper)) + + +@pytest.mark.parametrize("source,flagged", [ + pytest.param("async def f(a):\n await asyncio.to_thread(skill_set_service.replace, a)\n", + True, id="by-reference-unhooked"), + pytest.param("def f(a):\n db.set_agent_skills(agent_name=a)\n", True, id="call-unhooked"), + pytest.param("from services.skill_set_service import reconcile_agent as r\n" + "def f(a):\n r(a)\n", True, id="aliased-import-unhooked"), + pytest.param("from services import skill_set_service as sss\n" + "def f(a):\n sss.unassign(a, 's', 'u')\n", True, id="aliased-module-unhooked"), + pytest.param("def f(a):\n db.unassign_skill(a, 's')\n x = 'reconcile_library_gates'\n", + True, id="hook-named-only-in-a-string"), + pytest.param("from database import db as core_db\n" + "def f(a):\n core_db.assign_skill(a, 's', 'u')\n", True, id="aliased-facade-unhooked"), + pytest.param("import database\n" + "def f(a):\n database.db.set_agent_skills(agent_name=a)\n", True, id="chained-facade-unhooked"), + pytest.param("async def f(a):\n db.assign_skill(a, 's', 'u')\n" + " await skill_gate_map_service.reconcile_library_gates(a, ctx=c)\n", + False, id="hooked"), +]) +def test_the_guard_catches_what_it_should(source, flagged): + [(site, refs, hooked)] = scan_source("x.py", source) + assert refs and (not hooked) is flagged + + +# --------------------------------------------------------------------------- +# The behaviour +# --------------------------------------------------------------------------- + +OWNER, ADMIN = "owner-753-hk", "admin-753-hk" +FIN, ORCH = "fin-753-hk", "orch-753-hk" + + +def _user(username, *, scope=None, agent=None, role="user"): + from models import User + return User(id=1, username=username, email=f"{username}@example.com", role=role, + mcp_scope=scope, agent_name=agent, mcp_key_id="k" if scope else None) + + +@pytest.fixture +def skills_api(db_backend, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from database import db + from db_models import UserCreate + from db.capability_grants import CAPABILITY_SKILLS_MANAGE + from routers import skills + from services import docker_utils, assignment_provider, skill_set_service + import importlib + PAS = importlib.import_module("services.platform_audit_service") # sys.modules, not the package attribute + + db.create_user(UserCreate(username=OWNER, role="user", email=f"{OWNER}@example.com")) + db.create_user(UserCreate(username=ADMIN, role="admin", email=f"{ADMIN}@example.com")) + db.register_agent_owner(FIN, OWNER) + db.register_agent_owner(ORCH, OWNER) + db.grant_agent_capability(ORCH, CAPABILITY_SKILLS_MANAGE, ADMIN) + + library = [{"name": "deploy", "approval": "recommended"}, + {"name": "pay-invoice", "approval": None}, + {"name": "notes", "approval": None}] + + async def _stopped(name): + return "stopped" + + async def _audit(*a, **kw): + return "evt" + + monkeypatch.setattr(docker_utils, "agent_container_state_async", _stopped) + monkeypatch.setattr(PAS.platform_audit_service, "log", _audit) + monkeypatch.setattr(assignment_provider, "get_provider", lambda: None) + # The gate service reads the library through the LIVE module object; the + # router may hold an older binding after the conftest's module resets. + from services.skill_service import skill_service as live_skill_service + for target in {id(skills.skill_service): skills.skill_service, + id(live_skill_service): live_skill_service}.values(): + monkeypatch.setattr(target, "list_skills", lambda: [dict(s) for s in library]) + monkeypatch.setattr(skills.skill_service, "get_skill", + lambda n: next((dict(s) for s in library if s["name"] == n), None)) + monkeypatch.setattr(skills.skill_service, "deliver_assigned", + AsyncMock(return_value={"status": "pending_start", "skills": {}})) + removals = {"mode": "removed"} + + async def _remove(agent, names): + if removals["mode"] == "busy": + from services.skill_service import SkillInjectionBusy + raise SkillInjectionBusy("injection in progress") + return {"success": True, "skills_removed": len(names), "skills_failed": 0, + "results": {n: {"success": True, "status": "removed"} for n in names}} + + monkeypatch.setattr(skills.skill_service, "remove_skills", _remove) + monkeypatch.setattr(skills, "broadcast_skills_changed", AsyncMock()) + monkeypatch.setattr(skill_set_service, "via_sets_map", lambda *a, **k: {}) + monkeypatch.setattr(skill_set_service, "any_unresolved", lambda *a, **k: False) + + app = FastAPI() + app.include_router(skills.router) + principal = {"user": _user(OWNER)} + app.dependency_overrides[skills.get_current_user] = lambda: principal["user"] + client = TestClient(app) + + def gate(name, origin="set"): + db.write_skill_gate(FIN, name, changes={}, origin=origin, + set_by=OWNER, set_by_agent=None) + + def gated(): + from services import skill_gate_service as sgs + return set(sgs.list_skill_gates(FIN)) + + return SimpleNamespace(db=db, client=client, principal=principal, gate=gate, gated=gated, + removals=removals, skills=skills) + + +def test_assigning_a_recommended_skill_gates_it_and_says_so(skills_api): + r = skills_api.client.post(f"/api/agents/{FIN}/skills/deploy") + assert r.status_code == 200, r.text + assert r.json()["gates"]["gate_defaults"]["applied"] == ["deploy"] + assert skills_api.gated() == {"deploy"} + assert skills_api.client.post(f"/api/agents/{FIN}/skills/notes").json().get("gates") in (None, {}) + + +def test_a_persons_unassign_removes_the_explicit_gate(skills_api): + skills_api.client.post(f"/api/agents/{FIN}/skills/pay-invoice") + skills_api.gate("pay-invoice") + r = skills_api.client.delete(f"/api/agents/{FIN}/skills/pay-invoice") + assert r.json()["gates"]["gates_removed"] == ["pay-invoice"] + assert skills_api.gated() == set() + + +@pytest.mark.parametrize("target", [FIN, ORCH], ids=["sibling", "itself"]) +def test_an_agents_unassign_keeps_the_explicit_gate_so_it_cannot_launder_it(skills_api, target): + skills_api.client.post(f"/api/agents/{target}/skills/pay-invoice") + skills_api.db.write_skill_gate(target, "pay-invoice", changes={}, + origin="set", set_by=OWNER, set_by_agent=None) + skills_api.principal["user"] = _user(OWNER, scope="agent", agent=ORCH) + r = skills_api.client.delete(f"/api/agents/{target}/skills/pay-invoice") + assert r.status_code == 200, r.text + assert r.json()["gates"]["gates_kept"] == ["pay-invoice"] + skills_api.client.post(f"/api/agents/{target}/skills/pay-invoice") + from services import skill_gate_service as sgs + assert set(sgs.list_skill_gates(target)) == {"pay-invoice"} + + +def test_unassigning_a_recommended_skill_drops_its_default_for_anyone(skills_api): + skills_api.client.post(f"/api/agents/{FIN}/skills/deploy") + skills_api.principal["user"] = _user(OWNER, scope="agent", agent=ORCH) + r = skills_api.client.delete(f"/api/agents/{FIN}/skills/deploy") + assert r.json()["gates"]["gate_defaults"]["removed"] == ["deploy"] + assert skills_api.gated() == set() + skills_api.client.post(f"/api/agents/{FIN}/skills/deploy") # re-adding restores it + assert skills_api.gated() == {"deploy"} + + +def test_a_replace_follows_the_same_rules_and_spares_a_conflict_name(skills_api): + skills_api.client.put(f"/api/agents/{FIN}/skills", json={"skills": ["pay-invoice", "notes", "deploy"]}) + assert skills_api.gated() == {"deploy"} + skills_api.gate("pay-invoice") + skills_api.gate("notes") + skills_api.db.set_skill_delivery_status(FIN, ["notes"], []) # the agent's own `notes` runs + r = skills_api.client.put(f"/api/agents/{FIN}/skills", json={"skills": []}) + assert r.status_code == 200, r.text + body = r.json()["gates"] + assert body["gates_removed"] == ["pay-invoice"] + assert body["gate_defaults"]["removed"] == ["deploy"] + assert skills_api.gated() == {"notes"} + + +def test_a_replace_that_changes_nothing_touches_no_gate(skills_api): + skills_api.client.put(f"/api/agents/{FIN}/skills", json={"skills": ["deploy", "notes"]}) + r = skills_api.client.put(f"/api/agents/{FIN}/skills", json={"skills": ["notes", "deploy"]}) + assert r.json().get("gates") in (None, {}) + + +def test_unassigning_a_set_drops_its_members_explicit_gates_for_a_person(skills_api, monkeypatch): + from services import skill_set_service + + def _unassign(agent, set_name, by): + skills_api.db.unassign_skill(agent, "pay-invoice") + return {"existed": True, "added": [], "removed": ["pay-invoice"]} + + monkeypatch.setattr(skill_set_service, "unassign", _unassign) + skills_api.client.post(f"/api/agents/{FIN}/skills/pay-invoice") + skills_api.gate("pay-invoice") + r = skills_api.client.delete(f"/api/agents/{FIN}/skill-sets/finance") + assert r.status_code == 200, r.text + assert r.json()["gates"]["gates_removed"] == ["pay-invoice"] + assert skills_api.gated() == set() + + +def test_the_start_path_backfills_defaults(skills_api, monkeypatch): + from services.agent_service import lifecycle + monkeypatch.setattr(lifecycle.skill_service, "inject_skills", + AsyncMock(return_value={"success": True, "results": {}})) + prune = {"result": {"status": "skipped", "reason": "inventory_unavailable"}} + monkeypatch.setattr(lifecycle.skill_service, "reconcile_agent_skills", + AsyncMock(side_effect=lambda *a, **k: prune["result"])) + skills_api.db.assign_skill(FIN, "deploy", OWNER) # held before this shipped + skills_api.db.assign_skill(FIN, "notes", OWNER) + assert skills_api.gated() == set() + asyncio.run(lifecycle.inject_assigned_skills(FIN)) + assert skills_api.gated() == {"deploy"} + # unassigned while its removal deferred: the default stays until a start's + # prune has inventoried the agent — and goes then (with skills left, and + # with none left: both branches of the start path) + skills_api.db.unassign_skill(FIN, "deploy") + asyncio.run(lifecycle.inject_assigned_skills(FIN)) + assert skills_api.gated() == {"deploy"} + prune["result"] = {"status": "reconciled", "removed": 1, "results": {"deploy": {"status": "removed"}}} + asyncio.run(lifecycle.inject_assigned_skills(FIN)) + assert skills_api.gated() == set() + skills_api.db.assign_skill(FIN, "deploy", OWNER) + asyncio.run(lifecycle.inject_assigned_skills(FIN)) + skills_api.db.unassign_skill(FIN, "deploy") + skills_api.db.unassign_skill(FIN, "notes") + prune["result"] = {"status": "clean", "removed": 0} + asyncio.run(lifecycle.inject_assigned_skills(FIN)) + assert skills_api.gated() == set() + + +def test_the_library_sweep_backfills_defaults(skills_api, monkeypatch): + from services import skills_sync_service as sss + monkeypatch.setattr(sss.skill_service, "inject_skills", + AsyncMock(return_value={"success": True, "results": {}})) + skills_api.db.assign_skill(FIN, "deploy", OWNER) + asyncio.run(sss.SkillsLibrarySyncService._reinject_agent(FIN)) + assert skills_api.gated() == {"deploy"} + + +def test_a_default_is_in_place_before_the_package_is_delivered(skills_api, monkeypatch): + seen = [] + + async def _deliver(agent, names): + seen.append(skills_api.gated()) + return {"status": "pending_start", "skills": {}} + + monkeypatch.setattr(skills_api.skills.skill_service, "deliver_assigned", _deliver) + skills_api.client.post(f"/api/agents/{FIN}/skills/deploy") + skills_api.client.put(f"/api/agents/{FIN}/skills", json={"skills": ["deploy", "notes"]}) + assert seen and all(s == {"deploy"} for s in seen) + + +def test_a_deferred_removal_keeps_every_gate_on_the_skill(skills_api): + """The package may still be on the agent: neither its explicit gate nor its + default goes before its files do.""" + skills_api.client.post(f"/api/agents/{FIN}/skills/deploy") # default + skills_api.client.post(f"/api/agents/{FIN}/skills/pay-invoice") + skills_api.gate("pay-invoice") # explicit + skills_api.removals["mode"] = "busy" + for name in ("deploy", "pay-invoice"): + r = skills_api.client.delete(f"/api/agents/{FIN}/skills/{name}") + assert r.json()["removal"]["status"] == "deferred" + assert skills_api.gated() == {"deploy", "pay-invoice"} + assert r.json()["gates"]["gates_kept"] == ["pay-invoice"] + + +@pytest.mark.parametrize("principal,dropped", [ + pytest.param(lambda: _user(OWNER, scope="user"), True, id="the-owners-user-key"), + pytest.param(lambda: _user(ADMIN, scope="system", role="admin"), False, id="the-system-key"), +]) +def test_who_counts_as_a_person_on_unassign(skills_api, principal, dropped): + skills_api.client.post(f"/api/agents/{FIN}/skills/pay-invoice") + skills_api.gate("pay-invoice") + skills_api.principal["user"] = principal() + r = skills_api.client.delete(f"/api/agents/{FIN}/skills/pay-invoice") + assert r.status_code == 200, r.text + assert (skills_api.gated() == set()) is dropped + + +def test_assigning_a_set_gates_its_recommended_member_before_delivery(skills_api, monkeypatch): + from services import skill_set_service + + def _assign(agent, set_name, by, by_agent): + skills_api.db.assign_skill(agent, "deploy", by) + return {"created": True, "added": ["deploy"], "removed": [], + "entry": {"schedules": []}} + + seen = [] + + async def _deliver(agent, names): + seen.append(skills_api.gated()) + return {"status": "pending_start", "skills": {}} + + async def _status(agent, probe=False): + return [] + + monkeypatch.setattr(skill_set_service, "assign", _assign) + monkeypatch.setattr(skill_set_service, "agent_set_status", _status) + monkeypatch.setattr(skills_api.skills.skill_service, "deliver_assigned", _deliver) + r = skills_api.client.post(f"/api/agents/{FIN}/skill-sets/ops") + assert r.status_code == 200, r.text + assert r.json()["gates"]["gate_defaults"]["applied"] == ["deploy"] + assert seen == [{"deploy"}] + + +def test_a_manual_sync_gates_before_injecting_and_drops_after_the_prune(skills_api, monkeypatch): + from services import skill_set_service + seen = [] + + async def _inject(agent, force=False): + seen.append(skills_api.gated()) + return {"success": True, "results": {}} + + monkeypatch.setattr(skill_set_service, "reconcile_agent", lambda *a, **k: ([], [])) + monkeypatch.setattr(skills_api.skills.skill_service, "inject_skills", _inject) + monkeypatch.setattr(skills_api.skills.skill_service, "reconcile_agent_skills", + AsyncMock(return_value={"status": "clean", "removed": 0})) + skills_api.db.assign_skill(FIN, "deploy", OWNER) + skills_api.client.post(f"/api/agents/{FIN}/skills/inject") + assert seen == [{"deploy"}] + skills_api.db.unassign_skill(FIN, "deploy") + r = skills_api.client.post(f"/api/agents/{FIN}/skills/inject") + assert r.json()["gates"]["gate_defaults"]["removed"] == ["deploy"] + assert skills_api.gated() == set() + + +def test_the_library_sweep_drops_a_default_only_after_its_prune(skills_api, monkeypatch): + """A sync that dropped a set member re-injects and prunes; the member's + default goes after the prune removed its package — not before.""" + from services import skills_sync_service as sss + from services import skill_set_service + seen = [] + + async def _prune(agent, names): + seen.append(skills_api.gated()) + return {"status": "reconciled", "removed": 1, "results": {"deploy": {"status": "removed"}}} + + monkeypatch.setattr(sss.skill_service, "inject_skills", + AsyncMock(return_value={"success": True, "results": {}})) + monkeypatch.setattr(sss.skill_service, "reconcile_agent_skills", _prune) + skills_api.db.assign_skill(FIN, "deploy", OWNER) + skills_api.db.assign_skill(FIN, "notes", OWNER) + asyncio.run(sss.SkillsLibrarySyncService._reinject_agent(FIN)) + assert skills_api.gated() == {"deploy"} + + def _drop_member(agent, lib=None): + skills_api.db.unassign_skill(agent, "deploy") + return [], ["deploy"] + + monkeypatch.setattr(skill_set_service, "reconcile_agent", _drop_member) + asyncio.run(sss.SkillsLibrarySyncService._reinject_agent(FIN, sets_lib={})) + assert seen == [{"deploy"}] # still gated while the prune ran + assert skills_api.gated() == set() diff --git a/tests/unit/test_ent753_gate_map_service.py b/tests/unit/test_ent753_gate_map_service.py new file mode 100644 index 000000000..472bdf20d --- /dev/null +++ b/tests/unit/test_ent753_gate_map_service.py @@ -0,0 +1,581 @@ +""" +The per-agent skill gate map — the write service (trinity-enterprise#753). + +Target: ``services/skill_gate_map_service.py`` over the REAL per-test database +(``db_backend``). Stubbed: the container state and the marker exec (Docker), the +skills library listing, and the audit sink. + +What these pin: + +* named refusals — bad name, bad approver, ``approver`` on an install that + cannot resolve it, a deadline that is not an int in 1..168 (``true`` and + ``"24"`` included), a ghost (decision 2); +* an update keeps what it was not told to change; an explicit null deadline + resets to the default; +* clearing a gate on a library-assigned name leaves a ``cleared`` tombstone, so + the reconcile never re-applies a default the owner just cleared; +* ``reconcile_library_gates`` is state-driven: it applies ``approval: + recommended`` defaults to every assigned skill (new and existing holders — + the backfill ruling), removes defaults and tombstones whose skill is gone, + never loosens on a metadata change, skips ghosts, retries an unreadable + library at the next call, and writes nothing — no row, no audit — when + nothing changed; +* a person's unassign drops explicit gates only for names still unassigned, + and never one whose library row was in ``conflict`` (the agent's own skill + of that name is what runs — decision 1); +* the marker ordering contract (#752 note 2): written BEFORE the first gate's + insert on a running agent, removed only AFTER the last gate is gone, never + touched on a stopped agent; a failed write still saves, with a warning; and a + concurrent sync cannot remove the marker inside the critical section; +* honest status: ``approver_unassigned`` when the approver kind reaches nobody + (the default admin has no email). + +Related flow: docs/memory/feature-flows/skill-gate.md +""" +import asyncio +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from db_harness import db_backend # noqa: E402,F401 + +pytestmark = pytest.mark.unit + +OWNER, OWNER_EMAIL = "owner-753-svc", "owner-753-svc@example.com" +NOMAIL = "nomail-753-svc" +FIN = "fin-753-svc" +BARE = "bare-753-svc" # owner without an email +GHOST = "ghost-753-svc" + + +def _run(coro): + return asyncio.run(asyncio.wait_for(coro, timeout=10)) + + +@pytest.fixture +def world(db_backend, monkeypatch): + import importlib + from database import db + from db_models import UserCreate + from services import docker_utils, assignment_provider + from services.skill_service import skill_service + from services import skill_gate_map_service as gms + # Through sys.modules, which the code under test imports from at call time + # — never `import services.x as X`, which reads the package attribute and + # can be a stale copy after another test's re-import (learning 2026-10-05). + DS = importlib.import_module("services.docker_service") + PAS = importlib.import_module("services.platform_audit_service") + + db.create_user(UserCreate(username=OWNER, role="user", email=OWNER_EMAIL)) + db.create_user(UserCreate(username=NOMAIL, role="user")) + db.register_agent_owner(FIN, OWNER) + db.register_agent_owner(BARE, NOMAIL) + db.register_agent_owner(GHOST, OWNER, is_ephemeral=True) + + state = SimpleNamespace( + containers={FIN: "running", BARE: "stopped", GHOST: "running"}, + marker={}, # agent -> present? + events=[], # ordered: ("marker", agent, create) / ("db", op, agent) + exec_result={"exit_code": 0, "output": "", "timed_out": False}, + library=[{"name": "deploy", "approval": "recommended"}, + {"name": "notes", "approval": None}], + library_fail=False, + library_calls=0, + state_calls=0, + audits=[], + hold=None, # (entered, release, on_create) for the race tests + ) + + async def _state(name): + state.state_calls += 1 + return state.containers.get(name, "missing") + + async def _exec(container_name, command, timeout=60, *, environment=None, user="developer"): + from services import skill_gate_service as sgs + agent = container_name[len("agent-"):] + create = list(command) == sgs.marker_command(True) + state.events.append(("marker", agent, create)) + if state.exec_result.get("exit_code") == 0: + state.marker[agent] = create + if state.hold is not None and state.hold[2] == create: + entered, release, _ = state.hold + state.hold = None + entered.set() + await release.wait() + return dict(state.exec_result) + + def _library(): + state.library_calls += 1 + if state.library_fail: + raise RuntimeError("library clone unreadable") + return [dict(s) for s in state.library] + + async def _audit(event_type, event_action, source, **kw): + state.audits.append(SimpleNamespace(type=event_type, action=event_action, source=source, **kw)) + return "evt" + + monkeypatch.setattr(docker_utils, "agent_container_state_async", _state) + monkeypatch.setattr(DS, "execute_command_in_container", _exec) + monkeypatch.setattr(skill_service, "list_skills", _library) + monkeypatch.setattr(PAS.platform_audit_service, "log", _audit) + monkeypatch.setattr(assignment_provider, "get_provider", lambda: None) + + for op in ("write_skill_gate", "clear_skill_gate", + "reconcile_library_skill_gates", "drop_unassigned_skill_gates"): + real = getattr(db, op) + + def _wrap(*a, __real=real, __op=op, **kw): + state.events.append(("db", __op, a[0])) + return __real(*a, **kw) + monkeypatch.setattr(db, op, _wrap) + + person = SimpleNamespace(username=OWNER, email=OWNER_EMAIL, role="user", mcp_scope=None, + agent_name=None, connector_agent=None) + state.ctx = gms.GateContext(actor=person, via="ui", trigger="direct") + state.db, state.gms = db, gms + return state + + +def _gates(world, agent=FIN): + from services import skill_gate_service as sgs + return sgs.list_skill_gates(agent) + + +# ---- named refusals ------------------------------------------------------------- + +@pytest.mark.parametrize("skill,changes,code", [ + ("../etc", {}, "invalid_skill_name"), + ("-lead", {}, "invalid_skill_name"), + ("ok", {"approver": "boss"}, "invalid_approver"), + ("ok", {"approver": 1}, "invalid_approver"), + ("ok", {"approver": "approver"}, "approver_unavailable"), + ("ok", {"deadline_hours": True}, "invalid_deadline"), + ("ok", {"deadline_hours": "24"}, "invalid_deadline"), + ("ok", {"deadline_hours": 0}, "invalid_deadline"), + ("ok", {"deadline_hours": 169}, "invalid_deadline"), + ("ok", {"deadline_hours": 2.5}, "invalid_deadline"), +]) +def test_bad_input_is_refused_by_name_and_writes_nothing(world, skill, changes, code): + with pytest.raises(world.gms.SkillGateMapRefused) as e: + _run(world.gms.set_gate(FIN, skill, changes=changes, ctx=world.ctx)) + assert (e.value.status_code, e.value.code) == (422, code) + assert e.value.message + assert world.db.list_agent_skill_gates(FIN) == [] + assert [ev for ev in world.events if ev[0] == "marker"] == [] + + +@pytest.mark.parametrize("hours", [1, 168, None]) +def test_the_deadline_bounds_are_inclusive(world, hours): + out = _run(world.gms.set_gate(FIN, "pay-invoice", changes={"deadline_hours": hours}, ctx=world.ctx)) + assert out["gate"]["deadline_hours"] == hours + + +def test_the_approver_kind_is_offered_only_where_it_resolves(world, monkeypatch): + from services import assignment_provider + assert world.gms.approver_kinds() == ["primary"] + monkeypatch.setattr(assignment_provider, "get_provider", lambda: object()) + assert world.gms.approver_kinds() == ["primary", "approver"] + out = _run(world.gms.set_gate(FIN, "pay-invoice", changes={"approver": "approver"}, ctx=world.ctx)) + assert out["gate"]["approver"] == "approver" + + +def test_a_ghost_cannot_hold_a_gate_but_one_can_be_cleared(world): + with pytest.raises(world.gms.SkillGateMapRefused) as e: + _run(world.gms.set_gate(GHOST, "pay-invoice", changes={}, ctx=world.ctx)) + assert (e.value.status_code, e.value.code) == (409, "ephemeral_agent") + assert _run(world.gms.clear_gate(GHOST, "pay-invoice", ctx=world.ctx))["changed"] is False + + +# ---- set / clear semantics ------------------------------------------------------ + +def test_a_new_gate_defaults_to_primary_and_the_24h_deadline(world): + out = _run(world.gms.set_gate(FIN, "Pay-Invoice", changes={}, ctx=world.ctx)) + assert out["skill_name"] == "pay-invoice" and out["changed"] is True + assert out["gate"]["approver"] == "primary" and out["gate"]["deadline_hours"] is None + assert out["gate"]["origin"] == "set" and out["gate"]["set_by"] == OWNER + assert out["warnings"] == [] + assert _gates(world) == {"pay-invoice": world.gms.SkillGate("primary", None)} + + +def test_an_update_keeps_what_it_was_not_told_and_null_resets_the_deadline(world, monkeypatch): + from services import assignment_provider + monkeypatch.setattr(assignment_provider, "get_provider", lambda: object()) + _run(world.gms.set_gate(FIN, "pay-invoice", changes={"approver": "approver", "deadline_hours": 8}, + ctx=world.ctx)) + out = _run(world.gms.set_gate(FIN, "pay-invoice", changes={"deadline_hours": 5}, ctx=world.ctx)) + assert (out["gate"]["approver"], out["gate"]["deadline_hours"]) == ("approver", 5) + out = _run(world.gms.set_gate(FIN, "pay-invoice", changes={"deadline_hours": None}, ctx=world.ctx)) + assert (out["gate"]["approver"], out["gate"]["deadline_hours"]) == ("approver", None) + again = _run(world.gms.set_gate(FIN, "pay-invoice", changes={}, ctx=world.ctx)) + assert again["changed"] is False + assert [a.action for a in world.audits].count("skill_gate_set") == 3 + + +def test_an_approver_nobody_fills_is_saved_with_a_warning(world): + """The default admin has no email: `primary` reaches nobody, so every + gated request would be refused `role_unassigned`. Said, not hidden.""" + out = _run(world.gms.set_gate(BARE, "pay-invoice", changes={}, ctx=world.ctx)) + assert out["warnings"] == ["approver_unassigned"] + assert out["gate"]["approver_reachable"] is False # the same answer GET gives + assert _run(world.gms.set_gate(FIN, "x", changes={}, ctx=world.ctx))["gate"]["approver_reachable"] is True + view = world.gms.list_gates(BARE) + assert [g["approver_reachable"] for g in view["gates"]] == [False] + assert [g["approver_reachable"] for g in world.gms.list_gates(FIN)["gates"]] == [True] + + +def test_clearing_is_idempotent_and_deletes_an_unassigned_name(world): + _run(world.gms.set_gate(FIN, "pay-invoice", changes={}, ctx=world.ctx)) + out = _run(world.gms.clear_gate(FIN, "PAY-INVOICE", ctx=world.ctx)) + assert (out["changed"], out["cleared"]) == (True, "deleted") + assert world.db.list_agent_skill_gates(FIN) == [] + assert _run(world.gms.clear_gate(FIN, "pay-invoice", ctx=world.ctx))["changed"] is False + assert [a.action for a in world.audits] == ["skill_gate_set", "skill_gate_cleared"] + + +def test_clearing_a_library_default_leaves_a_tombstone_the_reconcile_honours(world): + world.db.assign_skill(FIN, "deploy", OWNER) + applied = _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + assert applied["applied"] == ["deploy"] + out = _run(world.gms.clear_gate(FIN, "deploy", ctx=world.ctx)) + assert (out["changed"], out["cleared"]) == (True, "tombstoned") + assert _gates(world) == {} + assert world.gms.list_gates(FIN)["cleared_defaults"] == ["deploy"] + again = _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + assert again is None or again["applied"] == [] + assert _gates(world) == {} + # an explicit set over the tombstone gates it again + _run(world.gms.set_gate(FIN, "deploy", changes={}, ctx=world.ctx)) + assert set(_gates(world)) == {"deploy"} + + +# ---- the reconcile ---------------------------------------------------------------- + +def test_reconcile_backfills_existing_holders_once_and_then_writes_nothing(world): + world.db.assign_skill(FIN, "Deploy", OWNER) + world.db.assign_skill(FIN, "notes", OWNER) + first = _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + assert first["applied"] == ["deploy"] + row = world.db.list_agent_skill_gates(FIN)[0] + assert (row["origin"], row["approver"], row["deadline_hours"]) == ("library_default", "primary", None) + audits = len(world.audits) + second = _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + assert second is None or (second["applied"] == [] and second["removed"] == []) + assert len(world.audits) == audits + + +def test_a_default_another_worker_already_applied_writes_no_second_audit(world, monkeypatch): + """Two workers reconcile the same agent (the sweep's leader lease fails + open): the pre-read of the second still sees no row, but the insert finds + one. The audit follows what the DB wrote, never what the pre-read guessed.""" + world.db.assign_skill(FIN, "deploy", OWNER) + _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + audits = len(world.audits) + real = world.db.list_agent_skill_gates + monkeypatch.setattr(world.db, "list_agent_skill_gates", + lambda agent: [] if agent == FIN else real(agent)) + out = _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + assert out is None or out["applied"] == [] + assert len(world.audits) == audits + + +def test_an_explicit_gate_is_never_replaced_by_the_default(world, monkeypatch): + from services import assignment_provider + monkeypatch.setattr(assignment_provider, "get_provider", lambda: object()) + _run(world.gms.set_gate(FIN, "deploy", changes={"approver": "approver", "deadline_hours": 3}, + ctx=world.ctx)) + world.db.assign_skill(FIN, "deploy", OWNER) + _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + row = world.db.list_agent_skill_gates(FIN)[0] + assert (row["origin"], row["approver"], row["deadline_hours"]) == ("set", "approver", 3) + + +def test_the_tombstone_goes_with_the_assignment_and_readding_restores_the_default(world): + world.db.assign_skill(FIN, "deploy", OWNER) + _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + _run(world.gms.clear_gate(FIN, "deploy", ctx=world.ctx)) # tombstone + world.db.unassign_skill(FIN, "deploy") + out = _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) # no package report needed + assert out["removed"] == ["deploy"] + assert world.db.list_agent_skill_gates(FIN) == [] + world.db.assign_skill(FIN, "deploy", OWNER) + assert _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx))["applied"] == ["deploy"] + + +@pytest.mark.parametrize("drop,kept", [ + pytest.param(None, True, id="removal-deferred"), + pytest.param(set(), True, id="nothing-reported-gone"), + pytest.param({"Deploy"}, False, id="the-route-saw-it-go"), + pytest.param("all", False, id="after-the-start-prune"), + pytest.param("all-but-deploy", True, id="the-prune-could-not-remove-it"), +]) +def test_a_default_stays_until_the_package_is_gone(world, drop, kept): + """An unassigned skill whose package removal deferred is still on the agent; + its default must not go before its files do (plan-gate ruling 2026-10-07).""" + world.db.assign_skill(FIN, "deploy", OWNER) + _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + world.db.unassign_skill(FIN, "deploy") + drop = {"all": world.gms.AllExcept(), "all-but-deploy": world.gms.AllExcept(frozenset({"deploy"}))}.get( + drop, drop) if isinstance(drop, str) else drop + _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx, add=False, drop_defaults=drop)) + assert (set(_gates(world)) == {"deploy"}) is kept + + +def test_the_write_itself_keeps_a_default_whose_package_may_remain(world): + """The same rule at the DB write, reached on a call that has other work (a + default to add): an unassigned skill's default is not swept along.""" + world.db.assign_skill(FIN, "deploy", OWNER) + _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + world.db.unassign_skill(FIN, "deploy") # removal deferred + world.library = [{"name": "deploy", "approval": "recommended"}, + {"name": "notes", "approval": "recommended"}] + world.db.assign_skill(FIN, "notes", OWNER) + out = _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + assert out["applied"] == ["notes"] and out["removed"] == [] + assert set(_gates(world)) == {"deploy", "notes"} + + +def test_what_counts_as_a_package_gone(): + from services import skill_gate_map_service as gms + removal = {"status": "partial", "results": { + "A": {"status": "removed"}, "b": {"status": "not_present"}, "c": {"status": "deferred"}, + "d": {"status": "partial"}, "e": {"status": "not_managed"}, "f": {"status": "still_assigned"}}} + assert gms.packages_gone(removal) == {"a", "b"} + assert gms.packages_gone({"status": "deferred", "reason": "injection_in_progress"}) == set() + assert gms.packages_gone(None) == set() + assert gms.drop_after_prune({"status": "clean", "removed": 0}) == gms.AllExcept(frozenset()) + assert gms.drop_after_prune({"status": "reconciled", "results": removal["results"]}) == \ + gms.AllExcept(frozenset({"c", "d", "e", "f"})) + for skipped in ({"status": "skipped"}, {"status": "refused"}, None): + assert gms.drop_after_prune(skipped) is None + + +def test_clearing_an_explicit_gate_on_a_skill_the_library_does_not_recommend_deletes_it(world): + """Only a recommended skill needs the tombstone; any other clear deletes, so + a recommendation that arrives later can still apply.""" + world.db.assign_skill(FIN, "notes", OWNER) + _run(world.gms.set_gate(FIN, "notes", changes={}, ctx=world.ctx)) + out = _run(world.gms.clear_gate(FIN, "notes", ctx=world.ctx)) + assert out["cleared"] == "deleted" + world.library = [{"name": "notes", "approval": "recommended"}] + assert _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx))["applied"] == ["notes"] + + +def test_two_partial_puts_at_once_keep_both_fields(world, monkeypatch): + from services import assignment_provider + monkeypatch.setattr(assignment_provider, "get_provider", lambda: object()) + _run(world.gms.set_gate(FIN, "pay-invoice", changes={}, ctx=world.ctx)) + + async def both(): + await asyncio.gather( + world.gms.set_gate(FIN, "pay-invoice", changes={"approver": "approver"}, ctx=world.ctx), + world.gms.set_gate(FIN, "pay-invoice", changes={"deadline_hours": 6}, ctx=world.ctx)) + _run(both()) + row = world.db.list_agent_skill_gates(FIN)[0] + assert (row["approver"], row["deadline_hours"]) == ("approver", 6) + + +def test_the_library_can_tighten_but_never_loosen(world): + world.db.assign_skill(FIN, "deploy", OWNER) + _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + world.library = [{"name": "deploy", "approval": None}] + _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + assert set(_gates(world)) == {"deploy"} + + +def test_an_unreadable_library_inserts_nothing_and_the_next_call_retries(world): + world.db.assign_skill(FIN, "deploy", OWNER) + world.library_fail = True + out = _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) + assert out["library_unreadable"] is True and out["applied"] == [] + assert _gates(world) == {} + world.library_fail = False + assert _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx))["applied"] == ["deploy"] + + +def test_a_ghost_gets_no_default(world): + world.db.assign_skill(GHOST, "deploy", OWNER) + out = _run(world.gms.reconcile_library_gates(GHOST, ctx=world.ctx)) + assert out is None or out["applied"] == [] + assert world.db.list_agent_skill_gates(GHOST) == [] + + +def test_reconcile_costs_nothing_on_an_agent_with_no_skills_and_no_defaults(world): + _run(world.gms.set_gate(FIN, "own-skill", changes={}, ctx=world.ctx)) # explicit: not its business + world.events.clear() + world.library_calls = world.state_calls = 0 + assert _run(world.gms.reconcile_library_gates(FIN, ctx=world.ctx)) is None + assert (world.library_calls, world.state_calls, world.events) == (0, 0, []) + assert set(_gates(world)) == {"own-skill"} # decision 1: sticky + + +def test_a_default_reaching_nobody_is_reported(world): + world.db.assign_skill(BARE, "deploy", NOMAIL) + out = _run(world.gms.reconcile_library_gates(BARE, ctx=world.ctx)) + assert out["applied"] == ["deploy"] and out["unreachable"] == ["deploy"] + + +# ---- the person's unassign --------------------------------------------------------- + +def test_a_person_unassign_drops_explicit_gates_only_for_names_still_unassigned(world): + for name in ("pay-invoice", "refund", "deploy"): + _run(world.gms.set_gate(FIN, name, changes={}, ctx=world.ctx)) + world.db.assign_skill(FIN, "refund", OWNER) # re-added concurrently: stays gated + removed = _run(world.gms.drop_explicit_gates_on_unassign( + FIN, ["Pay-Invoice", "refund", "deploy"], ctx=world.ctx, keep=["deploy"])) + assert removed == ["pay-invoice"] + assert set(_gates(world)) == {"refund", "deploy"} + assert world.gms.explicit_gate_names(FIN, ["REFUND", "nope"]) == ["refund"] + + +# ---- the marker ordering contract --------------------------------------------------- + +def _trace(world): + return [(e[0], e[2] if e[0] == "marker" else e[1]) for e in world.events] + + +def test_the_marker_is_written_before_the_first_gate_and_removed_after_the_last(world): + _run(world.gms.set_gate(FIN, "a", changes={}, ctx=world.ctx)) + assert _trace(world) == [("marker", True), ("db", "write_skill_gate"), ("marker", True)] + world.events.clear() + _run(world.gms.set_gate(FIN, "b", changes={}, ctx=world.ctx)) # not the first: no pre-write + assert _trace(world) == [("db", "write_skill_gate"), ("marker", True)] + world.events.clear() + _run(world.gms.clear_gate(FIN, "a", ctx=world.ctx)) + assert _trace(world) == [("db", "clear_skill_gate"), ("marker", True)] + world.events.clear() + _run(world.gms.clear_gate(FIN, "b", ctx=world.ctx)) + assert _trace(world) == [("db", "clear_skill_gate"), ("marker", False)] + assert world.marker[FIN] is False + + +def test_a_stopped_agent_is_never_execd(world): + _run(world.gms.set_gate(BARE, "a", changes={}, ctx=world.ctx)) + _run(world.gms.clear_gate(BARE, "a", ctx=world.ctx)) + assert [e for e in world.events if e[0] == "marker"] == [] + + +def test_a_failed_marker_write_still_saves_and_says_so(world): + world.exec_result = {"exit_code": 1, "output": "denied", "timed_out": False} + out = _run(world.gms.set_gate(FIN, "a", changes={}, ctx=world.ctx)) + assert out["changed"] is True and "marker_not_written" in out["warnings"] + assert set(_gates(world)) == {"a"} + + +def test_a_concurrent_sync_cannot_remove_the_marker_inside_the_critical_section(world): + """Hold the first writer between its marker write and its insert; a sync + started meanwhile (the start path, a heal) reads an empty map. Without the + shared per-agent lock it removes the marker and the insert lands with no + marker — the window the ordering contract exists to close.""" + from services import skill_gate_service as sgs + at_insert = [] + real = world.db.write_skill_gate + + def _record(*a, **kw): + at_insert.append(world.marker.get(FIN)) + return real(*a, **kw) + + world.db.write_skill_gate = _record + + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + world.hold = (entered, release, True) + writer = asyncio.create_task(world.gms.set_gate(FIN, "a", changes={}, ctx=world.ctx)) + await entered.wait() + syncer = asyncio.create_task(sgs.sync_gate_marker(FIN)) + for _ in range(20): + await asyncio.sleep(0) + release.set() + await writer + await syncer + + try: + _run(scenario()) + finally: + world.db.write_skill_gate = real + assert at_insert == [True] + assert world.marker[FIN] is True + + +def test_a_set_racing_the_clear_of_the_last_gate_still_writes_the_marker_first(world): + """Gates {a}. A clear of `a` holds the lock and removes the marker; a set of + `b`, started meanwhile, must decide "first gate" under the lock — from a + read taken before it, it would insert `b` with no marker.""" + _run(world.gms.set_gate(FIN, "a", changes={}, ctx=world.ctx)) + at_insert = [] + real = world.db.write_skill_gate + + def _record(*a, **kw): + at_insert.append(world.marker.get(FIN)) + return real(*a, **kw) + + world.db.write_skill_gate = _record + + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + world.hold = (entered, release, False) # hold the clear's marker REMOVAL + clearer = asyncio.create_task(world.gms.clear_gate(FIN, "a", ctx=world.ctx)) + await entered.wait() + setter = asyncio.create_task(world.gms.set_gate(FIN, "b", changes={}, ctx=world.ctx)) + for _ in range(20): + await asyncio.sleep(0) + release.set() + await clearer + await setter + + try: + _run(scenario()) + finally: + world.db.write_skill_gate = real + assert at_insert == [True] + assert world.marker[FIN] is True + + +# ---- audit ------------------------------------------------------------------------ + +def test_the_audit_says_who_and_from_where(world): + _run(world.gms.set_gate(FIN, "pay-invoice", changes={"deadline_hours": 6}, ctx=world.ctx)) + [row] = world.audits + assert (row.action, row.source, row.target_type, row.target_id) == ( + "skill_gate_set", "api", "agent", FIN) + assert row.details["skill"] == "pay-invoice" and row.details["deadline_hours"] == 6 + assert (row.details["via"], row.details["trigger"]) == ("ui", "direct") + assert row.details["previous"] is None + + +def test_an_agent_actor_is_recorded_as_the_agent(world): + agent = SimpleNamespace(username=OWNER, email=OWNER_EMAIL, role="user", mcp_scope="agent", + agent_name="orchestrator-753", connector_agent=None, + mcp_key_id="k1", mcp_key_name="orch") + ctx = world.gms.GateContext(actor=agent, via="orchestrator", trigger="direct") + _run(world.gms.set_gate(FIN, "pay-invoice", changes={}, ctx=ctx)) + [row] = world.audits + assert row.actor_agent_name == "orchestrator-753" and row.actor_email == OWNER_EMAIL + assert world.db.list_agent_skill_gates(FIN)[0]["set_by_agent"] == "orchestrator-753" + + +def test_a_failed_audit_never_fails_the_write(world, monkeypatch): + import importlib + + async def _boom(*a, **kw): + raise RuntimeError("audit sink down") + + monkeypatch.setattr(importlib.import_module("services.platform_audit_service").platform_audit_service, + "log", _boom) + out = _run(world.gms.set_gate(FIN, "pay-invoice", changes={}, ctx=world.ctx)) + assert out["changed"] is True + + +def test_via_comes_from_the_principal(world): + v = world.gms.via_for + assert v(SimpleNamespace(mcp_scope=None, agent_name=None)) == "ui" + assert v(SimpleNamespace(mcp_scope="user", agent_name=None)) == "api" + assert v(SimpleNamespace(mcp_scope="agent", agent_name="x")) == "orchestrator" + assert v(SimpleNamespace(mcp_scope="system", agent_name=None)) == "system" + assert v(None) == "system" + assert v(SimpleNamespace()) == "system" # no scope at all: never the JWT value diff --git a/tests/unit/test_ent753_gate_routes.py b/tests/unit/test_ent753_gate_routes.py new file mode 100644 index 000000000..8b4e49c9e --- /dev/null +++ b/tests/unit/test_ent753_gate_routes.py @@ -0,0 +1,307 @@ +""" +The per-agent skill gate map — the REST surface and who may use it +(trinity-enterprise#753). + +Target: ``GET/PUT/DELETE /api/agents/{agent_name}/skill-gates[/{skill_name}]`` +(``routers/skill_gate.py`` → ``services/skill_gate_map_service.py``), driven +through a TestClient over the REAL routes and dependencies on the real per-test +database. ``get_current_user`` is overridden by walking each route's own +dependant tree, so every principal goes through the real fences. + +The rules (AC 4, AC 8; Invariant #8; the #3236 shape): + +* WRITE — a person (a signed-in session or the person's own user-scoped key) + who owns the agent or is an admin; or an agent key holding ``skills.manage``, + only on an agent its owner OWNS (never the admin short-circuit) and NEVER on + itself (the agent a gate constrains must not lift it). The system key, a + connector key, any other scope and a principal with no scope are refused on + the principal alone (403), before anything about the target is said. +* READ — anyone with access to the agent; an agent key reads its OWN gates, or + (holding ``skills.manage``) an agent its owner owns. A sibling the key may not + read and an agent that does not exist answer the same 404 body. + +Stubbed: the container state (stopped, so no exec), the library, the audit sink. +Related flow: docs/memory/feature-flows/skill-gate.md +""" +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +if str(Path(__file__).resolve().parent) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parent)) # _route_census, as test_2996 does + +from db_harness import db_backend # noqa: E402,F401 + +pytestmark = pytest.mark.unit + +OWNER, OTHER, ADMIN = "owner-753-rt", "other-753-rt", "admin-753-rt" +FIN = "fin-753-rt" # owned by OWNER +SIB = "sib-753-rt" # owned by OWNER, no grant +ORCH = "orch-753-rt" # owned by OWNER, holds skills.manage +AORCH = "aorch-753-rt" # owned by ADMIN, holds skills.manage +ELSE = "else-753-rt" # owned by OTHER +GHOST = "ghost-753-rt" +MISSING = "missing-753-rt" + +_PRINCIPAL = {"user": None} +_APP = {} + + +def _client(): + from fastapi.testclient import TestClient + if "app" not in _APP: + from fastapi import FastAPI + from routers import skill_gate as r + app = FastAPI() + app.include_router(r.agent_router) + found = set() + + def walk(dependant): + for sub in dependant.dependencies: + if getattr(sub.call, "__name__", "") == "get_current_user": + found.add(sub.call) + walk(sub) + + for route in r.agent_router.routes: + walk(route.dependant) + assert found, "no get_current_user on the skill-gate routes" + for call in found: + app.dependency_overrides[call] = lambda: _PRINCIPAL["user"] + _APP["app"] = app + return TestClient(_APP["app"], raise_server_exceptions=True) + + +def _user(username, *, scope=None, agent=None, role="user", connector=None): + from models import User + return User(id=1, username=username, email=f"{username}@example.com", role=role, + mcp_scope=scope, agent_name=agent, connector_agent=connector, + mcp_key_id="key-1" if scope else None) + + +@pytest.fixture +def api(db_backend, monkeypatch): + from database import db + from db_models import UserCreate + from db.capability_grants import CAPABILITY_SKILLS_MANAGE + from services import docker_utils, assignment_provider + import importlib + PAS = importlib.import_module("services.platform_audit_service") # sys.modules, not the package attribute + from services.skill_service import skill_service + + for name, role in ((OWNER, "user"), (OTHER, "user"), (ADMIN, "admin")): + db.create_user(UserCreate(username=name, role=role, email=f"{name}@example.com")) + for agent, owner in ((FIN, OWNER), (SIB, OWNER), (ORCH, OWNER), (AORCH, ADMIN), (ELSE, OTHER)): + db.register_agent_owner(agent, owner) + db.register_agent_owner(GHOST, OWNER, is_ephemeral=True) + db.grant_agent_capability(ORCH, CAPABILITY_SKILLS_MANAGE, ADMIN) + db.grant_agent_capability(AORCH, CAPABILITY_SKILLS_MANAGE, ADMIN) + + async def _stopped(name): + return "stopped" + + async def _audit(*a, **kw): + return "evt" + + monkeypatch.setattr(docker_utils, "agent_container_state_async", _stopped) + monkeypatch.setattr(skill_service, "list_skills", lambda: []) + monkeypatch.setattr(PAS.platform_audit_service, "log", _audit) + monkeypatch.setattr(assignment_provider, "get_provider", lambda: None) + return SimpleNamespace(db=db, client=_client()) + + +def _as(principal): + _PRINCIPAL["user"] = principal + + +def _put(api, agent, skill="pay-invoice", body=None): + return api.client.put(f"/api/agents/{agent}/skill-gates/{skill}", json=body or {}) + + +def _delete(api, agent, skill="pay-invoice"): + return api.client.delete(f"/api/agents/{agent}/skill-gates/{skill}") + + +def _get(api, agent): + return api.client.get(f"/api/agents/{agent}/skill-gates") + + +# ---- writes ------------------------------------------------------------------- + +@pytest.mark.parametrize("principal", [ + pytest.param(lambda: _user(OWNER), id="owner-session"), + pytest.param(lambda: _user(OWNER, scope="user"), id="owner-user-key"), + pytest.param(lambda: _user(ADMIN, role="admin"), id="admin-session"), + pytest.param(lambda: _user(OWNER, scope="agent", agent=ORCH), id="holder-on-a-sibling"), +]) +def test_who_may_set_and_clear(api, principal): + _as(principal()) + r = _put(api, FIN, body={"deadline_hours": 12}) + assert r.status_code == 200, r.text + assert r.json()["gate"]["deadline_hours"] == 12 + assert _delete(api, FIN).json()["changed"] is True + + +@pytest.mark.parametrize("principal,code", [ + pytest.param(lambda: _user(ADMIN, scope="system", role="admin"), "person_required", id="system-key"), + pytest.param(lambda: _user(OWNER, scope="connector", connector=FIN), "person_required", id="connector"), + pytest.param(lambda: _user(OWNER, scope="ops"), "person_required", id="ops-scope"), + pytest.param(lambda: _user(OWNER, scope="agent", agent=SIB), "skill_management_not_permitted", + id="agent-without-the-grant"), + pytest.param(lambda: _user(OWNER, scope="agent", agent=ORCH), "person_required", id="holder-on-itself"), +]) +def test_who_may_not_write_is_refused_on_the_principal(api, principal, code): + _as(principal()) + target = ORCH if principal().agent_name == ORCH else FIN + for r in (_put(api, target), _delete(api, target)): + assert r.status_code == 403, r.text + assert r.json()["detail"]["code"] == code + assert api.db.list_agent_skill_gates(target) == [] + + +def test_the_refusal_does_not_depend_on_whether_the_agent_exists(api): + _as(_user(ADMIN, scope="system", role="admin")) + assert _put(api, MISSING).json() == _put(api, FIN).json() + + +def test_a_principal_without_a_scope_is_refused(api): + _as(SimpleNamespace(id=1, username=OWNER, email=None, role="admin", agent_name=None, + connector_agent=None, portal_delegate=False)) + assert _put(api, FIN).status_code == 403 + + +def test_a_holder_reaches_only_agents_its_owner_owns(api): + """An agent key carries its owner's role; an admin-owned holder must not + reach another user's agent through `can_user_share_agent`'s admin + short-circuit. Same 404 as an agent that does not exist.""" + _as(_user(ADMIN, scope="agent", agent=AORCH, role="admin")) + other = _put(api, ELSE) + missing = _put(api, MISSING) + assert other.status_code == missing.status_code == 404 + assert other.json() == missing.json() + assert api.db.list_agent_skill_gates(ELSE) == [] + + +def test_a_person_who_does_not_own_the_agent_gets_the_uniform_404(api): + _as(_user(OTHER)) + r, m = _put(api, FIN), _put(api, MISSING) + assert r.status_code == m.status_code == 404 and r.json() == m.json() + + +def test_a_ghost_is_refused_after_the_access_check(api): + _as(_user(OWNER)) + r = _put(api, GHOST) + assert r.status_code == 409 and r.json()["detail"]["code"] == "ephemeral_agent" + assert r.headers["X-Trinity-Error-Code"] == "ephemeral_agent" + _as(_user(OTHER)) + assert _put(api, GHOST).status_code == 404 + + +@pytest.mark.parametrize("body,code", [ + ({"deadline_hours": True}, "invalid_deadline"), + ({"deadline_hours": "24"}, "invalid_deadline"), + ({"deadline_hours": 500}, "invalid_deadline"), + ({"approver": "approver"}, "approver_unavailable"), + ({"approver": "owner"}, "invalid_approver"), +]) +def test_bad_input_gets_its_named_code(api, body, code): + _as(_user(OWNER)) + r = _put(api, FIN, body=body) + assert r.status_code == 422 and r.json()["detail"]["code"] == code + assert r.headers["X-Trinity-Error-Code"] == code + + +def test_a_bad_skill_name_is_refused(api): + _as(_user(OWNER)) + r = _put(api, FIN, skill="bad%20name") + assert r.status_code == 422 and r.json()["detail"]["code"] == "invalid_skill_name" + + +def test_put_then_get_round_trips_every_field(api): + _as(_user(OWNER)) + _put(api, FIN, skill="Pay-Invoice", body={"deadline_hours": 48}) + _put(api, FIN, skill="Pay-Invoice", body={}) # omitted: kept + body = _get(api, FIN).json() + assert body["agent_name"] == FIN + assert body["approver_kinds"] == ["primary"] and body["default_deadline_hours"] == 24 + [gate] = body["gates"] + assert {k: gate[k] for k in ("skill_name", "approver", "deadline_hours", "origin", "set_by", + "set_by_agent", "approver_reachable")} == { + "skill_name": "pay-invoice", "approver": "primary", "deadline_hours": 48, "origin": "set", + "set_by": OWNER, "set_by_agent": None, "approver_reachable": True} + assert gate["set_at"] + _put(api, FIN, skill="pay-invoice", body={"deadline_hours": None}) + assert _get(api, FIN).json()["gates"][0]["deadline_hours"] is None + + +# ---- reads -------------------------------------------------------------------- + +def test_a_machine_key_never_reads_who_set_a_gate(api): + """`set_by` is a username — an email for email-login users. A gate set by a + non-owner admin must not hand that admin's address to the agent's key + (cso-diff 2026-10-07 finding 1; #715 people stay with people).""" + _as(_user(ADMIN, role="admin")) + _put(api, FIN) + _as(_user(OWNER)) + assert _get(api, FIN).json()["gates"][0]["set_by"] == ADMIN + for machine in (_user(OWNER, scope="agent", agent=FIN), + _user(OWNER, scope="agent", agent=ORCH), + _user(ADMIN, scope="system", role="admin")): + _as(machine) + [gate] = _get(api, FIN).json()["gates"] + assert gate["set_by"] is None and gate["skill_name"] == "pay-invoice" + _as(_user(OWNER, scope="agent", agent=ORCH)) + assert _put(api, FIN, body={"deadline_hours": 2}).json()["gate"]["set_by"] is None + + +@pytest.mark.parametrize("principal,agent", [ + pytest.param(lambda: _user(OWNER), FIN, id="owner"), + pytest.param(lambda: _user(OWNER, scope="user"), FIN, id="owner-user-key"), + pytest.param(lambda: _user(ADMIN, role="admin"), ELSE, id="admin"), + pytest.param(lambda: _user(ADMIN, scope="system", role="admin"), FIN, id="system-key"), + pytest.param(lambda: _user(OWNER, scope="agent", agent=SIB), SIB, id="agent-reads-its-own"), + pytest.param(lambda: _user(OWNER, scope="agent", agent=ORCH), FIN, id="holder-reads-a-sibling"), +]) +def test_who_may_read(api, principal, agent): + _as(principal()) + r = _get(api, agent) + assert r.status_code == 200, r.text + assert r.json()["agent_name"] == agent + + +@pytest.mark.parametrize("principal,agent", [ + pytest.param(lambda: _user(OWNER, scope="agent", agent=SIB), FIN, id="agent-reads-a-sibling"), + pytest.param(lambda: _user(ADMIN, scope="agent", agent=AORCH, role="admin"), ELSE, + id="admin-owned-holder-reads-another-users-agent"), + pytest.param(lambda: _user(OTHER), FIN, id="person-without-access"), +]) +def test_a_read_that_may_not_happen_looks_like_a_missing_agent(api, principal, agent): + _as(principal()) + r, m = _get(api, agent), _get(api, MISSING) + assert r.status_code == m.status_code == 404 + assert r.content == m.content + + +@pytest.mark.parametrize("principal", [ + pytest.param(lambda: _user(OWNER, scope="connector", connector=FIN), id="connector-on-its-agent"), + pytest.param(lambda: _user(OWNER, scope="ops"), id="unknown-scope"), +]) +def test_a_scope_that_may_not_read_is_refused_on_the_principal(api, principal): + _as(principal()) + assert _get(api, FIN).status_code == 403 + assert _get(api, MISSING).status_code == 403 + + +# ---- the census ---------------------------------------------------------------- + +def test_every_route_is_classified_in_the_census(): + import _route_census as census + for key in ("routers/skill_gate.py::list_agent_skill_gates", + "routers/skill_gate.py::set_agent_skill_gate", + "routers/skill_gate.py::clear_agent_skill_gate"): + assert key in census.AGENT_CALLABLE, key diff --git a/tests/unit/test_ent753_gate_storage.py b/tests/unit/test_ent753_gate_storage.py new file mode 100644 index 000000000..1f606906c --- /dev/null +++ b/tests/unit/test_ent753_gate_storage.py @@ -0,0 +1,225 @@ +""" +The per-agent skill gate map — storage and the read seam (trinity-enterprise#753). + +What these pin, and why each exists: + +1. both migration tracks build `agent_skill_gates`: the SQLite entry is reached + through the REGISTERED `MIGRATIONS` list (a function tested directly says + nothing about registration) and run twice; the Alembic revision extends the + single head; +2. the primary key `(agent_name, skill_name)` is declared in `db/tables.py` too, + so an ON CONFLICT write works on a database built from either source; +3. the rows follow the agent — rename re-keys them, purge removes them — through + the AGENT_REFS registry, over a real database; +4. the facade delegates every `SkillGateOperations` method with the same + signature (tests that mock `db` cannot see a missing delegation); +5. `list_skill_gates` — the seam ent#751's dispatch check and ent#752's hook read + — answers from the table: lowercased keys, tombstones skipped, and a failed + read RAISES (never `{}`, which reads as "nothing is gated" and opens every + skill), so `read_gates` refuses 503; +6. a gate written as `My-Skill` holds `/my-skill` through the real matcher. + +Related flow: docs/memory/feature-flows/skill-gate.md +""" +import inspect +import sys +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from db_harness import db_backend, seed_agent, seed_user # noqa: E402,F401 + +pytestmark = pytest.mark.unit + +FIN = "fin-753-storage" +SIB = "sib-753-storage" + + +@pytest.fixture +def gates_db(db_backend): + from database import db + seed_user(1, "owner-753-storage") + seed_agent(FIN, 1) + seed_agent(SIB, 1) + return db + + +def _write(db, agent, skill, *, approver="primary", deadline=None, origin="set"): + return db.write_skill_gate(agent, skill, changes={"approver": approver, "deadline_hours": deadline}, + origin=origin, set_by="owner-753-storage", set_by_agent=None) + + +# ---- 1. both tracks ------------------------------------------------------------ + +def test_the_registered_sqlite_migration_builds_the_table_idempotently(tmp_path): + import sqlite3 + from db import migrations + entry = dict(migrations.MIGRATIONS)["agent_skill_gates"] + conn = sqlite3.connect(tmp_path / "m.db") + cur = conn.cursor() + entry(cur, conn) + entry(cur, conn) + cols = {r[1]: r for r in cur.execute("PRAGMA table_info(agent_skill_gates)")} + assert set(cols) == {"agent_name", "skill_name", "approver", "deadline_hours", "origin", + "set_by", "set_by_agent", "set_at"} + pk = sorted((r[5], r[1]) for r in cols.values() if r[5]) + assert [name for _, name in pk] == ["agent_name", "skill_name"] + + +def test_the_alembic_revision_extends_the_single_head(): + import importlib.util + root = _BACKEND / "migrations" / "versions" + spec = importlib.util.spec_from_file_location("rev753", root / "0094_agent_skill_gates.py") + rev = importlib.util.module_from_spec(spec) + spec.loader.exec_module(rev) + assert (rev.revision, rev.down_revision) == ( + "0094_agent_skill_gates", "0093_platform_alert_responded_heal") + + +def test_tables_metadata_declares_the_primary_key(): + from db.tables import agent_skill_gates + assert [c.name for c in agent_skill_gates.primary_key.columns] == ["agent_name", "skill_name"] + + +def test_an_upsert_works_on_a_database_built_from_tables_py(tmp_path, monkeypatch): + """ON CONFLICT needs the PK on the database the write runs against; one + built from `tables.py` metadata must carry it as well as `schema.py`'s.""" + from sqlalchemy import create_engine, insert + import db.tables as tables + import db.skill_gates as mod + engine = create_engine(f"sqlite:///{tmp_path / 'meta.db'}") + tables.agent_ownership.create(engine) + tables.agent_skills.create(engine) + tables.agent_skill_sets.create(engine) # the per-agent write lock touches it + tables.agent_skill_gates.create(engine) + with engine.begin() as conn: + conn.execute(insert(tables.agent_ownership).values( + agent_name=FIN, owner_id=1, created_at="2026-10-07T00:00:00Z")) + monkeypatch.setattr(mod, "get_engine", lambda: engine) + ops = mod.SkillGateOperations() + ops.write_skill_gate(FIN, "pay-invoice", changes={"approver": "primary", "deadline_hours": None}, + origin="set", set_by="o", set_by_agent=None) + prev, cur, changed = ops.write_skill_gate(FIN, "pay-invoice", changes={"deadline_hours": 48}, + origin="set", set_by="o", set_by_agent=None) + assert prev["deadline_hours"] is None and cur["deadline_hours"] == 48 and changed + assert [r["skill_name"] for r in ops.list_agent_skill_gates(FIN)] == ["pay-invoice"] + + +# ---- 2. the operations, on the real per-test database ------------------------- + +def test_write_lowercases_the_key_and_reports_previous_and_current(gates_db): + prev, cur, changed = _write(gates_db, FIN, "Pay-Invoice") + assert prev is None and changed + assert cur["skill_name"] == "pay-invoice" and cur["approver"] == "primary" + prev, cur, changed = _write(gates_db, FIN, "PAY-INVOICE", deadline=12) + assert prev["deadline_hours"] is None and cur["deadline_hours"] == 12 and changed + assert [r["skill_name"] for r in gates_db.list_agent_skill_gates(FIN)] == ["pay-invoice"] + assert _write(gates_db, FIN, "pay-invoice", deadline=12)[2] is False # same values: no write + + +def test_a_field_not_sent_keeps_what_is_stored(gates_db): + """The merge happens under the lock, in the write itself: two partial + writes never drop each other's field.""" + _write(gates_db, FIN, "pay-invoice", approver="approver", deadline=8) + gates_db.write_skill_gate(FIN, "pay-invoice", changes={"deadline_hours": 4}, origin="set", + set_by="o", set_by_agent=None) + gates_db.write_skill_gate(FIN, "pay-invoice", changes={"approver": "primary"}, origin="set", + set_by="o", set_by_agent=None) + [row] = gates_db.list_agent_skill_gates(FIN) + assert (row["approver"], row["deadline_hours"]) == ("primary", 4) + + +def test_a_write_to_an_agent_that_is_not_live_lands_nowhere(gates_db): + """A rename or purge between the auth check and the insert must not leave a + row under a name a future agent could inherit.""" + assert _write(gates_db, "no-such-agent-753", "pay-invoice") == (None, None, False) + assert gates_db.list_agent_skill_gates("no-such-agent-753") == [] + + +def test_rows_are_per_agent(gates_db): + _write(gates_db, FIN, "pay-invoice") + assert gates_db.list_agent_skill_gates(SIB) == [] + + +# ---- 3. the rows follow the agent --------------------------------------------- + +def test_the_gate_map_is_a_cascade_agent_ref(): + from db.agent_cleanup import AGENT_REFS, Policy + refs = {(r.table, r.column): r.policy for r in AGENT_REFS} + assert refs[("agent_skill_gates", "agent_name")] is Policy.CASCADE + # audit-only provenance, deliberately not re-keyed (the assigned_by_agent precedent) + assert ("agent_skill_gates", "set_by_agent") not in refs + + +def test_rename_carries_the_gates_and_purge_removes_them(gates_db): + _write(gates_db, FIN, "pay-invoice") + assert gates_db.rename_agent(FIN, FIN + "-renamed") is True + assert gates_db.list_agent_skill_gates(FIN) == [] + assert [r["skill_name"] for r in gates_db.list_agent_skill_gates(FIN + "-renamed")] == ["pay-invoice"] + gates_db.delete_agent_ownership(FIN + "-renamed") # soft delete keeps them (recovery) + assert len(gates_db.list_agent_skill_gates(FIN + "-renamed")) == 1 + gates_db.purge_agent_ownership(FIN + "-renamed") + assert gates_db.list_agent_skill_gates(FIN + "-renamed") == [] + + +# ---- 4. facade parity ---------------------------------------------------------- + +def test_the_facade_delegates_every_operation_with_the_same_signature(): + from database import DatabaseManager + from db.skill_gates import SkillGateOperations + names = [n for n, f in inspect.getmembers(SkillGateOperations, inspect.isfunction) + if not n.startswith("_")] + assert len(names) >= 5, names + for name in names: + assert hasattr(DatabaseManager, name), f"DatabaseManager has no {name}" + ops_sig = inspect.signature(getattr(SkillGateOperations, name)) + facade_sig = inspect.signature(getattr(DatabaseManager, name)) + assert list(ops_sig.parameters) == list(facade_sig.parameters), name + + +# ---- 5. the read seam ---------------------------------------------------------- + +def test_list_skill_gates_reads_the_table_and_skips_tombstones(gates_db): + from services import skill_gate_service as svc + _write(gates_db, FIN, "Pay-Invoice", approver="approver", deadline=6) + _write(gates_db, FIN, "deploy", origin="library_default") + _write(gates_db, FIN, "refund", origin="cleared") + gates = svc.list_skill_gates(FIN) + assert gates == { + "pay-invoice": svc.SkillGate(approver="approver", deadline_hours=6), + "deploy": svc.SkillGate(approver="primary", deadline_hours=None), + } + assert svc.list_skill_gates(SIB) == {} + + +def test_a_failed_read_raises_and_read_gates_refuses(gates_db, monkeypatch): + from database import db + from services import skill_gate_service as svc + + def _boom(agent_name): + raise RuntimeError("database is locked") + + monkeypatch.setattr(db, "list_agent_skill_gates", _boom) + with pytest.raises(RuntimeError): + svc.list_skill_gates(FIN) + with pytest.raises(svc.SkillGateRefused) as e: + svc.read_gates(FIN) + assert (e.value.status_code, e.value.code) == (503, "gate_unavailable") + + +# ---- 6. the stored key holds through the real matchers ------------------------- + +def test_a_gate_written_mixed_case_holds_through_the_matchers(gates_db): + from services import skill_gate_service as svc + from utils.skill_invocation import find_gated_invocations + _write(gates_db, FIN, "My-Skill") + gates = svc.read_gates(FIN) + assert find_gated_invocations("please run /my-skill now", gates) == ["my-skill"] + assert find_gated_invocations("please run /MY-SKILL now", gates) == ["my-skill"] + # the in-container hook's names are matched casefolded against the same keys + hits = sorted(k for k in gates if k.casefold() in {"My-Skill".casefold()}) + assert hits == ["my-skill"] diff --git a/tests/unit/test_loop_service.py b/tests/unit/test_loop_service.py index 6ce472830..22d2f9947 100644 --- a/tests/unit/test_loop_service.py +++ b/tests/unit/test_loop_service.py @@ -233,15 +233,19 @@ def start_loop_run(self, loop_id: str, run_number: int, *, execution_id=None) -> return rid def finalize_loop_run(self, run_id: str, **kwargs): + # CAS on status='running', as the real one (#3316). for runs in self.runs.values(): for r in runs: if r["id"] == run_id: + if r["status"] != "running": + return False for k, v in kwargs.items(): if k == "execution_id" and v is None: continue # COALESCE: don't overwrite with None r[k] = v r["completed_at"] = "now" - return + return True + return False def list_loop_runs(self, loop_id: str): return [dict(r) for r in sorted(