Repository navigation
feat(skill-gates): the per-agent skill gate map (abilityai/trinity-enterprise#753) - #3347
Conversation
Stores, per agent, which skills need approval before they run and which kind
of person approves. The dispatch check (ent#751) and the in-container hook
(ent#752) read it through skill_gate_service.list_skill_gates, which stops
returning {} and reads the table — the gate is live from here.
- Storage: agent_skill_gates (agent x skill -> approver primary|approver,
deadline 1-168h, origin set|library_default|cleared), both tracks (SQLite
entry + Alembic 0093 on 0092), CASCADE AgentRef. A failed read raises, so
the check refuses (gate_unavailable) instead of reading "nothing is gated".
- REST GET/PUT/DELETE /api/agents/{agent_name}/skill-gates[/{skill_name}] and
MCP list_skill_gates / set_skill_gate / clear_skill_gate. Writes: a person
who owns the agent or is an admin, or a skills.manage holder on an agent its
owner owns, never on itself (require_person_or_capability, copied verbatim
from #3236). An agent key reads its own gates; set_by is withheld from
machine keys. Named 422/409 refusals; OSS offers only `primary`.
- Library `approval: recommended` defaults follow assignment state: applied
before a package is delivered (existing holders backfilled at start/sync),
removed once the skill is unassigned and its package has gone; a cleared
default leaves a tombstone. The library can tighten, never loosen.
- Removal: an explicit gate goes only when a person unassigns the skill and
its package removal completed. An agent's unassign, the system key, a
deferred removal, Sync, start and the sweep keep it (gates_kept), so
unassign-then-reassign cannot launder a gate away. Own-skill gates are
sticky until cleared. This departs from the issue's "removing a skill
removes its gate" for own skills and non-person removals, by the recorded
scope decisions.
- Marker ordering (#752): the fail-closed marker is written before an
agent's first gate (decided under the per-agent lock) and removed after
its last; stopped agents are never exec'd.
- Audit on every change (who; via ui/api/orchestrator/system; trigger).
Tests: tests/unit/test_ent753_*.py, src/mcp-server/src/tools/skill-gates.test.ts;
every write path, auth bound and ordering line mutation-checked (each red).
Security: docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (trinity-enterprise#753)
verify-local's unit stage (random order) found two things the per-file runs
could not:
- `write_skill_gate` marked an unsent field with a module-level `KEEP =
object()` compared by identity. After another test re-imported `db.*`, the
caller's KEEP and the DB module's were different objects, so the sentinel
itself reached SQL ("type 'object' is not supported"). The write now takes
`changes` — only the fields to set — so there is nothing to compare by
identity and a reload cannot turn "keep" into a value.
- The service and route fixtures patched `import services.x as X`, which reads
the package attribute; after another test's re-import that can be a stale
copy while the code under test imports from sys.modules (learning
2026-10-05). The fake Docker exec then missed, the real exec ran, and the
marker tests saw no marker events. The fixtures now patch
`importlib.import_module(...)`; reproduced with a stale package binding
(red), then green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#3344 item 2) `skill_gate_requests` in database.md, `skill_gate_service.py` and `skill_gate_map_service.py` in backend.md's service catalog, the MCP gate result handling in mcp-server.md, and the dispatch gate's person-only endings in security.md — each two lines or fewer, beside the gate map rows this branch already added. Item 2 of #3344; items 1 and 3 stay open there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able, as GET does (trinity-enterprise#753) Found smoke-testing the routes on the verify-local sibling stack: GET reported approver_reachable=false for an owner with no email while the PUT response's gate carried null (only its warnings said approver_unassigned). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#3236 landed the shared `require_person_or_capability` / `_path_agent` this branch carried a verbatim copy of. Resolved `src/backend/dependencies.py` by keeping dev's version (with `_refuse_unless_owners_agent` and `capability_fence`) and dropping the duplicate; `get_skill_gate_readable_agent_by_name` stays. The gate routes' census entries still classify (test_2996 green); the flow doc no longer calls the helper a copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
…n to 0094 dev gained `0093_platform_alert_responded_heal` (#3338) on the same parent as this branch's `0093_agent_skill_gates` — two heads, so `upgrade head` would apply nothing (alembic-head-watch). The revision is now `0094_agent_skill_gates` on top of `0093_platform_alert_responded_heal`; it was never applied to a persistent PostgreSQL (local installs are SQLite, CI and verify-local databases are throwaway), so a rename, not a merge revision. Conflicts: `db/migrations.py` keeps both entries (dev's heal first, then `agent_skill_gates`; the SQLite runner tracks by name, so an install that already applied `agent_skill_gates` is unaffected) and `tests/registry.json` keeps both sets of entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # docs/memory/feature-flows/skill-gate.md # docs/memory/requirements/security.md
|
merge-train (mechanical): merged |
Description
This adds the per-agent skill gate map: for each agent, which skills need approval before they run, and which kind of person approves. The dispatch check (#3208) and the in-container hook (#3252) already read it through
skill_gate_service.list_skill_gates, which returned{}until now. Once this merges, gating is live.Storage. A new table,
agent_skill_gates, holds one row per agent and skill: the approver (primaryorapprover), an optional deadline (1–168 hours; empty means 24), and where the row came from (set,library_default, orcleared, a tombstone that gates nothing).agent_skill_gates, and Alembic0094_agent_skill_gateson top of0093_platform_alert_responded_heal(re-chained after fix(operator-queue): acknowledging a platform alert ends it (#2372) #3338 landed its own0093). One head.AgentRef, so the rows follow a rename and are purged with the agent.gate_unavailableinstead of treating the agent as ungated.REST:
GET/PUT/DELETE /api/agents/{agent_name}/skill-gates[/{skill_name}]. MCP:list_skill_gates,set_skill_gate,clear_skill_gate.list_skillsnow also carries the library'sapprovalkey.Who can write a gate:
skills.manage, only on agents its owner owns, and never on itself: an agent cannot set, loosen or lift its own gate.Everyone else is refused with a 403 before the target is looked up, including the system key, connector keys and a principal with no scope.
Who can read: anyone with access to the agent. An agent key can read its own gates, or, holding
skills.manage, the gates of an agent its owner owns.set_byis withheld from machine keys (test: gate dev → open-PR merges on the unit-test suite #715).Refusals by name:
invalid_skill_name,invalid_approver,approver_unavailable,invalid_deadline.approver_unavailablecomes back when no assignments provider is registered; in that case onlyprimaryis offered, andapprover_kindssays so.ephemeral_agent.Each gate reports
approver_reachable. A write whose approver kind reaches nobody returns the warningapprover_unassigned. That is the default case on an install whose admin has no email.Library default. A library skill whose
SKILL.mdsaysapproval: recommendedis gated before its package is delivered. Agents that already held the skill get the gate at their next start or library sync. The library can only tighten: a default is never removed because the metadata changed. When the owner clears a default, the clear sticks.Removal. No gate goes while the skill's files may still be on the agent.
gates_kept, so unassigning and re-assigning a skill cannot launder a gate away.Marker ordering (feat(skills): a gated skill is refused inside the agent unless its run was approved for it (abilityai/trinity-enterprise#752) #3252). The fail-closed marker is written before an agent's first gate (decided under the per-agent lock) and removed after its last. A stopped agent is never exec'd.
Audit. Every change is audited, but only when a row actually changed. Each audit row records who made the change,
via(ui/api/orchestrator/system) and the trigger.Deviations from the issue's acceptance criteria (recorded scope decisions)
Merge order
547deb2c6), anddevis merged into this branch (9fea4bd38). The merge-order requirement is met. The two doc conflicts kept both sides.dev(7d7b350e4) and now uses feat(auth): an agent changes its own shape only with a grant (ent#164) #3236'srequire_person_or_capability. The duplicate copy is gone; dev's version is kept, including_refuse_unless_owners_agent.Related Issue
Fixes abilityai/trinity-enterprise#753
It also covers item 2 of #3344, the architecture catalog rows for
skill_gate_requests, the gate services, the MCP gate results and the dispatch gate. Items 1 and 3 there stay open.Journey Impact
Journey Impact: none: no promise in tests/journeys/catalog.yaml covers gated skills yet; this adds the configuration surface for the existing gate
Type of Change
Testing
737d4db71on localhost, all 12 steps passed, each checked in the DB (test plan below)New tests:
tests/unit/test_ent753_*.py: 136 tests over the real per-test DB, the real routes and real dependencies.src/mcp-server/src/tools/skill-gates.test.ts: 6 tests.test_ent753_assignment_hooks.py) makes everyagent_skillswriter insrc/backendreach the gate reconcile, whether it calls the writer or passes it by reference.Results:
tscis clean.check_alembic_headsandcheck_alembic_parity.test_ent666_objective_join,test_ent477_*(two files) andtest_retention_floorfail only in some random orders and pass alone;test_ent751_gate_callers::TestPortalfails under one random seed and fails the same way on the base commit;test_ent679_payments_pin_parityfails on the host's package pins;test_2996_route_census_runtimeerrors because the host venv lacksopentelemetry.exporter./verify-local --skip-unit --skip-agent:test_circuit_breakerdormant-alert, already failing on dev and tracked as bug: circuit-breaker integration test still counts the old operator-queue call for the dormant alert (stale since #3246) #3343.set_skill_gatethenlist_skill_gatesthrough a user-scoped key, auditedvia=api.Mutation: n/a — not a fix. For the record: 28 hand mutations, covering the write paths, the auth bounds, the marker ordering, the drop-only-after-the-package-is-gone rule, the partial-update merge and the
set_byredaction. Each turned a test red, and each was restored byte-for-byte.Test plan (localhost)
/datais a Docker volume, so do it through the container: run Pythonsqlite3.backupto/data/backups/pre-eyeball-753-<ts>.db, thendocker cpit to~/trinity-backups. Then switch the checkout to the PR head; the backend reloads and applies the migration.mcp-serverif you want the MCP tools. REST works without it.ok(agent/health→skill_gate_hook). If it has no skills, add a temporary own skill for the test and remove it afterwards.GET /api/agents/<agent>/skill-gatesreturns no gates.approver_kindsis["primary"]on OSS, plus"approver"where an assignments provider is registered.PUT …/skill-gates/<skill>with{}.changed: true.warningssaysapprover_unassigned, give the owner an email first./opt/trinity/skill-gates-activeexists in the container, owned by root, mode0444.via=ui(by design, a JWT is the UI). A user-scoped key is auditedvia=api./<skill> …in the Workspace chat (or, now that fix(skill-gate): close the #3208 validation gaps before the gate map lands (#3274) #3345 is on dev, the agent page's Chat tab).skipped.skill_gate_refusedwithvia=skill_toolandreason=not_cleared, and no card is raised./<skill> …. It runs directly, auditedskill_gate_self_approved.set_bycomes back null.person_required, whether or not the agent holdsskills.manage, because the self bound is checked first.skill_management_not_permitted, and nothing is written.DELETE …/skill-gates/<skill>returnschanged: true, and the marker is gone.Security:
docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.md. One LOW finding (set_byreaching machine keys), fixed in this branch with a test.Checklist
feature-flows/skill-gate.md§6, the skills flows, the architecture catalogs, and a learnings fragment🤖 Generated with Claude Code