Skip to content

feat(skill-gates): the per-agent skill gate map (abilityai/trinity-enterprise#753) - #3347

Merged
vybe merged 7 commits into
devfrom
feature/753-skill-gate-map
Oct 7, 2026
Merged

vybe merged 7 commits into
devfrom
feature/753-skill-gate-map

Conversation

@webmixgamer

@webmixgamer webmixgamer commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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 (primary or approver), an optional deadline (1–168 hours; empty means 24), and where the row came from (set, library_default, or cleared, a tombstone that gates nothing).

    • Both migration tracks: the SQLite entry agent_skill_gates, and Alembic 0094_agent_skill_gates on top of 0093_platform_alert_responded_heal (re-chained after fix(operator-queue): acknowledging a platform alert ends it (#2372) #3338 landed its own 0093). One head.
    • A CASCADE AgentRef, so the rows follow a rename and are purged with the agent.
    • A failed read raises, so the check refuses with 503 gate_unavailable instead 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_skills now also carries the library's approval key.

  • Who can write a gate:

    • a person (a signed-in session, or the person's own user-scoped key) who owns the agent or is an admin;
    • an agent holding 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_by is withheld from machine keys (test: gate dev → open-PR merges on the unit-test suite #715).

  • Refusals by name:

    • 422: invalid_skill_name, invalid_approver, approver_unavailable, invalid_deadline. approver_unavailable comes back when no assignments provider is registered; in that case only primary is offered, and approver_kinds says so.
    • 409: ephemeral_agent.

    Each gate reports approver_reachable. A write whose approver kind reaches nobody returns the warning approver_unassigned. That is the default case on an install whose admin has no email.

  • Library default. A library skill whose SKILL.md says approval: recommended is 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.

    • A default goes once its package removal has completed, or once a start's prune has run.
    • An explicit gate goes only when a person unassigns the skill and the package removal completed.
    • It stays in place for an agent's unassign (on itself or a sibling), the system key, a deferred removal, a Sync, a start or the sweep. The response reports those as 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)

  • Own skills: an own skill's gate stays until someone clears it. The platform gets no event when an agent deletes its own skill folder, so dropping the gate when the skill disappears would let the agent ungate itself.
  • Library skills: "removing a skill removes its gate" holds when a person removes it and the package is gone. In every other case listed above the gate stays.
  • Ephemeral agents cannot hold gates in v1.

Merge order

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

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Testing

  • I have tested this locally: eyeball of 737d4db71 on localhost, all 12 steps passed, each checked in the DB (test plan below)
  • New tests added
  • All existing tests pass. The only failures are the pre-existing ones listed below.
  • Every new test executes the changed path

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.
  • The AST guard (test_ent753_assignment_hooks.py) makes every agent_skills writer in src/backend reach the gate reconcile, whether it calls the writer or passes it by reference.

Results:

  • The related suites pass in fixed order (1,098 tests).
  • The MCP suite passes (810), and tsc is clean.
  • Both migration guards pass: check_alembic_heads and check_alembic_parity.
  • Seven pre-existing test files fail on this host, none of them caused by this branch:
    • test_ent666_objective_join, test_ent477_* (two files) and test_retention_floor fail only in some random orders and pass alone;
    • test_ent751_gate_callers::TestPortal fails under one random seed and fails the same way on the base commit;
    • test_ent679_payments_pin_parity fails on the host's package pins;
    • test_2996_route_census_runtime errors because the host venv lacks opentelemetry.exporter.
  • /verify-local --skip-unit --skip-agent:
  • A smoke test of REST and MCP against the sibling stack's production images: set_skill_gate then list_skill_gates through a user-scoped key, audited via=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_by redaction. Each turned a test red, and each was restored byte-for-byte.

Test plan (localhost)

  1. Back up the DB first. The backend's /data is a Docker volume, so do it through the container: run Python sqlite3 .backup to /data/backups/pre-eyeball-753-<ts>.db, then docker cp it to ~/trinity-backups. Then switch the checkout to the PR head; the backend reloads and applies the migration.
  2. Optional: rebuild mcp-server if you want the MCP tools. REST works without it.
  3. Pick an agent. Use one whose skill-gate hook reports ok (agent /health → skill_gate_hook). If it has no skills, add a temporary own skill for the test and remove it afterwards.
  4. Read the empty map: GET /api/agents/<agent>/skill-gates returns no gates. approver_kinds is ["primary"] on OSS, plus "approver" where an assignments provider is registered.
  5. Set a gate: PUT …/skill-gates/<skill> with {}.
    • Expect changed: true.
    • If warnings says approver_unassigned, give the owner an email first.
    • The marker /opt/trinity/skill-gates-active exists in the container, owned by root, mode 0444.
  6. Audit: a JWT write via curl is audited via=ui (by design, a JWT is the UI). A user-scoped key is audited via=api.
  7. Raise an approval:
  8. Approve it: exactly one run happens, and it succeeds.
  9. Plain-text request: as the non-approver, ask in plain text. The in-container hook refuses it: audit skill_gate_refused with via=skill_tool and reason=not_cleared, and no card is raised.
  10. Self-approval: as the approver, ask for /<skill> …. It runs directly, audited skill_gate_self_approved.
  11. Agent keys:
    • The agent's own key can read its own gates, and set_by comes back null.
    • A PUT or DELETE on itself gets 403 person_required, whether or not the agent holds skills.manage, because the self bound is checked first.
    • On a sibling without the grant it gets 403 skill_management_not_permitted, and nothing is written.
  12. Clear the gate: DELETE …/skill-gates/<skill> returns changed: true, and the marker is gone.

Security: docs/security-reports/cso-diff-2026-10-07-ent753-skill-gate-map.md. One LOW finding (set_by reaching machine keys), fixed in this branch with a test.

Checklist

  • My code follows the project's style guidelines
  • I have updated the documentation (if applicable): requirements §26.13, feature-flows/skill-gate.md §6, the skills flows, the architecture catalogs, and a learnings fragment
  • I have not committed any sensitive data (API keys, credentials, etc.)
  • I have added appropriate logging for new functionality

🤖 Generated with Claude Code

webmixgamer and others added 4 commits October 7, 2026 14:32
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>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

⚠️ Alembic head fork if this PR is merged into dev.

dev has advanced since this PR's checks last ran. GitHub recomputes the merge ref when the base moves but does not re-trigger workflows, so a green schema-parity here can describe a base that no longer exists (#2533).

scripts/ci/check_alembic_heads.py against dev + this PR, merged in memory
alembic-heads: FAIL — src/backend/migrations/versions resolves to 2 heads across 95 revision(s); exactly 1 is required.
  • 0093_agent_skill_gates  (0093_agent_skill_gates.py)
  • 0093_platform_alert_responded_heal  (0093_platform_alert_responded_heal.py)

They fork at: 0092_portal_messages_attachments  (0092_portal_messages_attachments.py)

`alembic upgrade head` is singular and resolves its target BEFORE applying anything,
so this graph applies ZERO revisions — every revision since the fork stops arriving,
not only the one that forked. Fix by chaining the newer revision off the real head,
or — if the forked revision may already be applied somewhere — by adding a merge
revision (`alembic merge -m "…" <head-a> <head-b>`), whose tuple `down_revision`
converges the line from any starting state. See Architectural Invariant #3.
alembic-heads: src/backend/enterprise/backend/migrations/versions — version directory absent (submodule not initialised) — skipped.

Evaluated on the version line only — this PR also conflicts with dev in 2 unrelated file(s), which do not change this verdict but must be resolved before merge:

src/backend/db/migrations.py
tests/registry.json

Fix: rechain this PR's revision off 0093_platform_alert_responded_heal (the current dev head), or — if the forked revision may already be applied somewhere — add a merge revision (alembic merge -m "…" 0093_agent_skill_gates 0093_platform_alert_responded_heal), whose tuple down_revision converges the line from any starting state. See Architectural Invariant #3.

Push the fix and schema-parity re-checks it against the merge ref immediately; this comment clears on the next push to dev touching src/backend/migrations/versions/**.

Advisory — this check does not block merge. · head_sha: 7d7b350e4ccfa646b630a1f483c5479b36ed4e1b · run

webmixgamer and others added 2 commits October 7, 2026 18:31
…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
@vybe

vybe commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

merge-train (mechanical): merged origin/dev (now carrying #3345 and #3338) into this branch (9fea4bd). Two doc conflicts with #3345 resolved keep-both: feature-flows/skill-gate.md status line keeps this PR's "the gate map makes it live" plus #3345's "#3208 gaps closed by #3274 (plus #3233)"; requirements/security.md keeps this PR's gate-map block with the Tests line unioned (test_3274_* + the four frontend specs). Your own e34a145a had already rechained the revision to 0094_agent_skill_gates ← 0093_platform_alert_responded_heal — same as the train used; heads check: 1 head. Validated on train #3349 (all Tier 1 + Tier 2 green).

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20261007-1609 (#3349)

@vybe
vybe merged commit 99706d5 into dev Oct 7, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants