diff --git a/docs/src/content/docs/producer/repo-shapes.md b/docs/src/content/docs/producer/repo-shapes.md index 2763aded8..88968be82 100644 --- a/docs/src/content/docs/producer/repo-shapes.md +++ b/docs/src/content/docs/producer/repo-shapes.md @@ -217,6 +217,10 @@ Claude Code skills target. Authoring rules: `executables.deny` policy (the legacy `bin_deploy` rule remains a deprecated alias) -- see the [policy schema](../../reference/policy-schema/#executables). +- Consumers can pass `--trust-bin` on `apm install` to explicitly + consent (suppresses the trust-posture warning) or `--no-trust-bin` + to skip bin/ deployment for a single invocation. Without either + flag, bin/ deploys with a prominent warning. ## What to read next diff --git a/docs/src/content/docs/reference/policy-schema.md b/docs/src/content/docs/reference/policy-schema.md index 2b2780c29..b070021f0 100644 --- a/docs/src/content/docs/reference/policy-schema.md +++ b/docs/src/content/docs/reference/policy-schema.md @@ -454,7 +454,7 @@ deny). This realizes Claude Code's "skills-directory plugin" contract: a folder under a skills directory that contains `.claude-plugin/plugin.json` loads as `@skills-dir`, and its root `bin/` is added to the Bash tool's `PATH`. The package's `.claude-plugin/plugin.json` is required for Claude to load the folder as a plugin; APM copies it alongside `bin/` when the package ships one. The contract is Claude-specific, so deployment only targets Claude. Restart Claude Code (or run `/reload-plugins`) after install for new executables to be picked up. -**Security note:** deployed executables are tightened to `0o700` on POSIX systems and placed on Claude Code's `PATH`, so Claude can invoke them without further confirmation. By default, APM mirrors npm's trust model: installing a package implies trusting its declared artifacts, including executables. Use this field to opt out globally or per-package in enterprise environments. +**Security note:** deployed executables are tightened to `0o700` on POSIX systems and placed on Claude Code's `PATH`, so Claude can invoke them without further confirmation. By default, APM mirrors npm's trust model: installing a package implies trusting its declared artifacts, including executables. Use this field to opt out globally or per-package in enterprise environments. Additionally, the `--trust-bin` / `--no-trust-bin` flags on `apm install` provide per-invocation consent: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ deployment even if policy allows it, and the default (no flag) deploys with a warning. **Scope:** bin/ deployment only activates for global (`-g`, user-scope) installs. Project-scope installs do not deploy executables. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 5fefe2ad2..4da292599 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -10,7 +10,7 @@ | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) | +| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) | | `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). | | `apm uninstall PKGS...` | Remove packages (accepts `owner/repo` or `name@marketplace`) | `--dry-run`, `-g` global | | `apm prune` | Remove orphaned packages | `--dry-run` | diff --git a/packages/apm-guide/.apm/skills/apm-usage/governance.md b/packages/apm-guide/.apm/skills/apm-usage/governance.md index d3d895770..04927502c 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/governance.md +++ b/packages/apm-guide/.apm/skills/apm-usage/governance.md @@ -254,6 +254,18 @@ Deployed executables are placed on Claude Code's `PATH` and invoked without further confirmation, so use this field to opt out in environments where plugin executables are not trusted by default. +### CLI consent flag + +In addition to policy-level controls, the `--trust-bin` / `--no-trust-bin` +flags on `apm install` give per-invocation consent: + +- `--trust-bin` -- explicitly consent to bin/ deployment (suppresses the + trust-posture warning). +- `--no-trust-bin` -- explicitly deny bin/ deployment for this invocation, + even if the project policy allows it. +- Default (neither flag) -- deploy bin/ but emit a prominent warning + advising the user to pass `--trust-bin` for explicit consent. + ## Canvas extension trust (experimental) Behind the `canvas` experimental flag, a package may ship a Copilot CLI canvas diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 09f3cc95e..f296da915 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -225,6 +225,7 @@ class InstallContext: skill_subset: "builtins.tuple[str, ...] | None" = None skill_subset_from_cli: bool = False audit_override: str | None = None + trust_bin: bool | None = None # --------------------------------------------------------------------------- @@ -1106,6 +1107,18 @@ def _handle_mcp_install( "local-bundle installs; passing --as without a local bundle path is rejected." ), ) +@click.option( + "--trust-bin/--no-trust-bin", + "trust_bin", + default=None, + help=( + "Consent posture for bin/ executable deployment. " + "--trust-bin explicitly approves bin/ deployment (suppresses the " + "trust-posture warning). --no-trust-bin explicitly skips bin/ " + "deployment even when policy allows it. Default (neither flag): " + "deploys bin/ but emits a prominent trust-posture warning." + ), +) @click.option( "--root", "root", @@ -1156,6 +1169,7 @@ def install( # noqa: PLR0913 refresh, legacy_skill_paths, alias, + trust_bin, root, ): """Install APM and MCP dependencies from apm.yml (like npm install). @@ -1561,6 +1575,7 @@ def install( # noqa: PLR0913 plan_callback=None, skill_subset=_skill_subset, skill_subset_from_cli=bool(skill_names), + trust_bin=trust_bin, ) apm_count, mcp_count, lsp_count, apm_diagnostics = _install_apm_packages( diff --git a/src/apm_cli/install/exec_gate.py b/src/apm_cli/install/exec_gate.py index cc3888490..070ed37fb 100644 --- a/src/apm_cli/install/exec_gate.py +++ b/src/apm_cli/install/exec_gate.py @@ -165,3 +165,5 @@ def log_bin_status( f" |-- bin/ executables skipped (not approved in allowExecutables). " f"Run 'apm approve {_pkg_label}' to approve." ) + elif skill_result.bin_skipped_reason == "not_trusted": + log_fn(" |-- bin/ executables skipped (--no-trust-bin). Pass --trust-bin to deploy.") diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index e7438152d..8c4383708 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -186,6 +186,22 @@ def _log_canvas_skip(package_name: str, package_info: Any, logger: InstallLogger ) +def _resolve_bin_skip( + bin_approved: bool, + trust_bin: bool | None, +) -> tuple[bool, str | None]: + """Combine the ``allowExecutables`` gate with the ``--trust-bin`` flag. + + Returns ``(skip_bin, bin_skip_reason_override)`` for + ``integrate_package_skill``. + """ + if not bin_approved: + return True, "not_approved" + if trust_bin is False: + return True, "not_trusted" + return False, None + + def integrate_package_primitives( # noqa: PLR0913 package_info: Any, project_root: Path, @@ -205,6 +221,7 @@ def integrate_package_primitives( # noqa: PLR0913 is_first_party: bool = False, allow_executables: builtins.dict[str, builtins.dict[str, bool]] | None = None, dep_target_subset: list[str] | None = None, + trust_bin: bool | None = None, ) -> dict: """Run the full integration pipeline for a single package. @@ -225,6 +242,11 @@ def integrate_package_primitives( # noqa: PLR0913 whose key appears in the dict with the matching type set to ``True``. Local project content (``package_name == "_local"``) is always trusted. + When *trust_bin* is ``False`` (``--no-trust-bin``), bin/ deployment + is skipped with reason ``"not_trusted"``. When ``True`` + (``--trust-bin``), the trust-posture warning is suppressed. When + ``None`` (default), bin/ deploys but a prominent warning is emitted. + Returns a dict with integration counters and the list of deployed file paths. """ from apm_cli.integration.dispatch import get_dispatch_table @@ -522,6 +544,10 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ ) _emit_integration_hints(_prim_name, _info, _log_integration) + # Determine effective bin/ skip and reason. The ``allowExecutables`` + # gate and ``--trust-bin`` / ``--no-trust-bin`` are independent gates. + _skip_bin, _bin_skip_reason_override = _resolve_bin_skip(_bin_approved, trust_bin) + skill_result = integrators.skill.integrate_package_skill( package_info, project_root, @@ -532,7 +558,9 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ skill_subset=skill_subset, scope=scope, policy=policy, - skip_bin=not _bin_approved, + skip_bin=_skip_bin, + bin_skip_reason_override=_bin_skip_reason_override, + trust_bin=trust_bin, ) _skill_target_dirs: set = builtins.set() for tp in skill_result.target_paths: diff --git a/src/apm_cli/install/template.py b/src/apm_cli/install/template.py index e6641b77a..6bec50a57 100644 --- a/src/apm_cli/install/template.py +++ b/src/apm_cli/install/template.py @@ -172,6 +172,7 @@ def _integrate_materialization( dep_target_subset=dep_ref.target_subset, ctx=ctx, allow_executables=_effective_allow(ctx), + trust_bin=getattr(ctx, "trust_bin", None), ) mutation_keys = ( "prompts", diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index d327815b8..bf38635d8 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -1237,6 +1237,8 @@ def integrate_package_skill( scope=None, policy=None, skip_bin: bool = False, + bin_skip_reason_override: str | None = None, + trust_bin: bool | None = None, ) -> SkillIntegrationResult: """Integrate a package's skill into all active target directories. @@ -1259,6 +1261,15 @@ def integrate_package_skill( package ships one. Used by the executable approval gate to block unapproved bin/ executables while still deploying text primitives (skills, sub-skills). + bin_skip_reason_override: When *skip_bin* is True, use this + string as ``bin_skipped_reason`` instead of the default + ``"not_approved"``. Lets the caller distinguish + ``--no-trust-bin`` (``"not_trusted"``) from the + ``allowExecutables`` gate. + trust_bin: Tri-state consent flag. ``True`` suppresses the + trust-posture warning; ``False`` is handled upstream by + setting *skip_bin*; ``None`` (default) emits the warning + when bin/ is deployed. Returns: SkillIntegrationResult: Results of the integration operation @@ -1318,7 +1329,7 @@ def integrate_package_skill( if package_info.package_type == _PackageType.MARKETPLACE_PLUGIN: if skip_bin: - bin_skip_reason = "not_approved" + bin_skip_reason = bin_skip_reason_override or "not_approved" else: bin_paths, bin_skip_reason = self._deploy_plugin_bin( package_info, @@ -1328,6 +1339,7 @@ def integrate_package_skill( policy=policy, force=force, logger=logger, + trust_bin=trust_bin, ) # Check if this is a native Skill (already has SKILL.md at root) @@ -1443,6 +1455,7 @@ def _deploy_plugin_bin( policy=None, force: bool = False, logger=None, + trust_bin: bool | None = None, ) -> tuple[list[Path], str | None]: """Deploy bin/ executables and plugin manifest for a MARKETPLACE_PLUGIN. @@ -1463,6 +1476,13 @@ def _deploy_plugin_bin( systems. The deployed root is user-scoped (~/.claude/skills/), so tighter-than-0o755 permissions are correct. + When *trust_bin* is ``None`` (no ``--trust-bin`` / ``--no-trust-bin`` + flag), a prominent trust-posture warning is emitted after deployment + so the user is aware that executables are on Claude Code's PATH. + When *trust_bin* is ``True`` (``--trust-bin``), the warning is + suppressed. ``False`` is never passed here (handled upstream by + the caller setting ``skip_bin=True``). + Returns ``(deployed_paths, skip_reason)``. ``skip_reason`` is non-None ONLY when the package ships a bin/ but it could not be deployed for an actionable reason ("project_scope", "no_claude_target"), so the install @@ -1524,6 +1544,18 @@ def _deploy_plugin_bin( if manifest is not None: deployed.append(manifest) + # Trust-posture warning: when bin/ is deployed without explicit + # --trust-bin consent, surface a prominent warning so the user + # knows executables are on Claude Code's PATH. + if deployed and trust_bin is None and logger: + canonical = package_info.get_canonical_dependency_string() + logger.progress( + f"[!] Plugin '{canonical}' deploys executables to Claude Code's PATH " + "(invoked without confirmation). " + "Pass --trust-bin to acknowledge, or --no-trust-bin to skip bin/ deployment.", + symbol="warning", + ) + return deployed, None @staticmethod diff --git a/tests/integration/test_plugin_bin_trust_posture.py b/tests/integration/test_plugin_bin_trust_posture.py new file mode 100644 index 000000000..2516b9e58 --- /dev/null +++ b/tests/integration/test_plugin_bin_trust_posture.py @@ -0,0 +1,167 @@ +"""Integration coverage for marketplace plugin bin/ trust posture handling.""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from apm_cli.core.scope import InstallScope +from apm_cli.install.exec_gate import log_bin_status +from apm_cli.install.services import _resolve_bin_skip +from apm_cli.integration.skill_integrator import SkillIntegrator +from apm_cli.models.apm_package import PackageInfo, PackageType, validate_apm_package + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="POSIX mode hardening only") + + +class _RecordingLogger: + """Capture ``progress()`` messages for trust-posture assertions.""" + + def __init__(self) -> None: + self.progress_messages: list[str] = [] + + def progress(self, message: str, **_kwargs: Any) -> None: + """Record a progress line.""" + self.progress_messages.append(message) + + +def _write_marketplace_plugin(package_dir: Path, *, manifest_name: str) -> tuple[PackageInfo, Path]: + """Create and validate a real marketplace plugin fixture with one bin file.""" + package_dir.mkdir(parents=True) + (package_dir / "plugin.json").write_text( + f'{{"name": "{manifest_name}", "version": "1.0.0"}}', + encoding="utf-8", + ) + plugin_manifest_dir = package_dir / ".claude-plugin" + plugin_manifest_dir.mkdir() + (plugin_manifest_dir / "plugin.json").write_text( + f'{{"name": "{manifest_name}"}}', + encoding="utf-8", + ) + bin_dir = package_dir / "bin" + bin_dir.mkdir() + source_bin = bin_dir / "tool" + source_bin.write_text("#!/bin/sh\necho plugin\n", encoding="utf-8") + + validation = validate_apm_package(package_dir) + assert not validation.errors + assert validation.package is not None + assert validation.package_type is PackageType.MARKETPLACE_PLUGIN + return ( + PackageInfo( + package=validation.package, + install_path=package_dir, + package_type=validation.package_type, + ), + source_bin, + ) + + +def test_trust_bin_true_deploys_without_warning(tmp_path: Path) -> None: + """Explicit trust should deploy bin/ without the consent warning.""" + project_root = tmp_path / "home" + project_root.mkdir() + (project_root / ".claude").mkdir() + package_info, _source_bin = _write_marketplace_plugin( + tmp_path / "packages" / "trusted-tool", + manifest_name="MyOwner/TrustedTool", + ) + logger = _RecordingLogger() + + result = SkillIntegrator().integrate_package_skill( + package_info, + project_root, + scope=InstallScope.USER, + logger=logger, + trust_bin=True, + ) + + deployed_bin = project_root / ".claude" / "skills" / "trusted-tool" / "bin" / "tool" + assert result.bin_deployed > 0 + assert deployed_bin.is_file() + assert not any( + "invoked without confirmation" in message for message in logger.progress_messages + ) + + +def test_trust_bin_false_skips_with_not_trusted_reason(tmp_path: Path) -> None: + """Explicit no-trust should skip bin/ deployment with the trust-specific reason.""" + project_root = tmp_path / "home" + project_root.mkdir() + (project_root / ".claude").mkdir() + package_info, _source_bin = _write_marketplace_plugin( + tmp_path / "packages" / "distrusted-tool", + manifest_name="MyOwner/DistrustedTool", + ) + + result = SkillIntegrator().integrate_package_skill( + package_info, + project_root, + scope=InstallScope.USER, + skip_bin=True, + bin_skip_reason_override="not_trusted", + ) + + deployed_bin = project_root / ".claude" / "skills" / "distrusted-tool" / "bin" / "tool" + assert result.bin_deployed == 0 + assert result.bin_skipped_reason == "not_trusted" + assert not deployed_bin.exists() + + +def test_trust_bin_none_deploys_with_warning(tmp_path: Path) -> None: + """Default trust posture should deploy bin/ and emit the acknowledgement warning.""" + project_root = tmp_path / "home" + project_root.mkdir() + (project_root / ".claude").mkdir() + package_info, _source_bin = _write_marketplace_plugin( + tmp_path / "packages" / "prompted-tool", + manifest_name="MyOwner/PromptedTool", + ) + logger = _RecordingLogger() + + result = SkillIntegrator().integrate_package_skill( + package_info, + project_root, + scope=InstallScope.USER, + logger=logger, + trust_bin=None, + ) + + deployed_bin = project_root / ".claude" / "skills" / "prompted-tool" / "bin" / "tool" + assert result.bin_deployed > 0 + assert deployed_bin.is_file() + assert any("invoked without confirmation" in message for message in logger.progress_messages) + + +def test_log_bin_status_not_trusted() -> None: + """Status logging should point users at the trust flags when bin/ is skipped.""" + skill_result = SimpleNamespace(bin_deployed=0, bin_skipped_reason="not_trusted") + lines: list[str] = [] + + log_bin_status(skill_result, "", "pkg", SimpleNamespace(name="pkg"), lines.append) + + assert lines == [" |-- bin/ executables skipped (--no-trust-bin). Pass --trust-bin to deploy."] + + +@pytest.mark.parametrize( + ("bin_approved", "trust_bin", "expected"), + [ + (True, None, (False, None)), + (True, True, (False, None)), + (True, False, (True, "not_trusted")), + (False, None, (True, "not_approved")), + (False, True, (True, "not_approved")), + (False, False, (True, "not_approved")), + ], +) +def test_resolve_bin_skip( + bin_approved: bool, + trust_bin: bool | None, + expected: tuple[bool, str | None], +) -> None: + """allowExecutables approval should override trust posture when bin/ is blocked.""" + assert _resolve_bin_skip(bin_approved, trust_bin) == expected