diff --git a/CHANGELOG.md b/CHANGELOG.md index 6661b68810..ad75d487f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -494,6 +494,13 @@ This release closes that milestone with **301 merged pull requests**. ### Fixed +- **sandbox:** With host bash enabled, the local sandbox no longer keeps a + thread on the skill view of the last restricted Agent that ran there. That + view is only maintained while host bash is off, but `LocalSandboxProvider` + mounted it whenever it existed, so later unrestricted runs on the thread kept + the old allowlist and could not read other enabled or newly added skills + under `/mnt/skills`. Those runs now use the shared skill views; the thread + view is kept and enforced again once host bash is turned off. ([#6344]) - **sandbox:** The local sandbox no longer rewrites line endings. `read_file` translated CRLF to LF, so `str_replace` on a CRLF file wrote every line back as LF, and the read-before-write gate could not see a change that only touched @@ -7839,3 +7846,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6328]: https://github.com/bytedance/deer-flow/pull/6328 [#6332]: https://github.com/bytedance/deer-flow/pull/6332 [#6343]: https://github.com/bytedance/deer-flow/pull/6343 +[#6344]: https://github.com/bytedance/deer-flow/pull/6344 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index d5c4e3cd8b..d429995763 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -432,6 +432,10 @@ ### 修复 +- **沙箱:** 启用 host bash 时,本地沙箱不再把线程固定在上一个受限 Agent 的技能视图上。 + 该视图只在 host bash 关闭时维护,但 `LocalSandboxProvider` 只要它存在就会挂载,导致该线程之后 + 不受限的运行仍沿用旧的 allowlist,读不到 `/mnt/skills` 下其他已启用或新增的技能。现在这些运行 + 使用共享技能视图;线程视图会保留,并在关闭 host bash 后重新生效。([#6344]) - **沙箱:** 本地沙箱不再改写换行符。`read_file` 此前把 CRLF 转成 LF,导致 `str_replace` 编辑 CRLF 文件后整份文件都变成 LF,且读后写校验无法察觉仅改动换行符的 变更;在 Windows 上 `write_file` 会把 LF 内容写成 CRLF,使 `bash run.sh` 等脚本失败。 @@ -6517,3 +6521,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6326]: https://github.com/bytedance/deer-flow/pull/6326 [#6332]: https://github.com/bytedance/deer-flow/pull/6332 [#6343]: https://github.com/bytedance/deer-flow/pull/6343 +[#6344]: https://github.com/bytedance/deer-flow/pull/6344 diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index b2dc7335b2..340c092bb7 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -16,7 +16,7 @@ cancellation before propagating it. **Authorization gate** (`sandbox:execute`, RFC #4063 Phase 3): every sandbox-backed tool call passes through the gate in `deerflow/authz/sandbox_authz.py` - a binary `authorize(principal, "sandbox", "execute", target="*")` check before either reusing a persisted sandbox id or calling `provider.acquire`. Rechecking reuse is required because authorization config and user roles can change while the sandbox remains cached. Sync tool invocations call `authorize_sandbox_execution`; async tool invocations await `authorize_sandbox_execution_async` exactly once. A task-local `ContextVar` scopes that single decision across the complete composed tool invocation, including `ReadBeforeWriteMiddleware`'s pre-write inspection, tool body, and post-read mark; the value is copied into `asyncio.to_thread` workers. Authorization denial is converted to the normal error `ToolMessage` at the composed middleware boundary and is explicitly excluded from the gate's generic fail-open handlers. Async config loading and provider class discovery/import are offloaded before `aauthorize()` so reused sandbox calls do not hash config files or import custom modules on the event loop; provider construction remains on the running event loop because async providers may initialize loop-affine clients. The gate lives at the single tool initialization entry point (`ensure_sandbox_initialized` / `ensure_sandbox_initialized_async` in `tools.py`), while `SandboxMiddleware.before_agent` / `abefore_agent` apply the matching sync/async check to eager acquisition. Deny raises `SandboxAuthorizationError` (`sandbox/exceptions.py`), which propagates out of ordinary tool execution as a friendly error `ToolMessage` ("sandbox execution is not permitted for your role") - the eager path catches it and skips acquisition instead, deferring the deny to the first sandbox-touching tool call so both paths share the same semantics. Provider errors (authorization calls and provider resolution) follow `authorization.fail_closed` / `fail_open`; no readable `config.yaml` or `authorization.enabled: false` makes the gate a no-op (`safe_app_config` tolerates missing config). Gateway upload/artifact sync calls `try_acquire_sandbox_for_request` (`app/gateway/authz.py`), which gates, returns a request lease, and skips sync on deny while preserving the primary operation. Callers release after their last sandbox operation; artifacts request normal parking, uploads do not. Tests: `tests/test_sandbox_authorization.py` and `tests/blocking_io/test_sandbox_authorization.py`. **Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved. **Implementations**: -- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-user/thread `LocalSandbox` (id `local:{user_id}:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Shared runs use category mappings; a policy-scoped run replaces them with one `/mnt/skills` root mapping to the coherent thread view, so structured file tools resolve through one managed boundary. This is not a host filesystem security boundary: an enabled host `bash` subprocess can use canonical paths without `PathMapping`, so `supports_agent_skill_isolation` is dynamic and explicit Agent policies fail closed while host bash is enabled. Host-to-virtual output masking scans dynamic per-user/per-thread roots directly instead of compiling path-specific regexes, so evicted thread IDs do not remain in Python's global regex caches; a separate 256-entry root cache prevents repeated `realpath()` walks for every glob/grep match while bounding dynamic-path retention, and only the small process-stable skill/integration source set uses a bounded compiled cache. The shared `path_patterns.py` tail stops at `:`, so `$PATH`-style lists mask every entry; a mount symlink resolving outside all mounts keeps its mount path. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths. +- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-user/thread `LocalSandbox` (id `local:{user_id}:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Shared runs use category mappings; a policy-scoped run replaces them with one `/mnt/skills` root mapping to the coherent thread view, so structured file tools resolve through one managed boundary. This is not a host filesystem security boundary: an enabled host `bash` subprocess can use canonical paths without `PathMapping`, so `supports_agent_skill_isolation` is dynamic and explicit Agent policies fail closed while host bash is enabled; unrestricted runs then get the shared mappings, never a leftover thread view. Host-to-virtual output masking scans dynamic per-user/per-thread roots directly instead of compiling path-specific regexes, so evicted thread IDs do not remain in Python's global regex caches; a separate 256-entry root cache prevents repeated `realpath()` walks for every glob/grep match while bounding dynamic-path retention, and only the small process-stable skill/integration source set uses a bounded compiled cache. The shared `path_patterns.py` tail stops at `:`, so `$PATH`-style lists mask every entry; a mount symlink resolving outside all mounts keeps its mount path. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths. PowerShell fallback pairs guarded console UTF-8 setters with explicit UTF-8 pipe decoding; cmd/MSYS retain locale decoding. Encoding regressions include real-pipe probes and Windows-only PowerShell roundtrips with and without a console. - `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker isolation. Reuse enforces minimum persisted shell capacity even without image-env overrides. Upgrades require ownership-fenced teardown; provisioner create returns 409 for undersized Pods without deleting them. Acquire/reuse drops definitively dead active/warm entries before rediscovery/creation. Health-check failures mean unknown, so local discovery skips unverifiable containers. Acquire/reclaim publishes ownership; `_renew_owned_leases` refreshes it. Text appends use native AIO append, never read-modify-write. `reset()` closes acquire pools; `shutdown()` performs full teardown. Async acquire separates lock waiters from holder work; started cache/warm workers drain, queued work/skills projection stay cancellable. Create polls flock; owned work/readiness/registration/cleanup drain before unlock. `uses_thread_data_mounts` defaults to backend detection (`LocalContainerBackend=True`, remote/provisioner=False); `sandbox.thread_data_mounts` overrides this for deployments sharing thread user-data directories. `true` skips upload acquire/sync; a false positive makes uploads unavailable. Explicit Agent policies use four thread projection category mounts and a distinct deterministic identity, excluding older shared-mount containers. The startup snapshot of `skills.container_path` drives mounts, identity, remote requests, and provisioner validation; root changes prevent container/Pod reuse. Gateway and provisioner independently require a canonical absolute root outside reserved platform mounts and derive four category allowlist entries from it. All four category overrides suppress the provisioner's default hostPath/skills-PVC mount; with `USERDATA_PVC_NAME`, categories use subpaths on that shared PVC. Readiness probes and `agent_sandbox` clients set `trust_env=False` for direct control-plane destinations (loopback/private IPs, single-label cluster hosts, Docker/Podman internal hostnames); external FQDNs and public IPs retain environment proxies. - `E2BSandboxProvider` (`packages/harness/deerflow/community/e2b_sandbox/`) provides E2B remote isolation. diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py index bed20181a2..38d389505b 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py @@ -466,9 +466,15 @@ class LocalSandboxProvider(SandboxProvider): # triggers a full rebuild (~400 ms measured locally) under the # cross-process projection lock, serializing concurrent acquires and # mutations for that user. Acceptable for an editing-frequency event. + # + # A thread view exists only to enforce an Agent skill allowlist, and + # SandboxMiddleware keeps it fresh only while this provider can enforce + # one. With host bash allowed it skips the view (restricted runs are + # rejected), so mounting a leftover view would pin the last restricted + # Agent's allowlist; use the shared projection instead. skill_projection = self._ensure_skills_projection( effective_user_id, - thread_id=thread_id, + thread_id=thread_id if self.supports_agent_skill_isolation else None, ) key = self._thread_key(thread_id, effective_user_id) diff --git a/backend/packages/harness/deerflow/skills/AGENTS.md b/backend/packages/harness/deerflow/skills/AGENTS.md index 34769c7a31..ea2244f060 100644 --- a/backend/packages/harness/deerflow/skills/AGENTS.md +++ b/backend/packages/harness/deerflow/skills/AGENTS.md @@ -8,7 +8,7 @@ - **Loading**: `load_skills()` recursively scans public, per-user custom, global integration, and legacy custom locations for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json plus per-user skill state for non-public categories; that directory is a package boundary, so no nested `SKILL.md` is registered as a runtime skill. A custom skill directory may be a one-level symlink to an external directory for compatibility with operator-managed skill trees; activation still rejects a symlinked `SKILL.md` or deeper path escape. Deleting such a skill unlinks the entry and leaves the external tree untouched. SkillScan has a deliberately narrower packaging rule: known eval fixtures are permitted as support data, while other nested `SKILL.md` files are reported as package defects. Ancestor-link cycles are pruned; independent aliases remain discoverable. - **External reload**: `POST /api/skills/reload` is an admin-only, process-local invalidation hook for trusted MinIO/NFS/CSI writes. `SkillStorage` instances do not cache a catalog — `load_skills()` scans on every call — so the route clears all `(app_config, user_id)` entries and the rendered prompt-section LRU, then waits up to the shared refresh timeout for the existing off-loop single-flight refresh. Each invalidation receives a generation-bound result handle; a successful scan atomically replaces the global enabled-skills cache, while a loader-level failure propagates to the HTTP waiter and preserves the last-known-good global cache. Per-user/config scans capture the refresh version and cannot repopulate shared caches if invalidation occurs while they are loading. A timed-out HTTP wait fails generically while the daemon refresh worker continues. Subsequent runs rescan after a successful reload; active runs keep their existing snapshot. Each Uvicorn worker/Kubernetes Pod must be targeted separately. Direct mount writes bypass install/edit validation, SkillScan, and history, so mounted roots are an operator-controlled trust boundary. - **Tool policy**: Agent `allowed-tools` declarations apply dynamically only to slash-activated skills and skills captured in `ThreadState.skill_context` through configured `read_file` loads; passive enabled skills and skill allowlists do not clamp the baseline tool set. A lead custom Agent's explicit `skills` list is additionally enforced at the sandbox filesystem layer (see Sandbox projection); subagent skill lists still scope discovery and activation only because concurrently delegated subagents share the lead thread sandbox. Subagents render only skill discovery metadata at startup and reuse the same adjacent `SkillActivationMiddleware` + `SkillToolPolicyMiddleware` pair as the lead; their configured `skills` field limits discovery and activation instead of eagerly loading bodies or unioning policies. Slash policy is dominant for its run, preventing subsequently read skills from widening explicit authority; autonomous captured skills use the existing union only when no slash source exists. `tool_search` and `describe_skill` stay available as framework discovery infrastructure, while every discovered or promoted business tool still requires active-policy permission for schema visibility and execution; `task`, `list_background_tasks`, and `cancel_background_task` likewise require explicit declarations. Each active model call intentionally reloads the full live registry so enable/disable changes, frontmatter edits, and custom/public name-shadow winners take effect without a stale TTL or unsafe direct-path cache; all tool calls produced by that model step reuse the resulting source-and-path-signed decision. Registry failures and all-invalid active sets fail closed, while stale individual paths are skipped when another valid skill remains. The dynamic `allowed-tools` policy remains best-effort behavioral scoping: alternate loading paths are not captured and bounded autonomous context may evict entries. -- **Sandbox projection**: `skills/projection.py` materializes enabled-only shared trees at `{base_dir}/skills_view/public` and `{base_dir}/users/{user_id}/skills_view/{custom,legacy,integrations}`. A lead Agent with an explicit `skills` allowlist (including `[]`) gets the intersection of enabled public/user-visible skills and that allowlist at `{base_dir}/users/{user_id}/threads/{thread_id}/skills_view/{public,custom,legacy,integrations}`. `skills=None` keeps the shared zero-copy mount until a thread has used an explicit policy; later unrestricted runs repopulate the same stable thread root with all enabled skills. Rebuilds sign source state, view state, and normalized policy in a manifest, revoke every old category before adding the new policy, stage copies in temporary directories, and atomically replace files. Category root inodes stay stable for live bind mounts; concurrent readers can briefly see fewer skills during a policy change, never a skill revoked by the new policy. Policy-scoped copies reject absolute symlinks and relative symlinks that resolve outside their own skill package, preventing a permitted package from linking back to an omitted source. It copies files into the view (`_copy_into_view`) so a sandbox write cannot mutate the canonical skill inode; the operational trade-off is an O(total bytes) I/O and per-user/thread storage multiplier across rebuilds, prioritized for write isolation over zero-copy hardlinks. Steady-state freshness checks combine source and view metadata tree digests, so in-sandbox view tampering is detected and repaired on the next acquire. Storage writes, archive installs, deletes, and toggles rebuild shared scopes under a cross-process lock; Gateway boot ensures only the shared public view, user views are repaired lazily on acquire, and Agent thread views are recomputed before sandbox reuse. Managed integration packages are global, but their projected category is per-user because enabled state is isolated. User projection rebuilds re-read global enable state from disk instead of the process singleton, so a toggle handled by another Gateway worker is reflected on the next acquire. Gateway public-skill toggles take the public projection lock before the shared `extensions_config_write_lock`, re-read an existing config raw from disk, change only that skill's entry, and rebuild before responding; keep this as one worker-owned critical section so MCP writes cannot interleave and request cancellation cannot release either lock while the worker still runs. The shared public steady-state signature check runs without the global projection lock; stale/error paths take the lock and re-check before rebuilding or clearing. User and thread scope checks are serialized per scope. Projection failures clear the affected view before raising. +- **Sandbox projection**: `skills/projection.py` materializes enabled-only shared trees at `{base_dir}/skills_view/public` and `{base_dir}/users/{user_id}/skills_view/{custom,legacy,integrations}`. A lead Agent with an explicit `skills` allowlist (including `[]`) gets the intersection of enabled public/user-visible skills and that allowlist at `{base_dir}/users/{user_id}/threads/{thread_id}/skills_view/{public,custom,legacy,integrations}`. `skills=None` keeps the shared zero-copy mount until a thread has used an explicit policy; later unrestricted runs repopulate the same stable thread root with all enabled skills (with host bash enabled, `LocalSandboxProvider` mounts the shared views instead). Rebuilds sign source state, view state, and normalized policy in a manifest, revoke every old category before adding the new policy, stage copies in temporary directories, and atomically replace files. Category root inodes stay stable for live bind mounts; concurrent readers can briefly see fewer skills during a policy change, never a skill revoked by the new policy. Policy-scoped copies reject absolute symlinks and relative symlinks that resolve outside their own skill package, preventing a permitted package from linking back to an omitted source. It copies files into the view (`_copy_into_view`) so a sandbox write cannot mutate the canonical skill inode; the operational trade-off is an O(total bytes) I/O and per-user/thread storage multiplier across rebuilds, prioritized for write isolation over zero-copy hardlinks. Steady-state freshness checks combine source and view metadata tree digests, so in-sandbox view tampering is detected and repaired on the next acquire. Storage writes, archive installs, deletes, and toggles rebuild shared scopes under a cross-process lock; Gateway boot ensures only the shared public view, user views are repaired lazily on acquire, and Agent thread views are recomputed before sandbox reuse. Managed integration packages are global, but their projected category is per-user because enabled state is isolated. User projection rebuilds re-read global enable state from disk instead of the process singleton, so a toggle handled by another Gateway worker is reflected on the next acquire. Gateway public-skill toggles take the public projection lock before the shared `extensions_config_write_lock`, re-read an existing config raw from disk, change only that skill's entry, and rebuild before responding; keep this as one worker-owned critical section so MCP writes cannot interleave and request cancellation cannot release either lock while the worker still runs. The shared public steady-state signature check runs without the global projection lock; stale/error paths take the lock and re-check before rebuilding or clearing. User and thread scope checks are serialized per scope. Projection failures clear the affected view before raising. - **Injection (legacy / default)**: Enabled skills are listed in the agent system prompt with full metadata and container paths (`` block). Controlled by `skills.deferred_discovery: false` (default). - **Deferred discovery** (`skills.deferred_discovery: true`): Skills are listed by name only in a compact `` block, keeping the system prompt prefix-cache friendly. The agent calls the `describe_skill` tool at runtime to fetch full metadata for skills it wants to use, then loads the SKILL.md via `read_file`. Two new modules support this path: - `skills/catalog.py` — immutable `SkillCatalog`; query forms: `select:a,b`, `+prefix`, free-text intent terms. Ranked queries use up to 256 characters / 16 unique literal terms, preferring name matches over description-only matches at equal coverage; ties keep catalog order. A lazy per-catalog index caches normalized names/descriptions. Unlike tool search, ranking uses literal intent terms, not regexes. `select:` is parsed before search limits and returns all exact catalog matches without query-length or result caps; other modes cap at `MAX_RESULTS=5`. diff --git a/backend/tests/test_three_way_skills_mount_e2e.py b/backend/tests/test_three_way_skills_mount_e2e.py index 8c32fe4e25..19f87d721f 100644 --- a/backend/tests/test_three_way_skills_mount_e2e.py +++ b/backend/tests/test_three_way_skills_mount_e2e.py @@ -32,6 +32,7 @@ from deerflow.skills.types import SKILL_MD_FILE, Skill, SkillCategory _AIO_MODULE = "deerflow.community.aio_sandbox.aio_sandbox_provider" _AIO_GET_CONFIG = f"{_AIO_MODULE}.get_app_config" +_LOCAL_HOST_BASH_ALLOWED = "deerflow.sandbox.local.local_sandbox_provider.is_host_bash_allowed" def _write_skill(base: Path, name: str, description: str = "test skill") -> Path: @@ -402,6 +403,8 @@ class TestThreeWayMountEndToEnd: "deerflow.config.extensions_config.get_extensions_config", return_value=extensions, ), + # The provider enforces a thread view only while host bash is off. + patch(_LOCAL_HOST_BASH_ALLOWED, return_value=False), ): storage = UserScopedSkillStorage( "user-1", @@ -476,6 +479,8 @@ class TestThreeWayMountEndToEnd: "deerflow.config.extensions_config.get_extensions_config", return_value=extensions, ), + # The provider enforces a thread view only while host bash is off. + patch(_LOCAL_HOST_BASH_ALLOWED, return_value=False), ): storage = UserScopedSkillStorage( "user-1", @@ -519,6 +524,61 @@ class TestThreeWayMountEndToEnd: ) assert grepped == [] + def test_local_host_bash_run_does_not_inherit_a_stale_agent_view(self, tmp_path): + """A restricted run leaves a thread view behind. Once host bash is + allowed, SandboxMiddleware no longer refreshes that view, so the + provider must not mount it: later unrestricted runs on the thread would + otherwise keep the old allowlist and never see newly added skills.""" + from deerflow.sandbox.middleware import SandboxMiddleware + + skills_root = tmp_path / "skills" + _write_skill(skills_root / "public", "allowed-skill", "ALLOWED_MARKER") + _write_skill(skills_root / "public", "excluded-skill", "EXCLUDED_MARKER") + (skills_root / "custom").mkdir(parents=True) + paths = Paths(base_dir=tmp_path) + cfg = _build_config(skills_root) + extensions = ExtensionsConfig() + + with ( + patch("deerflow.config.get_app_config", return_value=cfg), + patch("deerflow.config.paths.get_paths", return_value=paths), + patch("deerflow.config.extensions_config.ExtensionsConfig.from_file", return_value=extensions), + patch("deerflow.config.extensions_config.get_extensions_config", return_value=extensions), + ): + storage = UserScopedSkillStorage("user-1", host_path=str(skills_root), app_config=cfg) + provider = LocalSandboxProvider() + + def run(available_skills, *, host_bash: bool): + with ( + patch(_LOCAL_HOST_BASH_ALLOWED, return_value=host_bash), + patch("deerflow.sandbox.middleware.get_sandbox_provider", return_value=provider), + patch("deerflow.skills.storage.get_or_new_user_skill_storage", return_value=storage), + ): + SandboxMiddleware(available_skills=available_skills)._prepare_agent_skill_projection("thread-switch", user_id="user-1") + sandbox = provider.get(provider.acquire("thread-switch", user_id="user-1")) + assert sandbox is not None + return sandbox + + def readable(sandbox, name: str) -> bool: + try: + sandbox.read_file(f"/mnt/skills/public/{name}/SKILL.md") + except FileNotFoundError: + return False + return True + + restricted = run({"allowed-skill"}, host_bash=False) + assert (readable(restricted, "allowed-skill"), readable(restricted, "excluded-skill")) == (True, False) + + _write_skill(skills_root / "public", "added-skill", "ADDED_MARKER") + unrestricted = run(None, host_bash=True) + assert all(readable(unrestricted, name) for name in ("allowed-skill", "excluded-skill", "added-skill")) + assert "/mnt/skills" not in {mapping.container_path for mapping in unrestricted.path_mappings} + + # The view was left in place, so the allowlist is enforced again + # as soon as host bash is turned back off. + restricted_again = run({"allowed-skill"}, host_bash=False) + assert [readable(restricted_again, name) for name in ("allowed-skill", "excluded-skill", "added-skill")] == [True, False, False] + # ── AioSandboxProvider ────────────────────────────────────────────── def test_aio_public_skill_mount(self, skills_fs, aio_mod):