mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
bcb8c5ab88
Two independent multi-agent reviews of the branch (high, then max effort) found authority-confinement and robustness defects the per-step reviews could not see. This commit addresses every confirmed finding. task_agent turned out to be the surface that lagged its siblings on nearly every axis. Risk gate (most severe): - task_agent(skill=...) never enforced the high/critical-risk PRINCIPAL-load- only gate that skills(load) / spawn_workstream / spawn_batch enforce, so a model could route around it by delegating activation to a sub-agent. Enforce it inline in _prepare_task on the row already fetched (no re-query, no drift between get_skill_by_name and get_prompt_template_by_name). - _high_risk_skill_denied now fails CLOSED on a storage fault: deny, never wave the skill through. Denying (not returning "") also keeps spawn_batch's per-row partial-success intact under a transient blip. - (first round) extracted _high_risk_skill_denied onto spawn_workstream / spawn_batch, closing the coordinator-side bypass. Persona confinement (Principle 7 attenuation on the task_agent edge): - A restrictive persona now attenuates the sub-agent's TOOLS, not just its identity text — the tool lever is frozen into the item and filtered before _run_agent. - Honor ALL FOUR persona levers on the sub-agent, not two: a child persona's mcp-off and memory-off levers now drop MCP tools (mcp__* + read_resource / use_prompt) and the memory tool, matching a main session under the persona. - Cap the sub-agent by the PARENT session's own persona grant too, so a restricted principal cannot escalate authority by spawning. - Add persona to the task_agent judge/audit func_args projection (policy + audit parity with spawn). - Persona-resolution failures defer to a clean tool error (try/except mirroring _validate_child_persona) instead of an opaque "internal error". Substitution / capability: - substitute_args=False for capability contexts (defaults, task_agent) so a literal $ARGUMENTS / $N in a body is preserved, not blanked; env vars still resolve. The literal-$ARGUMENTS scan is deferred behind that guard (skipped on every capability render). - Drop the CLAUDE_SKILL_DIR alias (canonical TURNSTONE_SKILL_DIR only). That name also lives in bash, where turnstone-as-a-node-inside-Claude-Code must not shadow the host's value; claiming it in the prompt but deferring in bash diverged the two surfaces (a review finding). turnstone now claims it in neither surface. The CLAUDE_SESSION_ID / CLAUDE_EFFORT prompt aliases stay (pure prompt values, no bash-namespace collision). Skills-as-context: - DEFAULT (always-on) skills stay in the identity system message — the standing baseline, never a mid-session cache-bust; only a NAMED applied skill moves to the user-role capability message. This shrinks the pending model-adherence eval surface to the named-skill move alone. Cleanups: consolidate a duplicated rationale comment; correct the now-stale "task agents are not persona-filtered" note. PRE-MERGE GATE unchanged: the §7 Q1 model-adherence eval (named-skill move, this branch vs main) is not runnable in-tree and must clear before merge.
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""Skill substitution unification (SKILL.md subsystem refactor, step 1).
|
|
|
|
Pins the invariant that skill-body placeholder substitution is IDENTICAL
|
|
across every invocation context. Interactive load, default skills, and
|
|
``task_agent`` sub-agents all route through
|
|
``ChatSession._render_skill_body`` — so a skill reading ``$ARGUMENTS`` or
|
|
``${TURNSTONE_EFFORT}`` resolves the same everywhere, rather than
|
|
rendering literally on the ``task_agent`` path (which previously ran
|
|
``_render_template`` alone).
|
|
|
|
Also covers the two behaviours the unified path newly guarantees:
|
|
|
|
* ``${TURNSTONE_*}`` env vars (canonical) and their ``${CLAUDE_*}``
|
|
back-compat aliases both resolve.
|
|
* ``${TURNSTONE_SKILL_DIR}`` resolves to the concrete materialized bundle
|
|
path in the rendered body, because resources are materialized BEFORE
|
|
substitution (the ordering fix).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from tests._session_helpers import make_session
|
|
from turnstone.core.storage._registry import get_storage
|
|
|
|
if TYPE_CHECKING:
|
|
import pytest
|
|
|
|
|
|
def _create_skill(db: Any, skill_id: str, name: str, content: str, **kw: Any) -> None:
|
|
db.create_prompt_template(
|
|
template_id=skill_id,
|
|
name=name,
|
|
category=kw.get("category", "general"),
|
|
content=content,
|
|
variables="[]",
|
|
is_default=kw.get("is_default", False),
|
|
org_id="",
|
|
created_by="test",
|
|
origin="manual",
|
|
mcp_server="",
|
|
readonly=False,
|
|
description="",
|
|
tags="[]",
|
|
source_url="",
|
|
version="1.0.0",
|
|
author="",
|
|
activation=kw.get("activation", "named"),
|
|
token_estimate=0,
|
|
model="",
|
|
auto_approve=False,
|
|
temperature=None,
|
|
reasoning_effort="",
|
|
max_tokens=None,
|
|
token_budget=0,
|
|
agent_max_turns=None,
|
|
notify_on_complete="{}",
|
|
enabled=True,
|
|
allowed_tools="[]",
|
|
priority=0,
|
|
)
|
|
|
|
|
|
class TestRenderSkillBodySharedPath:
|
|
"""``_render_skill_body`` is the single substitution path — the one
|
|
``task_agent`` now calls. With ``substitute_args=True`` (arg-capable
|
|
invocations: interactive /skill, skills(load)) the spec arg forms
|
|
resolve; with ``substitute_args=False`` (capability contexts: defaults,
|
|
task_agent) literal ``$N``/``$ARGUMENTS`` are left untouched. Env vars
|
|
resolve either way."""
|
|
|
|
def test_env_vars_resolve(self, tmp_db: str) -> None:
|
|
session = make_session(reasoning_effort="high")
|
|
try:
|
|
out = session._render_skill_body(
|
|
"id ${TURNSTONE_SESSION_ID} effort ${TURNSTONE_EFFORT}"
|
|
)
|
|
assert out == f"id {session._ws_id} effort high"
|
|
finally:
|
|
session.close()
|
|
|
|
def test_claude_aliases_resolve(self, tmp_db: str) -> None:
|
|
session = make_session(reasoning_effort="low")
|
|
try:
|
|
out = session._render_skill_body("id ${CLAUDE_SESSION_ID} effort ${CLAUDE_EFFORT}")
|
|
assert out == f"id {session._ws_id} effort low"
|
|
finally:
|
|
session.close()
|
|
|
|
def test_arg_capable_path_clears_bare_arguments_when_no_args(self, tmp_db: str) -> None:
|
|
# Arg-capable invocation (interactive /skill, skills(load)) with no
|
|
# args → bare $ARGUMENTS clears to empty, per the SKILL.md spec.
|
|
session = make_session()
|
|
try:
|
|
assert session._render_skill_body("before $ARGUMENTS after") == "before after"
|
|
finally:
|
|
session.close()
|
|
|
|
def test_curly_and_spec_passes_both_apply(self, tmp_db: str) -> None:
|
|
# Legacy ``{{model}}`` AND spec ``${TURNSTONE_EFFORT}`` in one body —
|
|
# both passes run through the shared path.
|
|
session = make_session(model="my-model", reasoning_effort="high")
|
|
try:
|
|
out = session._render_skill_body("model {{model}} effort ${TURNSTONE_EFFORT}")
|
|
assert out == "model my-model effort high"
|
|
finally:
|
|
session.close()
|
|
|
|
def test_arg_capable_path_clears_positional_tokens_when_no_args(self, tmp_db: str) -> None:
|
|
# Arg-capable path with no args → positional forms clear to empty (spec).
|
|
session = make_session()
|
|
try:
|
|
out = session._render_skill_body("step $1 / $0 / $ARGUMENTS[2] done")
|
|
assert out == "step / / done"
|
|
finally:
|
|
session.close()
|
|
|
|
def test_capability_context_preserves_literal_arg_tokens(self, tmp_db: str) -> None:
|
|
# Capability contexts (task_agent, defaults) never receive invocation
|
|
# args, so substitute_args=False leaves literal $ARGUMENTS/$N/$name
|
|
# untouched (they are prose/shell text) while env vars still resolve.
|
|
# Pins the review fix that stopped blanking such tokens for sub-agents.
|
|
session = make_session(reasoning_effort="high")
|
|
try:
|
|
out = session._render_skill_body(
|
|
"run ./deploy.sh $1 $2 at ${TURNSTONE_EFFORT}; process $ARGUMENTS",
|
|
substitute_args=False,
|
|
)
|
|
assert out == "run ./deploy.sh $1 $2 at high; process $ARGUMENTS"
|
|
finally:
|
|
session.close()
|
|
|
|
def test_skill_dir_literal_on_sub_agent_path(self, tmp_db: str) -> None:
|
|
# task_agent calls _render_skill_body with no skill_dir (sub-agent
|
|
# bundles aren't materialized yet), so ${TURNSTONE_SKILL_DIR} stays
|
|
# literal on this path — unchanged from before, resolved in a later
|
|
# step. The env vars that DO have values still resolve.
|
|
session = make_session(reasoning_effort="high")
|
|
try:
|
|
out = session._render_skill_body(
|
|
"dir ${TURNSTONE_SKILL_DIR} effort ${TURNSTONE_EFFORT}"
|
|
)
|
|
assert out == "dir ${TURNSTONE_SKILL_DIR} effort high"
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
class TestSkillDirResolvesInBody:
|
|
"""Materialize-before-substitute: ``${TURNSTONE_SKILL_DIR}`` in a skill
|
|
body resolves to the concrete on-disk bundle path after a full load."""
|
|
|
|
def test_turnstone_skill_dir_in_body(self, tmp_db: str) -> None:
|
|
db = get_storage()
|
|
_create_skill(db, "s1", "dir-skill", "Scripts under ${TURNSTONE_SKILL_DIR}/scripts.")
|
|
db.create_skill_resource("r1", "s1", "scripts/go.py", "print('x')")
|
|
|
|
session = make_session(skill="dir-skill")
|
|
try:
|
|
base = session._skill_resources_dir
|
|
assert base is not None
|
|
assert session._skill_content == f"Scripts under {base}/scripts."
|
|
finally:
|
|
session.close()
|
|
|
|
def test_claude_skill_dir_not_aliased_in_body(self, tmp_db: str) -> None:
|
|
# CLAUDE_SKILL_DIR is NOT a turnstone-owned alias: it stays a literal
|
|
# placeholder even with a materialized bundle (that name belongs to the
|
|
# host in bash; turnstone claims neither surface). The canonical
|
|
# TURNSTONE_SKILL_DIR does resolve.
|
|
db = get_storage()
|
|
_create_skill(
|
|
db,
|
|
"s1",
|
|
"dir-alias-skill",
|
|
"Bundle at ${CLAUDE_SKILL_DIR} vs ${TURNSTONE_SKILL_DIR}",
|
|
)
|
|
db.create_skill_resource("r1", "s1", "references/a.md", "# a")
|
|
|
|
session = make_session(skill="dir-alias-skill")
|
|
try:
|
|
base = session._skill_resources_dir
|
|
assert base is not None
|
|
assert session._skill_content == f"Bundle at ${{CLAUDE_SKILL_DIR}} vs {base}"
|
|
finally:
|
|
session.close()
|
|
|
|
def test_skill_dir_literal_without_resources(self, tmp_db: str) -> None:
|
|
# No bundled resources → no dir → placeholder stays literal
|
|
# (graceful degradation), not an empty path.
|
|
db = get_storage()
|
|
_create_skill(db, "s1", "no-res-skill", "Path ${TURNSTONE_SKILL_DIR} here.")
|
|
|
|
session = make_session(skill="no-res-skill")
|
|
try:
|
|
assert session._skill_resources_dir is None
|
|
assert session._skill_content == "Path ${TURNSTONE_SKILL_DIR} here."
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
class TestSkillResourceEnvAliases:
|
|
"""Bash env exposes the materialized bundle dir under turnstone-owned
|
|
names (``TURNSTONE_SKILL_DIR`` / ``SKILL_RESOURCES_DIR``) unconditionally,
|
|
and never under the foreign ``CLAUDE_SKILL_DIR`` — that name is the host's,
|
|
so turnstone leaves it untouched whether or not the host has set it."""
|
|
|
|
def test_turnstone_owned_names_present_claude_absent(
|
|
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("CLAUDE_SKILL_DIR", raising=False)
|
|
db = get_storage()
|
|
_create_skill(db, "s1", "env-alias-skill", "content")
|
|
db.create_skill_resource("r1", "s1", "scripts/t.py", "code")
|
|
|
|
session = make_session(skill="env-alias-skill")
|
|
try:
|
|
env = session._skill_resource_env()
|
|
d = session._skill_resources_dir
|
|
assert env["SKILL_RESOURCES_DIR"] == d
|
|
assert env["TURNSTONE_SKILL_DIR"] == d
|
|
# turnstone never supplies CLAUDE_SKILL_DIR (the host's namespace),
|
|
# even when the host hasn't set it.
|
|
assert "CLAUDE_SKILL_DIR" not in env
|
|
finally:
|
|
session.close()
|
|
|
|
def test_host_claude_skill_dir_untouched(
|
|
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# turnstone as a node inside Claude Code: the host's CLAUDE_SKILL_DIR
|
|
# must survive. turnstone never injects CLAUDE_SKILL_DIR into the
|
|
# extra-env, so scrubbed_env's passthrough keeps the host value.
|
|
monkeypatch.setenv("CLAUDE_SKILL_DIR", "/host/claude/skill")
|
|
db = get_storage()
|
|
_create_skill(db, "s1", "env-host-skill", "content")
|
|
db.create_skill_resource("r1", "s1", "scripts/t.py", "code")
|
|
|
|
session = make_session(skill="env-host-skill")
|
|
try:
|
|
env = session._skill_resource_env()
|
|
d = session._skill_resources_dir
|
|
assert env["TURNSTONE_SKILL_DIR"] == d
|
|
assert env["SKILL_RESOURCES_DIR"] == d
|
|
assert "CLAUDE_SKILL_DIR" not in env
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
class TestSkillContextPlacement:
|
|
"""Step 3: an applied skill's body rides its own capability context
|
|
message (user role), separate from the identity system message — so it
|
|
never occupies the cached identity prefix or reads as identity, and it
|
|
does not leak into the task_agent base."""
|
|
|
|
def test_skill_body_in_context_message_not_identity(self, tmp_db: str) -> None:
|
|
db = get_storage()
|
|
_create_skill(db, "s1", "place-skill", "PLACEMENT_MARKER body text")
|
|
|
|
session = make_session(skill="place-skill")
|
|
try:
|
|
msgs = session.system_messages
|
|
# Identity system message is first, role=system, and skill-free.
|
|
assert msgs[0]["role"] == "system"
|
|
assert "PLACEMENT_MARKER" not in msgs[0]["content"]
|
|
# Skill rides exactly one separate user-role capability message.
|
|
skill_msgs = [m for m in msgs if m["role"] == "user"]
|
|
assert len(skill_msgs) == 1
|
|
assert "PLACEMENT_MARKER" in skill_msgs[0]["content"]
|
|
# The intro names the active skill so the model knows what it is.
|
|
assert "place-skill" in skill_msgs[0]["content"]
|
|
finally:
|
|
session.close()
|
|
|
|
def test_no_skill_no_context_message(self, tmp_db: str) -> None:
|
|
# No applied skill and no defaults → only the identity system message.
|
|
session = make_session()
|
|
try:
|
|
assert all(m["role"] == "system" for m in session.system_messages)
|
|
finally:
|
|
session.close()
|
|
|
|
def test_agent_prefix_excludes_skill_context(self, tmp_db: str) -> None:
|
|
# task_agent base = the identity system block only; the parent's
|
|
# applied skill does NOT leak into the sub-agent prefix.
|
|
db = get_storage()
|
|
_create_skill(db, "s1", "leak-skill", "SHOULD_NOT_LEAK body")
|
|
|
|
session = make_session(skill="leak-skill")
|
|
try:
|
|
assert len(session._agent_system_messages) == 1
|
|
assert session._agent_system_messages[0]["role"] == "system"
|
|
assert "SHOULD_NOT_LEAK" not in session._agent_system_messages[0]["content"]
|
|
finally:
|
|
session.close()
|