Repository navigation
feat(skills): a gated skill is refused inside the agent unless its run was approved for it (abilityai/trinity-enterprise#752) - #3252
Conversation
…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
left a comment
There was a problem hiding this comment.
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.mdonly — #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-e2ewas skipped on the PR (nouilabel); please adduiso it runs before merge.
Follow-ups (non-blocking, happy to file):
/health.skill_gate_hookhas 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 stalerm -fafter 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_requestshas 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>
|
merge-train (2026-10-06): pushed one mechanical commit to 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. |
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
Skillcall loaded the skill. On Claude Code agents, aPreToolUsehook 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:
self_approvedrecord, written when the approver sends the request themselves.Nothing hashes or compares the run's input.
What changed
docker/base-image/hooks/skill-gate.pyplus_skill_gate.py./etc/claude-code/managed-settings.d/50-skill-gate.json(root, 0444).env -iwith-I -S. It fires onSkillcalls, and onAgent/Taskcalls whose subagent definition preloads skills./proc(the claude process's launch environment and the container's), never from the hook's own environment.POST /api/skill-gate/check(routers/skill_gate.py→skill_gate_service.check_invocation).get_self_agent), and every verdict is a 200.skill_gate_refused. The audit is throttled per run and skill, and per agent.record_self_approvalwrites theself_approvedrow at/task, at/chat(moved from admission to the row's setup) and in theexecute_taskbackstop. There is no migration: it is a new value ofskill_gate_requests.state, outside the decision lattice./chatturn 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./healthreportsskill_gate_hook.a1e0dd4a8).test_channel_image_vision.pyre-importedadapters.message_routerunderpatch.dict(sys.modules). That restores the dict, but not theadapterspackage attribute, so a mock leaked into later tests and caused an order-dependent failure intest_ent751_gate_callers.py. A learnings fragment is included.Stated limits (requirements §26.13)
/skillwith noSkillcall, 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 aSkillcall inside the cleared run: a/skillmid-sentence, or the model choosing the skill.Verification
test_ent752_*files, plus the touchedtest_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.1e77bf37e, minus one thata1e0dd4a8repairs.import main;--self-test, andimport agent_server;skill_gate_hook: "ok";docs/security-reports/cso-diff-2026-10-05-ent752-gated-skill-hook.md).Skillcall is refused and handed back (not_cleared);self_approvedrecord, and the call is allowed;/chat→ the self-approved turn never enters the shared session;no_run);/healthreportsskill_gate_hook: ok, and the hook's log never carries the key.Docs
requirements/security.md§26.13 and §28.2feature-flows/skill-gate.md§5 and Testingarchitecture/{agent-runtime,api-endpoints,backend,execution,security}.mdfeature-flows.mdNo DB migration.
🤖 Generated with Claude Code