Skip to content

feat(skills): a gated skill is refused inside the agent unless its run was approved for it (abilityai/trinity-enterprise#752) - #3252

Merged
vybe merged 6 commits into
devfrom
feature/752-gated-skill-hook
Oct 6, 2026
Merged

vybe merged 6 commits into
devfrom
feature/752-gated-skill-hook

Conversation

@webmixgamer

Copy link
Copy Markdown
Contributor

Fixes abilityai/trinity-enterprise#752

Summary

The dispatch-time gate (abilityai/trinity-enterprise#751) reads what a requester typed. So a request that names a gated skill only in prose ("please pay the invoice") reached the executor, and the agent's own Skill call loaded the skill. On Claude Code agents, a PreToolUse hook now asks the platform before a skill loads into a run. It refuses the skill unless that run was cleared for it.

The clearance is the execution and the skill (D3), not an input hash. A run is cleared for a gated skill by one of two records:

  • the abilityai/trinity-enterprise#751 approved-run record;
  • a new self_approved record, written when the approver sends the request themselves.

Nothing hashes or compares the run's input.

What changed

  • The hook: docker/base-image/hooks/skill-gate.py plus _skill_gate.py.
    • It is registered by its own managed-settings drop-in, /etc/claude-code/managed-settings.d/50-skill-gate.json (root, 0444).
    • It runs in exec form under env -i with -I -S. It fires on Skill calls, and on Agent/Task calls whose subagent definition preloads skills.
    • Identity comes from /proc (the claude process's launch environment and the container's), never from the hook's own environment.
    • Every outcome is exit 0 or exit 2, under a 10-second deadline.
    • When the platform gives no verdict, a root-owned marker decides. An agent with gates refuses; every other agent keeps its skills.
  • The check: POST /api/skill-gate/check (routers/skill_gate.py → skill_gate_service.check_invocation).
    • The agent comes from its own key (get_self_agent), and every verdict is a 200.
    • A refusal hands the request back ("…a message that includes /pay-invoice and what they want done…") and names no person or role.
    • Each refusal is audited as skill_gate_refused. The audit is throttled per run and skill, and per agent.
  • Self-approval is recorded on the run the agent receives. record_self_approval writes the self_approved row at /task, at /chat (moved from admission to the row's setup) and in the execute_task backstop. There is no migration: it is a new value of skill_gate_requests.state, outside the decision lattice.
  • A self-approved /chat turn runs in its own session (isolated_session), so the next caller never resumes a context with the skill loaded. Workspace threads and the Chat tab keep their continuity, because there the conversation belongs to the approver.
  • The marker is synced at start and recreate for gated agents, re-synced from the check (rate-limited), and serialised per agent. The agent's /health reports skill_gate_hook.
  • Test isolation repair (a1e0dd4a8). test_channel_image_vision.py re-imported adapters.message_router under patch.dict(sys.modules). That restores the dict, but not the adapters package attribute, so a mock leaked into later tests and caused an order-dependent failure in test_ent751_gate_callers.py. A learnings fragment is included.

Stated limits (requirements §26.13)

  • Claude Code only. Codex and Gemini have no hook.
  • Claude Code expands a message that starts with /skill with no Skill call, so the hook is never asked about 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.
  • This is not a boundary against an adversarial executor; credential confinement is.
  • It stays inert until abilityai/trinity-enterprise#753 supplies the gate map.

Verification

  • Unit: ten new test_ent752_* files, plus the touched test_ent751_* suites. The targeted set on the merged tree: 857 passed. Every wired call site and each review change was mutation-checked: 43 mutants killed, none survived.
  • Full unit suite, run sequentially (verify-local's unit-stage command): no new failures. The failures are exactly dev's known order-dependent set at 1e77bf37e, minus one that a1e0dd4a8 repairs.
  • /verify-local: PASS. Every stage passed:
    • backend image build and import main;
    • agent base image build, including the hook's --self-test, and import agent_server;
    • boot and health;
    • a real agent created from the branch's base image, healthy with skill_gate_hook: "ok";
    • integration: 70 passed, 13 gated skips, 2 known false-fails deselected.
  • /cso --diff: 0 findings (docs/security-reports/cso-diff-2026-10-05-ent752-gated-skill-hook.md).
  • Manual check on a local instance: base image built from the branch, gates supplied by a local-only shim. Every case passed:
    • another agent asks in prose → the Skill call is refused and handed back (not_cleared);
    • an approved run with the slash mid-sentence → the hook allows it, and the skill runs;
    • the approver's own run with the slash mid-sentence → a self_approved record, and the call is allowed;
    • classic /chat → the self-approved turn never enters the shared session;
    • a subagent that preloads the skill → refused;
    • a terminal session → refused (no_run);
    • the check answering 503 → the gated agent refuses, and an agent with no gates runs;
    • /health reports skill_gate_hook: ok, and the hook's log never carries the key.

Docs

  • requirements/security.md §26.13 and §28.2
  • feature-flows/skill-gate.md §5 and Testing
  • architecture/{agent-runtime,api-endpoints,backend,execution,security}.md
  • feature-flows.md
  • the /cso report
  • a learnings fragment

No DB migration.

🤖 Generated with Claude Code

webmixgamer and others added 5 commits October 5, 2026 16:36
…patch.dict re-import

test_channel_image_vision re-imports adapters.message_router inside a
patch.dict(sys.modules, ...) that mocks adapters.base. patch.dict puts the
original module back in sys.modules on exit, but not the `adapters` package
attribute the re-import rebound, and `import adapters.message_router as mr`
resolves through that attribute. A later test then received the module built on
the mocked ChannelResponse: test_ent751_gate_callers::
test_a_channel_message_held_by_the_gate_is_answered_in_the_conversation failed
after test_1533 + test_channel_image_vision, reproduced on the base commit
09f9d08 (pre-existing, order-dependent; surfaced by an -n auto run).

An autouse fixture in the polluting file puts the binding back after every
test. The three-file reproduction goes from 1 failed to 88 passed. Learnings
fragment added (the 2026-08-10 package-attribute / sys.modules class).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n was approved for it (Abilityai/trinity-enterprise#752)

The dispatch-time gate (#751) reads what a requester typed, so a request
that names a gated skill only in prose ("please pay the invoice") reached
the executor, and the agent's own Skill call loaded the skill. On Claude Code
agents a PreToolUse hook now asks the platform before a skill loads into a
run, and refuses it unless that run was cleared for it.

- Hook: docker/base-image/hooks/skill-gate.py (bootstrap) + _skill_gate.py,
  registered by its own managed-settings drop-in
  (/etc/claude-code/managed-settings.d/50-skill-gate.json, root 0444, exec
  form under `env -i` + `-I -S`, LD_* pinned empty) on Skill and on
  Agent/Task subagent `skills:` preloads. Identity from /proc (the claude
  process's launch env, the container's), never its own env. Every outcome
  is exit 0 or 2 under a 10 s deadline — for this CLI exit 1, a crash or a
  hook timeout lets the tool run. No verdict → a root-owned marker decides:
  an agent with gates fails closed, every other agent keeps its skills.
- Check: POST /api/skill-gate/check (routers/skill_gate.py), the agent from
  its key (get_self_agent), every verdict a 200; refusals hand the request
  back ("…with a message that includes /pay-invoice…"), name no person or
  role, and are audited skill_gate_refused (throttled).
- Clearance is execution + skill (D3), not an input hash: #751's approved-run
  record, or a new `self_approved` record that record_self_approval writes at
  /task, /chat (moved from admission to the row's setup — the agent receives
  the row's id, not the capacity slot's) and the execute_task backstop. No
  migration: a new value in skill_gate_requests.state, outside the lattice.
- A self-approved /chat turn runs in its own session (isolated_session), so
  the next caller never resumes a context with the skill loaded.
- Marker synced at start/recreate (gated agents only), self-healed from
  the check (rate-limited) and serialised per agent; /health reports
  skill_gate_hook.
- Review hardening: a skill or subagent definition whose name the hook
  cannot read (or an unterminated front matter) is "could not tell", never
  "not gated"; block-scalar names are read; no subagent_type means
  general-purpose (a definition may override it); CLAUDE_CONFIG_DIR from
  the claude env adds its dirs; refusal audits are capped per agent (the
  run id is the caller's); a logger that cannot start never changes the
  verdict. Every wired call site and each review fix was mutation-checked.

Inert until ent#753 supplies the gate map. Stated limits in
requirements/security.md §26.13 (Claude Code only; not a boundary against an
adversarial executor — credential confinement is, R23 / ent#558).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ading slash never reaches it (Abilityai/trinity-enterprise#752)

The localhost eyeball showed that Claude Code expands an approved or
self-approved request that starts with /pay-invoice without any Skill call,
so the hook is never asked about it; the dispatch-time check decides those.
The hook's allow path is a Skill call inside the cleared run (a slash
mid-sentence, or the model choosing the skill).

- skill-gate.md Testing step 7 now uses the mid-sentence form for the approved
  and self-approved cases, adds the isolated /chat probe and the no-run case,
  replaces "stop the backend" (the chat itself goes through it) with a check
  route that answers 503, and saves the running base image before the build so
  the restore never downgrades the agents.
- §26.13 and the flow's limits say what a clearance lets through.
- "the session tab" is the Chat tab.
- Status records the 2026-10-05 eyeball.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l-hook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st-runner classifies its skip (Abilityai/trinity-enterprise#752)

The skip matched no rule in test-runner's skip-patterns.txt and showed up as an unclassified warning in /verify-local's unit stage. Its skipif is exactly 'not Linux', so the reason now says so and the existing GATED:Linux only rule classifies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@AndriiPasternak31 AndriiPasternak31 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.

Reviewed #3252 (structural /review + /validate-pr, lane C). Nice work — the gate's load-bearing predicates all have teeth: I mutated the cross-agent run check, the cross-agent record check, the liveness check, the "manual" id, the hook's no-verdict branch, and both /chat wiring points, and each one turns a named test red (342 passed / 1 skipped on the targeted set locally). Agent keys can't mint a clearance (record_self_approval only fires on a person-proven decision; AST guard pins the producers), the check route is a self-uniform use with a census entry, and every existing reader of skill_gate_requests filters by state, so self_approved doesn't leak into the sweeps/caps/backstop.

Before merge:

  • Conflict: docs/memory/feature-flows.md only — #3240's ent#568 row and this ent#752 row both landed at the head of the changelog table. Keep both; the other three files auto-merge with no semantic overlap.
  • AC deviations need a ruling on trinity-enterprise#752. The issue says "when the backend cannot answer, a gated or unknown skill is refused" and "token bound to skill and input". The PR fails closed only where the marker exists (_skill_gate.py:784-791 — an agent whose marker is missing, e.g. fresh after recreate or map unreadable at start, allows on no-verdict) and binds (execution, skill) only (D3, skill_gate_service.py:839-852). Both are defensible and documented in §26.13, but there's no sign-off on the issue — could @vybe confirm there?
  • Lane C merge gate: frontend-e2e was skipped on the PR (no ui label); please add ui so it runs before merge.

Follow-ups (non-blocking, happy to file):

  • /health.skill_gate_hook has no backend consumer, so a gated agent on a pre-#752 image or on Codex/Gemini is silently unenforced in-container. Worth wiring before/with ent#753 so a gate can't be set on an agent that can't enforce it.
  • _marker_locks (skill_gate_service.py:939-963) serialises per worker only; two workers can interleave a stale rm -f after a fresh write — heal fixes it within 5 min. A Redis lease or a map-version compare would close it; at least the requirements wording ("Syncs of one agent are serialised") should say "per worker".
  • The drop-in is unconditional, so every Skill call fleet-wide now spawns the hook + a backend round-trip (≤4 s added if the backend hangs) even before ent#753 — fine, but worth stating next to "inert".
  • skill_gate_requests has no retention; each self-approved run now adds a row with up to 6000 chars of request text.

Not verified on my side: image build/self-test, the hook inside a real 2.1.281 claude, PG path of record_self_approved_run (sqlite only), full unit suite.

merge-train: resolve docs/memory/feature-flows.md — keep both 2026-10-05 rows
(ent#752 from this branch, ent#568 from dev). Mechanical, per the merge-train
note on the PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vybe

vybe commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-06): pushed one mechanical commit to feature/752-gated-skill-hook. It merges origin/dev, and the only conflict was docs/memory/feature-flows.md, where both sides added a 2026-10-05 row. I kept both rows (ent#752 first, then ent#568). On the merged tree, the ent752/ent751 suites plus the auth guards (2996, 1310, 293, models_centralized) and the ent568 and touched suites give 825 passed, 1 skipped.

One question for the owner, not a blocker: like #751, enforcement here is ungated OSS core and does nothing until ent#753 supplies the gate map. If the gate map is meant to be paid, that private module is where the entitlement gate belongs.

@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/20261006-0833 (#3266)

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.

4 participants