Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/decisions/0015-write-containment.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ Cross-skill intersection was retired. Declared BOUNDED scopes are sibling
directories, so no loaded skill's scope nests inside another's in practice, and
narrowing one sibling by another only denied legitimate chains such as
`investigate → prepare-issue`. Narrowing survives where it means something: a
single skill's scope against one dispatch's `output_dir`.
single skill's scope against one dispatch's `output_dir`. `run_skill` admission and
the `skill-write-path-recipe-alignment` recipe rule both decide that through the one
predicate `output_dir_widens_scope`, and
`tests/server/test_bundled_dispatch_write_scope_admission.py` guards every bundled
dispatch against drift between the two.

A skill the projection manifest does not know — another plugin's skill or a
built-in command — is bound as foreign: it abstains from containment and from join
Expand Down
4 changes: 2 additions & 2 deletions docs/phoropter/execution-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ Each selected lens runs against the target, producing per-lens output files (dia

### Synthesize — Aggregation and Verdict

Reads all per-lens outputs from the capture directory and produces a unified result. The synthesis strategy (§6) determines how conflicts between lens outputs are resolved.
Reads all per-lens outputs from the capture directory (for `vis-lens`, from the captured list of lens output paths) and produces a unified result. The synthesis strategy (§6) determines how conflicts between lens outputs are resolved.

- Synthesize is the only phase that emits the family's final output tokens (e.g., `verdict`, `visualization_plan_path`, `architecture_impact`).
- The synthesis skill is invoked with the capture directory containing all per-lens output files.
- The synthesis skill is invoked with the capture directory containing all per-lens output files; `vis-lens` synthesis instead receives the apply step's captured path list (`vis_lens_output_paths`).

## §4. Configuration Knobs

Expand Down
3 changes: 2 additions & 1 deletion docs/phoropter/recipe-blocks/multi-family-prefixed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,11 @@ vis_synthesize:
/autoskillit:synthesize-vis-plan
${{ inputs.source_dir }}
${{ context.experiment_plan }}
{{AUTOSKILLIT_TEMP}}/run-vis-lenses
"${{ context.vis_lens_output_paths }}"
capture:
visualization_plan_path: "${{ result.visualization_plan_path }}"
report_plan_path: "${{ result.report_plan_path }}"
visualization_plan_trace_path: "${{ result.visualization_plan_trace_path }}"
optional_context_refs: [vis_lens_output_paths]
on_success: next_stage
on_failure: escalate_stop
1 change: 1 addition & 0 deletions scripts/check_single_enforcement_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
("_interactive_invocation_environment_policy", "execution/backends/_claude/environment.py"),
("admit_hook_session_scope", "hooks/_runtime/_hook_settings.py"),
("admit_tool_session_scope", "server/lifecycle/_session_scope.py"),
("bounded_scope_contains", "hooks/_write_scope.py"),
)

#: Policy functions permitted exactly two call sites, iff the second site is
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/hooks/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,6 @@ Files with no registration support their folder; files with several registration
- `_dispatch.py` — Support module: Stable hook dispatcher — NEVER RENAME THIS FILE.
- `_join_ledger.py` — Support module: Stdlib-only durable authority for declared fixed-set join batches.
- `_session_binding.py` — Support module: Shared authority for the cross-process session-binding channel.
- `_write_scope.py` — Support module: Stdlib-only authority for typed skill write scopes, their expansion and containment, and the session fold.
- `_write_scope.py` — Support module: Stdlib-only authority for typed skill write scopes, their expansion and containment, the session fold, and the `output_dir` boundary predicate.
- `session_lifetime_notice_hook.py` — Groups 2 registrations: Interactive lifetime notice on PostToolUse and Stop.
- `skill_load_post_hook.py` — Groups 2 registrations: PostToolUse Skill and UserPromptExpansion slash-command binding writer.
26 changes: 24 additions & 2 deletions src/autoskillit/hooks/_write_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
* ``INHERIT`` — the literal ``inherit``: the skill has no writes of its own.

This module owns the single decoder and encoder for that declaration, the one
expansion of both temp spellings, the temp-root containment check, and the
session fold that composes loaded skills' scopes by union. It is imported both as
expansion of both temp spellings, the temp-root containment check, the session
fold that composes loaded skills' scopes by union, and the ``output_dir`` boundary
predicate shared by ``run_skill`` admission and the recipe rule. It is imported both as
``_write_scope`` by hook subprocesses and as ``autoskillit.hooks._write_scope``
by in-venv callers.
"""
Expand Down Expand Up @@ -210,6 +211,25 @@ def bounded_scope_contains(scope: WriteScope, path: str, project_dir: str) -> bo
)


def output_dir_widens_scope(scope: WriteScope, output_dir: str, project_dir: str) -> bool:
"""Return whether a dispatch ``output_dir`` ("" if omitted) widens the skill's write scope."""
match scope.kind:
case WriteScopeKind.BOUNDED:
return bool(output_dir) and not bounded_scope_contains(scope, output_dir, project_dir)
case WriteScopeKind.UNRESTRICTED | WriteScopeKind.INHERIT:
return False
case _ as unreachable:
assert_never(unreachable)


def widened_output_dir_message(skill_name: str, scope: WriteScope, output_dir: str) -> str:
return (
f"output_dir {output_dir!r} is outside the declared write scope of skill {skill_name!r} "
f"(write_paths: {', '.join(scope.paths)}); omit output_dir to use the skill's own temp "
"directory, or pass a directory inside write_paths"
)


__all__ = [
"TEMP_PLACEHOLDER",
"WRITE_SCOPE_INHERIT",
Expand All @@ -224,6 +244,8 @@ def bounded_scope_contains(scope: WriteScope, path: str, project_dir: str) -> bo
"encode_write_scope",
"expand_write_path",
"fold_session_write_scopes",
"output_dir_widens_scope",
"temp_root",
"temp_root_escape",
"widened_output_dir_message",
]
2 changes: 2 additions & 0 deletions src/autoskillit/recipe/_skill_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
get_logger,
logger,
pkg_root,
skill_write_scope,
)

__all__ = [
Expand All @@ -46,4 +47,5 @@
"get_logger",
"logger",
"pkg_root",
"skill_write_scope",
]
3 changes: 3 additions & 0 deletions src/autoskillit/recipe/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
_resolve_skill_md,
bound_skill_name,
get_allowed_values_for_skill,
skill_write_scope,
)
from ._skill_placeholder_parser import (
_BANNED_CONTENT_SUFFIX_RE,
Expand Down Expand Up @@ -232,6 +233,7 @@ def __getattr__(name: str):
"_resolve_skill_md": "_skill_helpers",
"bound_skill_name": "_skill_helpers",
"get_allowed_values_for_skill": "_skill_helpers",
"skill_write_scope": "_skill_helpers",
"_BANNED_CONTENT_SUFFIX_RE": "_skill_placeholder_parser",
"_CONTENT_VAR_SIGNAL_RE": "_skill_placeholder_parser",
"_DYNAMIC_WRITE_VAR_RE": "_skill_placeholder_parser",
Expand Down Expand Up @@ -363,6 +365,7 @@ def __getattr__(name: str):
"pkg_root",
"push_reachable",
"shell_vars_assigned",
"skill_write_scope",
"substitute_scripts_placeholder",
"substitute_temp_placeholder",
]
23 changes: 23 additions & 0 deletions src/autoskillit/recipe/helpers/_skill_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

if TYPE_CHECKING:
from autoskillit.core import SkillResolver
from autoskillit.hooks._write_scope import WriteScope
from autoskillit.recipe.analysis._analysis import ValidationContext


Expand Down Expand Up @@ -92,6 +93,28 @@ def _resolve_skill_md(
return skill_path if isinstance(skill_path, Path) else None


def skill_write_scope(
ctx: ValidationContext, skill_name: str
) -> tuple[Path, str, WriteScope | None] | None:
"""Return a skill's SKILL.md path, text, and declared write scope (None if undeclared).

Returns None when the SKILL.md cannot be resolved or read.
"""
skill_md_path = _resolve_skill_md(
skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver
)
if skill_md_path is None:
return None
try:
content = skill_md_path.read_text(encoding="utf-8")
except OSError:
logger.debug("Could not read SKILL.md for %s at %s", skill_name, skill_md_path)
return None
from autoskillit.workspace import parse_frontmatter_content

return skill_md_path, content, parse_frontmatter_content(content).write_scope


def _has_dynamic_skill_name(skill_cmd: str) -> bool:
"""Return True if the skill name portion contains template expressions.

Expand Down
29 changes: 19 additions & 10 deletions src/autoskillit/recipe/rules/rules_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
get_logger,
pkg_root,
)
from autoskillit.hooks._write_scope import WriteScopeKind
from autoskillit.recipe._analysis import ValidationContext
from autoskillit.recipe._contracts_types import VALID_EXTERNAL_EFFECTS
from autoskillit.recipe._skill_helpers import bound_skill_name
from autoskillit.recipe._skill_helpers import bound_skill_name, skill_write_scope
from autoskillit.recipe.contracts import (
get_skill_contract,
load_bundled_manifest,
Expand Down Expand Up @@ -712,14 +713,14 @@ def _check_path_output_recovery_coverage(ctx: ValidationContext) -> list[RuleFin
@semantic_rule(
name="write-skill-requires-source-output-dir",
description=(
"run_skill steps with write_behavior in (always, conditional) and not read_only "
"must declare output_dir. Without it, the write guard defaults to a temp-dir "
"prefix that blocks source-file edits."
"run_skill steps whose skill declares write_paths: unrestricted and write_behavior in "
"(always, conditional) must declare output_dir; without it the write guard confines "
"the skill to its temp floor."
),
severity=Severity.ERROR,
)
def _check_write_skill_requires_source_output_dir(ctx: ValidationContext) -> list[RuleFinding]:
"""Fire when a write-behavior skill step lacks output_dir."""
"""Fire when an UNRESTRICTED write-behavior skill step lacks output_dir."""
try:
manifest = load_bundled_manifest()
except Exception:
Expand All @@ -742,16 +743,24 @@ def _check_write_skill_requires_source_output_dir(ctx: ValidationContext) -> lis
continue
if contract.write_behavior not in ("always", "conditional") or contract.read_only:
continue
if "output_dir" not in (step.with_args or {}):
resolved = skill_write_scope(ctx, name)
if resolved is None:
continue
_, _, scope = resolved
if (
scope is not None
and scope.kind is WriteScopeKind.UNRESTRICTED
and not (step.with_args or {}).get("output_dir")
):
findings.append(
make_finding(
rule_name="write-skill-requires-source-output-dir",
step_name=step_name,
message=(
f"Step '{step_name}' invokes skill '{name}' "
f"(write_behavior={contract.write_behavior!r}) but declares "
f"no output_dir. The write guard defaults to a temp-dir "
f"prefix that blocks source-file edits. Add "
f"Step '{step_name}' invokes skill '{name}' (write_paths: unrestricted, "
f"write_behavior={contract.write_behavior!r}) but declares no "
f"output_dir. The write guard confines the skill to its temp floor, "
f"which blocks source-file edits. Add "
f"output_dir: ${{{{ context.work_dir }}}} "
f"(or equivalent) to this step."
),
Expand Down
Loading
Loading