fix(skills): parse Responses API content blocks in moderation scanner (#4936)

* Fix skill moderation parsing for Responses API content blocks

Normalize LangChain Responses API text blocks before parsing the security moderation decision, while preserving the existing fail-closed behavior for unavailable or invalid moderation results. Add regression coverage for mixed content blocks and document the compatibility boundary.

Constraint: Responses API AIMessage content is list-shaped while Chat Completions content is string-shaped
Rejected: Disable security scanning | would weaken the skill write safety boundary
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep moderation parsing provider-format tolerant without including reasoning or tool blocks in the decision payload
Tested: 27 security scanner tests; ruff check; ruff format check
Not-tested: Live moderation request against the configured external endpoint

* Reuse shared LLM response text normalization

Route skill moderation responses through the existing provider-format normalizer so only text and output_text blocks participate in JSON parsing. Strengthen regression coverage with reasoning and tool blocks that contain misleading text fields.\n\nConstraint: Responses API content is shared across multiple harness consumers\nRejected: Keep a private normalizer | duplicated provider-shape policy diverges and can reintroduce reasoning-block contamination\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Extend the shared normalizer when a new provider content shape is verified; do not add divergent local parsers\nTested: 118 related backend tests; regression test red against the previous parser; Ruff check and format check\nNot-tested: Live GitHub CLA status refresh

* Restore trusted external skill package loading

Skill discovery follows one-level package-directory symlinks, but activation path validation rejected the resolved external path. Restore that compatibility for configured custom-skill category roots while keeping file-level symlinks and deeper escapes blocked. Add regression coverage for local and user-scoped storage plus slash activation, and document the boundary.

Constraint: Existing skill discovery follows directory symlinks and operator-managed external packages must remain loadable

Rejected: Allow arbitrary resolved paths | would weaken the skill path trust boundary

Confidence: high

Scope-risk: moderate

Directive: Keep the final SKILL.md file symlink-free and preserve one-level category-root validation

Tested: 79 targeted skill storage, loader, slash activation, and user-scoped tests passed; Ruff check and format check passed; GitNexus staged change detection reported low risk

Not-tested: Real symlink activation on this Windows host lacks SeCreateSymbolicLinkPrivilege and is skipped

Related: Skill projection copies sources into sandbox-visible views

* Exercise real filesystem symlink boundaries in skill storage tests

Replace global Path.resolve/is_symlink mocks with real directory and file symlinks, preserving the Windows privilege skip. Add regression coverage for deeper custom-root escapes and symlinks under non-custom categories so the one-level allowance remains explicit.

Constraint: Symlink creation requires SeCreateSymbolicLinkPrivilege on some Windows runners
Rejected: Keep global path-method mocks | they validate the mock behavior rather than filesystem semantics
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep security-boundary tests on real filesystem primitives; skip only when the runner lacks symlink privilege
Tested: 76 targeted loader/storage/slash tests; Ruff check; Ruff format check
Not-tested: Windows symlink-enabled execution on this host
Related: #4936

* Pin the actual nested symlink escape boundary

Place the second symlink below a real custom package directory so the test reaches the one-level relative-parent guard instead of returning early on a non-symlink parent. Keep the public-category rejection coverage unchanged.

Constraint: The security boundary depends on both symlink depth and category root
Rejected: Link the outer package directory directly | the parent is not a symlink at validation time, so the depth guard is never evaluated
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep this regression tied to the exact relative_parent.parts depth check
Tested: Targeted storage, loader, and slash suites; GitNexus staged detection
Not-tested: Symlink-enabled execution on this Windows host
Related: #4936

* Make the nested symlink regression reach the depth guard

The test now validates the SKILL.md directly through the nested symlink, so the symlink is the immediate parent and the relative-parent depth check is executed.

Constraint: Windows test execution may skip when symlink privilege is unavailable
Rejected: Keep the extra nested path segment | it bypasses the symlink-depth guard through an early return
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Mutation tests must fail when the depth restriction is removed
Tested: Targeted test (skipped on this Windows host without symlink privilege); Ruff check and format check
Not-tested: Real symlink execution on Windows; Linux CI will exercise the case
Related: #4936

* Keep sandbox projections fresh for linked external skill packages

The storage layer intentionally accepts one-level custom package-directory symlinks, but projection freshness previously hashed only the link inode. Follow the permitted target tree during custom and legacy source-signature scans so edits to SKILL.md, scripts, references, or assets trigger a rebuild before sandbox use.

Constraint: Preserve the existing one-level custom/legacy symlink boundary and do not follow public, integration, nested, or file symlinks

Rejected: Invalidate projections only from /api/skills/reload | sandbox acquisition must also detect edits made directly in external targets

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Keep target-tree traversal limited to the storage paths that explicitly permit external package-directory links

Tested: 77 projection, user-scoped storage, and lifecycle tests passed; Ruff check and format check passed; git diff --check passed; GitNexus staged detection reported low risk

Not-tested: Real external symlink execution on this Windows host without SeCreateSymbolicLinkPrivilege; existing tests skip that platform limitation
This commit is contained in:
georgelichen 2026-08-24 21:27:43 +08:00 committed by GitHub
parent cc6a2657e7
commit 641a4147e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 277 additions and 8 deletions

View File

@ -890,7 +890,7 @@ than a green status hiding a later `command not found`.
If a trusted operator manages the configured skills directory through an external mount such as MinIO, NFS, or CSI, an administrator can call `POST /api/skills/reload` after changing files. This invalidates skill prompt caches for the current Gateway process and waits up to the bounded refresh timeout so subsequent runs rescan the latest files; running tasks are unchanged. A loader-level filesystem failure returns a generic server error and preserves the last successfully loaded process cache rather than publishing an empty catalog. Uvicorn workers and Kubernetes Pods must each be targeted separately. Direct mount writes bypass the validation, SkillScan, and history applied by DeerFlow's install/edit APIs, so only operator-controlled systems should have write access. If a trusted operator manages the configured skills directory through an external mount such as MinIO, NFS, or CSI, an administrator can call `POST /api/skills/reload` after changing files. This invalidates skill prompt caches for the current Gateway process and waits up to the bounded refresh timeout so subsequent runs rescan the latest files; running tasks are unchanged. A loader-level filesystem failure returns a generic server error and preserves the last successfully loaded process cache rather than publishing an empty catalog. Uvicorn workers and Kubernetes Pods must each be targeted separately. Direct mount writes bypass the validation, SkillScan, and history applied by DeerFlow's install/edit APIs, so only operator-controlled systems should have write access.
Skill installs and agent-managed skill edits run through **SkillScan**, a native deterministic safety scanner before the LLM-based skill scanner. Phase 1 runs offline with no Semgrep/OpenGrep dependency, blocks high-confidence `CRITICAL` findings such as private keys or shell execution, and passes warning findings to the LLM scanner for contextual review. Python instance-client exfiltration checks follow a minimal same-scope evidence chain: a simple name bound to a known client constructor, optional name-to-name aliases, and an actual outbound method or context-manager use supported by that constructor. Constructor roots must be proven imports; bare canonical-looking names are not inferred as modules. Nested scopes do not inherit client handles and inherit only constructor import aliases that are never rebound in the enclosing scope. Comprehensions, walrus-bearing statements, annotations, complex binding targets, unsupported operations, and ambiguous branch flows produce no finding from this signal; skipped constructs conservatively invalidate every name they may bind so stale client state cannot create a finding. A deterministic work budget or recursion limit reached by this best-effort analysis does not discard findings already collected for the file. Set `skill_scan.enabled: false` in `config.yaml` to disable only the deterministic analyzers; safe archive extraction and the LLM scanner still run. Skill installs and agent-managed skill edits run through **SkillScan**, a native deterministic safety scanner before the LLM-based skill scanner. Phase 1 runs offline with no Semgrep/OpenGrep dependency, blocks high-confidence `CRITICAL` findings such as private keys or shell execution, and passes warning findings to the LLM scanner for contextual review. The moderation adapter normalizes both plain-text model responses and LangChain Responses API text blocks before parsing the required JSON decision. Python instance-client exfiltration checks follow a minimal same-scope evidence chain: a simple name bound to a known client constructor, optional name-to-name aliases, and an actual outbound method or context-manager use supported by that constructor. Constructor roots must be proven imports; bare canonical-looking names are not inferred as modules. Nested scopes do not inherit client handles and inherit only constructor import aliases that are never rebound in the enclosing scope. Comprehensions, walrus-bearing statements, annotations, complex binding targets, unsupported operations, and ambiguous branch flows produce no finding from this signal; skipped constructs conservatively invalidate every name they may bind so stale client state cannot create a finding. A deterministic work budget or recursion limit reached by this best-effort analysis does not discard findings already collected for the file. Set `skill_scan.enabled: false` in `config.yaml` to disable only the deterministic analyzers; safe archive extraction and the LLM scanner still run.
DeerFlow also ships with **skill-reviewer**, a public skill for read-only skill quality review. It uses the built-in `review_skill_package` tool to inspect installed skills, local packages, archives, or pasted `SKILL.md` content without activating the target skill, binding its secrets, executing its scripts, or installing it. The tool returns a compact, tag-neutralized JSON payload to the model context and keeps the full raw review payload in the tool artifact for programmatic consumers. The deterministic review core reuses DeerFlow parsing and SkillScan facts, emits versioned JSON contracts under `contracts/skill_review/`, and can be run from the backend CLI: DeerFlow also ships with **skill-reviewer**, a public skill for read-only skill quality review. It uses the built-in `review_skill_package` tool to inspect installed skills, local packages, archives, or pasted `SKILL.md` content without activating the target skill, binding its secrets, executing its scripts, or installing it. The tool returns a compact, tag-neutralized JSON payload to the model context and keeps the full raw review payload in the tool artifact for programmatic consumers. The deterministic review core reuses DeerFlow parsing and SkillScan facts, emits versioned JSON contracts under `contracts/skill_review/`, and can be run from the backend CLI:

View File

@ -2,7 +2,7 @@
- **Location**: global public skills live under `deer-flow/skills/public/`; user-authored custom skills live under `{DEER_FLOW_HOME}/users/{user_id}/skills/custom/`; globally managed integration skills live under `{DEER_FLOW_HOME}/integrations/skills/{provider}/`; per-user integration credentials remain under `{DEER_FLOW_HOME}/users/{user_id}/integrations/{provider}/{config,data}` - **Location**: global public skills live under `deer-flow/skills/public/`; user-authored custom skills live under `{DEER_FLOW_HOME}/users/{user_id}/skills/custom/`; globally managed integration skills live under `{DEER_FLOW_HOME}/integrations/skills/{provider}/`; per-user integration credentials remain under `{DEER_FLOW_HOME}/users/{user_id}/integrations/{provider}/{config,data}`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets) - **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets)
- **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. 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. It parses runtime metadata and reads enabled state from extensions_config.json. - **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. 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. It parses runtime metadata and reads enabled state from extensions_config.json.
- **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. - **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 custom-agent/subagent skill allowlists remain discoverable without clamping the baseline toolset. 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. This is best-effort behavioral scoping, not a hard security boundary: alternate loading paths are not captured and bounded autonomous context may evict entries. - **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 custom-agent/subagent skill allowlists remain discoverable without clamping the baseline toolset. 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. This is best-effort behavioral scoping, not a hard security boundary: alternate loading paths are not captured and bounded autonomous context may evict entries.
- **Sandbox projection**: `skills/projection.py` materializes enabled-only trees at `{base_dir}/skills_view/public` and `{base_dir}/users/{user_id}/skills_view/{custom,legacy,integrations}`. 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 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 under a cross-process lock; Gateway boot ensures only the shared public view, while each user view is repaired lazily on first sandbox acquire. Managed integration packages are global, but their projected category is per-user because enabled state is isolated. Rebuilds stage a complete tree and reconcile it with per-file atomic replacement, so unrelated enabled skills remain continuously visible; disable/delete paths remove only the affected package before mutating to preserve fail-closed behavior. 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 from disk, persist the full model shape, 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-scope checks remain serialized per user. Category root inodes remain stable so live bind mounts observe content changes without sandbox recreation. Projection failures clear the affected view before raising. - **Sandbox projection**: `skills/projection.py` materializes enabled-only trees at `{base_dir}/skills_view/public` and `{base_dir}/users/{user_id}/skills_view/{custom,legacy,integrations}`. 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 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 under a cross-process lock; Gateway boot ensures only the shared public view, while each user view is repaired lazily on first sandbox acquire. Managed integration packages are global, but their projected category is per-user because enabled state is isolated. Rebuilds stage a complete tree and reconcile it with per-file atomic replacement, so unrelated enabled skills remain continuously visible; disable/delete paths remove only the affected package before mutating to preserve fail-closed behavior. 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 from disk, persist the full model shape, 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-scope checks remain serialized per user. Category root inodes remain stable so live bind mounts observe content changes without sandbox recreation. Projection failures clear the affected view before raising.
@ -13,7 +13,7 @@
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`), disabled skills, and skills outside a custom agent's whitelist. - **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`), disabled skills, and skills outside a custom agent's whitelist.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory - **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Managed integrations**: Lark/Feishu CLI support installs one global official `lark-*` pack as read-only `SkillCategory.INTEGRATION` entries under `/mnt/skills/integrations/lark-cli/...`; enabled flags, app configuration, and OAuth data remain per-user. Install resolves the newest `larksuite/cli` release from GitHub (`releases/latest`) at install time (falling back to a bottom-line pinned version if the lookup fails) rather than hard-coding the pack version; integrity relies on the official host + structural archive guards + a recorded hash of the effective installed tree after shared guidance injection (not a pinned archive-byte SHA, which GitHub does not keep stable). The Gateway image still installs a pinned `@larksuite/cli` binary, so `get_lark_integration_status` surfaces `latest_available_version` and `runtime_version_mismatch` for the UI. AIO installs additionally verify and publish official Linux amd64/arm64 binaries under `{DEER_FLOW_HOME}/integrations/lark-cli/sandbox-cli`, mounted read-only at `/mnt/integrations/lark-cli/runtime`; `/mnt/integrations/lark-cli/config` (app credentials, incl. the long-lived `appSecret`) is mounted **read-only** into the sandbox, its empty `config/locks` subdirectory is over-mounted writable for `lark-cli` coordination files, and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, all mapping to owner-only per-user directories. **Sandbox trust boundary:** the credential-bearing config and data dirs are still *readable* by arbitrary sandbox processes (the agent's `bash` tool, or code reached via prompt-injection in a tool result), so the app secret and tokens are exposed to sandbox-side code even though they never reach the browser — the read-only config mount only prevents in-sandbox tampering, not read/exfiltration. The sidecar credential-broker (Pattern B, issue #4338) is the fix that removes these plaintext mounts from sandbox execution: set `LARK_CLI_BROKER_IMAGE` on the provisioner (see `docker/lark-cli-broker/`) and the Gateway sends `provision_lark_cli_broker` on sandbox create. The provisioner then runs a `lark-cli-broker` sidecar that owns the per-user `config`/`config/locks`/`data` mounts (mounted into the **sidecar only**, at `/var/lark/{config,config/locks,data}` with only the nested locks mount writable) and serves the `lark-cli` command surface on Pod loopback (`http://127.0.0.1:8788`); a shim init container (`install-shim`) writes a forwarding `lark-cli` into the shared runtime `emptyDir`, so the sandbox gets `DEERFLOW_LARK_BROKER_URL` + a shim on PATH but **no** credential files. The on-PATH `bin/lark-cli` is a `/bin/sh` launcher that resolves a Python 3 interpreter and execs the Python shim body (`bin/lark-cli-shim.py`) beside it, so broker mode does not silently ENOEXEC on a sandbox image without a `#!/usr/bin/env python3`-resolvable interpreter — it fails loudly (exit 127, actionable message) and can be pinned with `DEERFLOW_LARK_BROKER_PYTHON`. The broker runs `lark-cli` in the sidecar's cwd and cannot see the sandbox filesystem, so cwd is intentionally **not** forwarded and file-I/O subcommands relative to the sandbox cwd are unsupported (command surface only). An optional `DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS` denylist (comma-separated command prefixes, forwarded from the provisioner) lets the broker refuse secret-dumping subcommands before spawning the binary. `lark_cli_env_overlay(broker=True)` therefore omits `LARKSUITE_CLI_CONFIG_DIR`/`DATA_DIR`; `sandbox_lark_broker_active()` (TTL-cached provisioner `/api/capabilities` probe, tight timeout + longer negative caching on the bash hot path) selects broker vs. binary mode for both the bash env overlay and status. `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` supplies a validated, symlink-free pre-staged runtime for air-gapped deployments. For the remote provisioner (K8s), the runtime binary is otherwise provisioned by an optional init container + shared `emptyDir` (Pattern A): set `LARK_CLI_INIT_IMAGE` on the provisioner (see `docker/lark-cli-init/`) and the Gateway sends `provision_lark_cli_runtime` on sandbox create once the pack is installed, so remote installs skip the Gateway-side GitHub download entirely. Broker (Pattern B) supersedes the init-container binary (Pattern A) when both images are configured. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container` / `broker`) and `sandbox_runtime_ready` (remote modes read the provisioner `GET /api/capabilities`: `lark_cli_init_image` / `lark_cli_broker_image`) so a green UI can't hide a chat-time `lark-cli: command not found`. Cheap status probes are explicitly not live-verified; users authorize or reconnect through the browser device-flow endpoints instead of running terminal commands. - **Managed integrations**: Lark/Feishu CLI support installs one global official `lark-*` pack as read-only `SkillCategory.INTEGRATION` entries under `/mnt/skills/integrations/lark-cli/...`; enabled flags, app configuration, and OAuth data remain per-user. Install resolves the newest `larksuite/cli` release from GitHub (`releases/latest`) at install time (falling back to a bottom-line pinned version if the lookup fails) rather than hard-coding the pack version; integrity relies on the official host + structural archive guards + a recorded hash of the effective installed tree after shared guidance injection (not a pinned archive-byte SHA, which GitHub does not keep stable). The Gateway image still installs a pinned `@larksuite/cli` binary, so `get_lark_integration_status` surfaces `latest_available_version` and `runtime_version_mismatch` for the UI. AIO installs additionally verify and publish official Linux amd64/arm64 binaries under `{DEER_FLOW_HOME}/integrations/lark-cli/sandbox-cli`, mounted read-only at `/mnt/integrations/lark-cli/runtime`; `/mnt/integrations/lark-cli/config` (app credentials, incl. the long-lived `appSecret`) is mounted **read-only** into the sandbox, its empty `config/locks` subdirectory is over-mounted writable for `lark-cli` coordination files, and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, all mapping to owner-only per-user directories. **Sandbox trust boundary:** the credential-bearing config and data dirs are still *readable* by arbitrary sandbox processes (the agent's `bash` tool, or code reached via prompt-injection in a tool result), so the app secret and tokens are exposed to sandbox-side code even though they never reach the browser — the read-only config mount only prevents in-sandbox tampering, not read/exfiltration. The sidecar credential-broker (Pattern B, issue #4338) is the fix that removes these plaintext mounts from sandbox execution: set `LARK_CLI_BROKER_IMAGE` on the provisioner (see `docker/lark-cli-broker/`) and the Gateway sends `provision_lark_cli_broker` on sandbox create. The provisioner then runs a `lark-cli-broker` sidecar that owns the per-user `config`/`config/locks`/`data` mounts (mounted into the **sidecar only**, at `/var/lark/{config,config/locks,data}` with only the nested locks mount writable) and serves the `lark-cli` command surface on Pod loopback (`http://127.0.0.1:8788`); a shim init container (`install-shim`) writes a forwarding `lark-cli` into the shared runtime `emptyDir`, so the sandbox gets `DEERFLOW_LARK_BROKER_URL` + a shim on PATH but **no** credential files. The on-PATH `bin/lark-cli` is a `/bin/sh` launcher that resolves a Python 3 interpreter and execs the Python shim body (`bin/lark-cli-shim.py`) beside it, so broker mode does not silently ENOEXEC on a sandbox image without a `#!/usr/bin/env python3`-resolvable interpreter — it fails loudly (exit 127, actionable message) and can be pinned with `DEERFLOW_LARK_BROKER_PYTHON`. The broker runs `lark-cli` in the sidecar's cwd and cannot see the sandbox filesystem, so cwd is intentionally **not** forwarded and file-I/O subcommands relative to the sandbox cwd are unsupported (command surface only). An optional `DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS` denylist (comma-separated command prefixes, forwarded from the provisioner) lets the broker refuse secret-dumping subcommands before spawning the binary. `lark_cli_env_overlay(broker=True)` therefore omits `LARKSUITE_CLI_CONFIG_DIR`/`DATA_DIR`; `sandbox_lark_broker_active()` (TTL-cached provisioner `/api/capabilities` probe, tight timeout + longer negative caching on the bash hot path) selects broker vs. binary mode for both the bash env overlay and status. `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` supplies a validated, symlink-free pre-staged runtime for air-gapped deployments. For the remote provisioner (K8s), the runtime binary is otherwise provisioned by an optional init container + shared `emptyDir` (Pattern A): set `LARK_CLI_INIT_IMAGE` on the provisioner (see `docker/lark-cli-init/`) and the Gateway sends `provision_lark_cli_runtime` on sandbox create once the pack is installed, so remote installs skip the Gateway-side GitHub download entirely. Broker (Pattern B) supersedes the init-container binary (Pattern A) when both images are configured. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container` / `broker`) and `sandbox_runtime_ready` (remote modes read the provisioner `GET /api/capabilities`: `lark_cli_init_image` / `lark_cli_broker_image`) so a green UI can't hide a chat-time `lark-cli: command not found`. Cheap status probes are explicitly not live-verified; users authorize or reconnect through the browser device-flow endpoints instead of running terminal commands.
- **SkillScan**: `packages/harness/deerflow/skills/skillscan/` is the native deterministic scanner for `.skill` archives and agent-managed skill writes. It runs offline before the LLM scanner, emits structured findings (`rule_id`, `severity`, `file`, `line`, `message`, `remediation`, redacted `evidence` — category/analyzer are encoded in the `rule_id` prefix), blocks `CRITICAL`, and passes warning findings into `scan_skill_content()`. `scan_archive_preflight()` / `scan_skill_dir()` are pure sync functions (dispatch off the event loop); `enforce_static_scan()` applies the blocking policy and the `skill_scan.enabled` kill switch. The Python instance-client signal deliberately follows only a one-level, same-scope evidence chain (PR #4265 review): a proven imported constructor bound to a simple name, optional name-to-name alias propagation, rebinding invalidation, and a constructor-supported outbound method or context-manager use; bare canonical-looking names never fall back to module identity. Nested scopes never inherit client handles and inherit only constructor aliases proven stable by a binding-only enclosing-scope prepass. Comprehensions, walrus-bearing statements, annotations, executable expressions inside complex binding targets, unsupported operations, and ambiguous flows produce no finding from this signal; skipped constructs invalidate all names they may bind, while representative false negatives are pinned by `test_python_declared_false_negatives_stay_unreported`. Compound bodies are walked from isolated copies so wrapping code in `if True:` is not a bypass, while copied scope entries, binding-only prepasses, and AST visits consume a deterministic work budget and the walk stops after its first sink. Budget or recursion exhaustion skips only this best-effort signal and retains deterministic findings already collected for the file. Do not add Semgrep/OpenGrep or YAML rule-engine dependencies to the core path; Phase 1 rule specs live in Python constants next to their analyzers in `skillscan/orchestrator.py`. - **SkillScan**: `packages/harness/deerflow/skills/skillscan/` is the native deterministic scanner for `.skill` archives and agent-managed skill writes. It runs offline before the LLM scanner, emits structured findings (`rule_id`, `severity`, `file`, `line`, `message`, `remediation`, redacted `evidence` — category/analyzer are encoded in the `rule_id` prefix), blocks `CRITICAL`, and passes warning findings into `scan_skill_content()`. The moderation adapter must normalize both plain-text responses and LangChain Responses API text blocks before parsing the required JSON decision. `scan_archive_preflight()` / `scan_skill_dir()` are pure sync functions (dispatch off the event loop); `enforce_static_scan()` applies the blocking policy and the `skill_scan.enabled` kill switch. The Python instance-client signal deliberately follows only a one-level, same-scope evidence chain (PR #4265 review): a proven imported constructor bound to a simple name, optional name-to-name alias propagation, rebinding invalidation, and a constructor-supported outbound method or context-manager use; bare canonical-looking names never fall back to module identity. Nested scopes never inherit client handles and inherit only constructor aliases proven stable by a binding-only enclosing-scope prepass. Comprehensions, walrus-bearing statements, annotations, executable expressions inside complex binding targets, unsupported operations, and ambiguous flows produce no finding from this signal; skipped constructs invalidate all names they may bind, while representative false negatives are pinned by `test_python_declared_false_negatives_stay_unreported`. Compound bodies are walked from isolated copies so wrapping code in `if True:` is not a bypass, while copied scope entries, binding-only prepasses, and AST visits consume a deterministic work budget and the walk stops after its first sink. Budget or recursion exhaustion skips only this best-effort signal and retains deterministic findings already collected for the file. Do not add Semgrep/OpenGrep or YAML rule-engine dependencies to the core path; Phase 1 rule specs live in Python constants next to their analyzers in `skillscan/orchestrator.py`.
- **Skill Review Core**: `packages/harness/deerflow/skills/review/` provides read-only package snapshots, deterministic facts, resource/eval analysis, report rendering, and the CLI (`python -m deerflow.skills.review.cli`). It reuses the shared frontmatter helper and SkillScan; it must not import `app.*`, execute target scripts, install dependencies, or call networks. JSON contracts live in `contracts/skill_review/`. The `review_skill_package` built-in tool labels results with `review_subject_entry` and never `skill_context_entry`, so reviewing a target does not activate it, bind its `required-secrets`, or apply its `allowed-tools`. Its model-visible `ToolMessage.content` is a compact JSON payload with untrusted control tags neutralized; the full raw review payload, including Markdown renders, stays in `ToolMessage.artifact`. CI should run the CLI with `--fail-on error --fail-on-incomplete` so blocker/error findings and truncated/not-assessed packages fail the gate. The public `skills/public/skill-reviewer` skill owns semantic readiness review and suggestions only; mutation and runtime experiments remain owned by `skill-creator`. - **Skill Review Core**: `packages/harness/deerflow/skills/review/` provides read-only package snapshots, deterministic facts, resource/eval analysis, report rendering, and the CLI (`python -m deerflow.skills.review.cli`). It reuses the shared frontmatter helper and SkillScan; it must not import `app.*`, execute target scripts, install dependencies, or call networks. JSON contracts live in `contracts/skill_review/`. The `review_skill_package` built-in tool labels results with `review_subject_entry` and never `skill_context_entry`, so reviewing a target does not activate it, bind its `required-secrets`, or apply its `allowed-tools`. Its model-visible `ToolMessage.content` is a compact JSON payload with untrusted control tags neutralized; the full raw review payload, including Markdown renders, stays in `ToolMessage.artifact`. CI should run the CLI with `--fail-on error --fail-on-incomplete` so blocker/error findings and truncated/not-assessed packages fail the gate. The public `skills/public/skill-reviewer` skill owns semantic readiness review and suggestions only; mutation and runtime experiments remain owned by `skill-creator`.
#### Request-Scoped Secrets (`required-secrets`) #### Request-Scoped Secrets (`required-secrets`)

View File

@ -216,7 +216,13 @@ def _clear_projection_scope(scope_root: Path, *category_roots: Path) -> None:
_manifest_path(scope_root).unlink(missing_ok=True) _manifest_path(scope_root).unlink(missing_ok=True)
def _update_tree_digest(digest, root: Path, label: str) -> None: def _update_tree_digest(
digest,
root: Path,
label: str,
*,
follow_package_directory_symlinks: bool = False,
) -> None:
"""Hash directory metadata (inode/mode/size/mtime), not file contents. """Hash directory metadata (inode/mode/size/mtime), not file contents.
Trade-off: fast enough to run on every sandbox acquire (O(files), no Trade-off: fast enough to run on every sandbox acquire (O(files), no
@ -225,6 +231,11 @@ def _update_tree_digest(digest, root: Path, label: str) -> None:
projection stale until the next explicit rebuild. Runtime writes through projection stale until the next explicit rebuild. Runtime writes through
this codebase are covered regardless: the mutation path rebuilds under this codebase are covered regardless: the mutation path rebuilds under
lock, and atomic-rename always changes the inode. lock, and atomic-rename always changes the inode.
Custom skill roots may contain an operator-managed package directory
symlink. Follow only those links directly below the category root so
changes in their external target tree invalidate the projection, while
nested and unrelated symlinks remain boundary markers.
""" """
digest.update(f"root:{label}\0".encode()) digest.update(f"root:{label}\0".encode())
if not root.exists(): if not root.exists():
@ -242,6 +253,9 @@ def _update_tree_digest(digest, root: Path, label: str) -> None:
metadata = entry.stat(follow_symlinks=False) metadata = entry.stat(follow_symlinks=False)
if entry.is_symlink(): if entry.is_symlink():
kind = "link" kind = "link"
if follow_package_directory_symlinks and relative_root == Path(".") and entry.is_dir(follow_symlinks=True):
digest.update(f"{label}:{relative.as_posix()}:target:{Path(entry.path).resolve(strict=False)}\0".encode())
child_dirs.append((Path(entry.path), relative))
elif entry.is_dir(follow_symlinks=False): elif entry.is_dir(follow_symlinks=False):
kind = "dir" kind = "dir"
child_dirs.append((Path(entry.path), relative)) child_dirs.append((Path(entry.path), relative))
@ -267,8 +281,18 @@ def _source_signature(storage: SkillStorage, scope: str) -> str:
elif scope == "user": elif scope == "user":
user_custom_root = storage.get_user_custom_root() user_custom_root = storage.get_user_custom_root()
integration_root = storage.get_user_integrations_root() integration_root = storage.get_user_integrations_root()
_update_tree_digest(digest, user_custom_root, "custom") _update_tree_digest(
_update_tree_digest(digest, host_root / SkillCategory.CUSTOM.value, "legacy") digest,
user_custom_root,
"custom",
follow_package_directory_symlinks=True,
)
_update_tree_digest(
digest,
host_root / SkillCategory.CUSTOM.value,
"legacy",
follow_package_directory_symlinks=True,
)
_update_tree_digest(digest, integration_root, "integrations") _update_tree_digest(digest, integration_root, "integrations")
# CUSTOM/LEGACY/INTEGRATION visibility is the intersection of the # CUSTOM/LEGACY/INTEGRATION visibility is the intersection of the
# per-user state and the global extensions default, so both belong in # per-user state and the global extensions default, so both belong in

View File

@ -15,6 +15,7 @@ from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.types import SKILL_MD_FILE from deerflow.skills.types import SKILL_MD_FILE
from deerflow.tracing import inject_langfuse_metadata from deerflow.tracing import inject_langfuse_metadata
from deerflow.utils.llm_text import extract_response_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -155,7 +156,7 @@ async def scan_skill_content(
config=invoke_config, config=invoke_config,
) )
model_responded = True model_responded = True
raw = str(getattr(response, "content", "") or "") raw = extract_response_text(getattr(response, "content", ""))
parsed = _extract_json_object(raw) parsed = _extract_json_object(raw)
if parsed: if parsed:
decision = str(parsed.get("decision", "")).lower() decision = str(parsed.get("decision", "")).lower()

View File

@ -78,6 +78,23 @@ class SkillStorage(ABC):
if parsed_name != name: if parsed_name != name:
raise ValueError(f"Frontmatter name '{parsed_name}' must match requested skill name '{name}'.") raise ValueError(f"Frontmatter name '{parsed_name}' must match requested skill name '{name}'.")
@staticmethod
def _is_external_skill_directory_symlink(skill_file: Path, custom_root: Path) -> bool:
"""Allow one-level package-directory links without allowing file links.
The storage contract permits an externally managed skill package to be
linked directly below the supplied custom-skill category root. Preserve
that compatibility while rejecting arbitrary path escapes and symlinked
``SKILL.md`` files.
"""
if skill_file.is_symlink() or not skill_file.parent.is_symlink():
return False
try:
relative_parent = skill_file.parent.relative_to(custom_root)
except ValueError:
return False
return len(relative_parent.parts) == 1 and skill_file.parent.resolve().is_dir()
def ensure_safe_support_path(self, name: str, relative_path: str) -> Path: def ensure_safe_support_path(self, name: str, relative_path: str) -> Path:
"""Validate and return the resolved absolute path for a support file.""" """Validate and return the resolved absolute path for a support file."""
_ALLOWED_SUPPORT_SUBDIRS = {"references", "templates", "scripts", "assets"} _ALLOWED_SUPPORT_SUBDIRS = {"references", "templates", "scripts", "assets"}
@ -122,14 +139,23 @@ class SkillStorage(ABC):
under the per-user custom root, because custom skills are stored in a under the per-user custom root, because custom skills are stored in a
separate directory tree that is not a sub-path of the global root. separate directory tree that is not a sub-path of the global root.
A one-level symlinked package directory directly below the configured
custom-skill category root is also accepted for operator-managed
external skills; the final ``SKILL.md`` file itself must not be a
symlink.
Raises: Raises:
ValueError: if the resolved path escapes all allowed roots. ValueError: if the resolved path escapes all allowed roots.
""" """
if skill_file.is_symlink():
raise ValueError("Resolved skill file must stay within the configured skills root.")
resolved_file = skill_file.resolve() resolved_file = skill_file.resolve()
resolved_root = self.get_skills_root_path().resolve() resolved_root = self.get_skills_root_path().resolve()
try: try:
resolved_file.relative_to(resolved_root) resolved_file.relative_to(resolved_root)
except ValueError as exc: except ValueError as exc:
if self._is_external_skill_directory_symlink(skill_file, self.get_skills_root_path() / SkillCategory.CUSTOM.value):
return resolved_file
raise ValueError("Resolved skill file must stay within the configured skills root.") from exc raise ValueError("Resolved skill file must stay within the configured skills root.") from exc
return resolved_file return resolved_file

View File

@ -412,7 +412,12 @@ class UserScopedSkillStorage(LocalSkillStorage):
Custom and managed integration skills live outside ``_host_root``, so Custom and managed integration skills live outside ``_host_root``, so
the default implementation's single-root check would reject them. the default implementation's single-root check would reject them.
One-level package-directory symlinks under either configured custom-skill
category root retain the operator-managed external-skill compatibility
of the base storage.
""" """
if skill_file.is_symlink():
raise ValueError(f"Resolved skill file {skill_file} must stay within the configured skill roots and cannot be a symlink.")
resolved_file = skill_file.resolve() resolved_file = skill_file.resolve()
allowed_roots = ( allowed_roots = (
self._host_root.resolve(), self._host_root.resolve(),
@ -425,6 +430,8 @@ class UserScopedSkillStorage(LocalSkillStorage):
return resolved_file return resolved_file
except ValueError: except ValueError:
continue continue
if any(self._is_external_skill_directory_symlink(skill_file, custom_root) for custom_root in (self._user_custom_root, self._global_custom_root)):
return resolved_file
raise ValueError( raise ValueError(
f"Resolved skill file {resolved_file} must stay within the global skills root " f"Resolved skill file {resolved_file} must stay within the global skills root "
f"({self._host_root.resolve()}), the per-user custom root " f"({self._host_root.resolve()}), the per-user custom root "

View File

@ -115,6 +115,37 @@ async def test_scan_skill_content_passes_run_name_to_model(monkeypatch):
assert model.kwargs["config"] == {"run_name": "security_agent"} assert model.kwargs["config"] == {"run_name": "security_agent"}
@pytest.mark.anyio
async def test_scan_skill_content_parses_responses_api_text_blocks(monkeypatch):
_make_env(
monkeypatch,
[{"type": "text", "text": '{"decision":"allow","reason":"clean"}'}],
)
result = await scan_skill_content(SKILL_CONTENT, executable=False)
assert result.decision == "allow"
assert result.reason == "clean"
@pytest.mark.anyio
async def test_scan_skill_content_ignores_non_text_blocks_and_joins_text_blocks(monkeypatch):
_make_env(
monkeypatch,
[
{"type": "reasoning", "text": '{"decision":"block","reason":"fake"}'},
{"type": "text", "text": '{"decision":"allow",'},
{"type": "output_text", "text": '"reason":"clean"}'},
{"type": "tool_call", "text": '{"decision":"block","reason":"fake"}'},
],
)
result = await scan_skill_content(SKILL_CONTENT, executable=False)
assert result.decision == "allow"
assert result.reason == "clean"
@pytest.mark.anyio @pytest.mark.anyio
async def test_scan_skill_content_blocks_when_model_unavailable(monkeypatch): async def test_scan_skill_content_blocks_when_model_unavailable(monkeypatch):
config = SimpleNamespace(skill_evolution=SimpleNamespace(moderation_model_name=None)) config = SimpleNamespace(skill_evolution=SimpleNamespace(moderation_model_name=None))

View File

@ -144,6 +144,29 @@ def test_atomic_custom_skill_rewrite_refreshes_projection(projection_env) -> Non
assert target.stat().st_ino != old_inode assert target.stat().st_ino != old_inode
def test_external_custom_skill_directory_target_update_refreshes_projection(projection_env, tmp_path: Path) -> None:
env = projection_env
external_skill_dir = tmp_path / "external-skills" / "linked-skill"
source = _write_skill(external_skill_dir.parent, "linked-skill", "before")
linked_skill_dir = env.storage.get_user_custom_root() / "linked-skill"
linked_skill_dir.parent.mkdir(parents=True, exist_ok=True)
try:
linked_skill_dir.symlink_to(external_skill_dir, target_is_directory=True)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
projected = rebuild_skill_projections(env.storage)
target = projected.custom / "linked-skill" / "SKILL.md"
assert "before" in target.read_text(encoding="utf-8")
source.write_text(_skill_content("linked-skill", "after"), encoding="utf-8")
ensure_skill_projections(env.storage)
assert "after" in target.read_text(encoding="utf-8")
def test_custom_content_write_keeps_unrelated_skill_visible_during_rebuild(projection_env, monkeypatch) -> None: def test_custom_content_write_keeps_unrelated_skill_visible_during_rebuild(projection_env, monkeypatch) -> None:
env = projection_env env = projection_env
env.storage.write_custom_skill("alpha", "SKILL.md", _skill_content("alpha")) env.storage.write_custom_skill("alpha", "SKILL.md", _skill_content("alpha"))

View File

@ -3,8 +3,11 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest
from deerflow.config.skills_config import SkillsConfig from deerflow.config.skills_config import SkillsConfig
from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
def _write_skill(skill_dir: Path, name: str, description: str) -> None: def _write_skill(skill_dir: Path, name: str, description: str) -> None:
@ -63,6 +66,27 @@ def test_load_skills_discovers_nested_skills_and_sets_container_paths(tmp_path:
assert team_skill.get_container_file_path() == "/mnt/skills/custom/team/helper/SKILL.md" assert team_skill.get_container_file_path() == "/mnt/skills/custom/team/helper/SKILL.md"
def test_local_storage_accepts_external_custom_skill_directory_symlink(tmp_path: Path):
skills_root = tmp_path / "skills"
external_file = tmp_path / "external-skills" / "external-skill" / "SKILL.md"
external_file.parent.mkdir(parents=True)
external_file.write_text("---\nname: external-skill\ndescription: An external skill\n---\n", encoding="utf-8")
linked_dir = skills_root / "custom" / "external-skill"
linked_file = linked_dir / "SKILL.md"
linked_dir.parent.mkdir(parents=True)
try:
linked_dir.symlink_to(external_file.parent, target_is_directory=True)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
storage = LocalSkillStorage(host_path=str(skills_root))
assert storage.validate_skill_file_path(linked_file) == external_file
def test_load_skills_stops_at_skill_package_boundary(tmp_path: Path): def test_load_skills_stops_at_skill_package_boundary(tmp_path: Path):
"""SKILL.md files inside an existing skill package are support data, not skills.""" """SKILL.md files inside an existing skill package are support data, not skills."""
skills_root = tmp_path / "skills" skills_root = tmp_path / "skills"

View File

@ -3,6 +3,7 @@ import hashlib
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest
from langchain.agents.middleware.types import ModelRequest from langchain.agents.middleware.types import ModelRequest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@ -187,6 +188,59 @@ def test_skill_activation_middleware_reads_public_skill_from_real_user_scoped_st
assert user_msg is original assert user_msg is original
def test_skill_activation_middleware_reads_external_custom_skill_directory_symlink(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
skill_dir = tmp_path / "external-skills" / "external-skill"
skill_dir.mkdir(parents=True)
skill_content = "---\nname: external-skill\ndescription: An external skill\n---\n\n# External skill\n"
(skill_dir / "SKILL.md").write_text(skill_content, encoding="utf-8")
user_custom_root = tmp_path / "users" / "test-user" / "skills" / "custom"
user_custom_root.mkdir(parents=True)
try:
(user_custom_root / "external-skill").symlink_to(skill_dir, target_is_directory=True)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
app_config = SimpleNamespace(
skills=SimpleNamespace(
get_skills_path=lambda: skills_root,
container_path="/mnt/skills",
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
),
)
extensions_config = ExtensionsConfig()
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr(ExtensionsConfig, "from_file", classmethod(lambda cls, config_path=None: extensions_config))
monkeypatch.setattr("deerflow.config.extensions_config.get_extensions_config", lambda: extensions_config)
storage = UserScopedSkillStorage("test-user", host_path=str(skills_root), app_config=app_config)
monkeypatch.setattr(middleware_module, "get_or_new_user_skill_storage", lambda user_id, **kwargs: storage)
middleware = SkillActivationMiddleware(
app_config=app_config,
user_id="test-user",
slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN,
)
original = HumanMessage(content="/external-skill Run the external skill", id="msg-external-symlink")
request = _make_model_request([original])
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(request, handler)
assert isinstance(result, AIMessage)
assert result.content == "ok"
activation_msg, user_msg = captured["messages"]
assert "# External skill" in activation_msg.content
assert user_msg is original
def test_skill_activation_middleware_does_not_duplicate_existing_activation(monkeypatch, tmp_path): def test_skill_activation_middleware_does_not_duplicate_existing_activation(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.") skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill])) monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
@ -727,7 +781,12 @@ def test_skill_activation_middleware_rejects_skill_file_outside_skills_root(monk
outside_dir.mkdir() outside_dir.mkdir()
outside_file = outside_dir / "SKILL.md" outside_file = outside_dir / "SKILL.md"
outside_file.write_text("# Leaked\nDo not read me.", encoding="utf-8") outside_file.write_text("# Leaked\nDo not read me.", encoding="utf-8")
(skill_dir / "SKILL.md").symlink_to(outside_file) try:
(skill_dir / "SKILL.md").symlink_to(outside_file)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
skill = Skill( skill = Skill(
name="data-analysis", name="data-analysis",
description="Description for data-analysis", description="Description for data-analysis",

View File

@ -308,6 +308,80 @@ class TestPathSafety:
with pytest.raises(ValueError, match="must stay within"): with pytest.raises(ValueError, match="must stay within"):
user_storage.validate_skill_file_path(skill_file) user_storage.validate_skill_file_path(skill_file)
def test_accepts_external_skill_directory_symlink_but_not_file_symlink(self, user_storage: UserScopedSkillStorage, tmp_path: Path):
external_file = tmp_path / "external-skills" / "external-skill" / "SKILL.md"
external_file.parent.mkdir(parents=True)
external_file.write_text(_skill_content("external-skill"), encoding="utf-8")
linked_dir = user_storage.get_user_custom_root() / "external-skill"
linked_file = linked_dir / "SKILL.md"
linked_dir.parent.mkdir(parents=True)
try:
linked_dir.symlink_to(external_file.parent, target_is_directory=True)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
assert user_storage.validate_skill_file_path(linked_file) == external_file
file_link = user_storage.get_user_custom_root() / "file-link" / "SKILL.md"
file_link.parent.mkdir(parents=True)
try:
file_link.symlink_to(external_file)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
with pytest.raises(ValueError, match="must stay within"):
user_storage.validate_skill_file_path(file_link)
def test_rejects_file_symlink_even_when_target_stays_inside_allowed_root(self, user_storage: UserScopedSkillStorage, tmp_path: Path):
target_file = user_storage.get_user_custom_root() / "real-skill" / "SKILL.md"
target_file.parent.mkdir(parents=True)
target_file.write_text(_skill_content("real-skill"), encoding="utf-8")
linked_file = user_storage.get_user_custom_root() / "alias-skill" / "SKILL.md"
linked_file.parent.mkdir(parents=True)
try:
linked_file.symlink_to(target_file)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
with pytest.raises(ValueError, match="must stay within"):
user_storage.validate_skill_file_path(linked_file)
def test_rejects_deeper_and_non_custom_directory_symlinks(self, user_storage: UserScopedSkillStorage, skills_root: Path, tmp_path: Path):
external_dir = tmp_path / "external-skills" / "nested"
external_dir.mkdir(parents=True)
external_file = external_dir / "SKILL.md"
external_file.write_text(_skill_content("nested-skill"), encoding="utf-8")
deep_parent = user_storage.get_user_custom_root() / "outer"
deep_parent.mkdir(parents=True)
deep_link = deep_parent / "link"
try:
deep_link.symlink_to(external_dir, target_is_directory=True)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
public_link = skills_root / SkillCategory.PUBLIC.value / "external-skill"
public_link.parent.mkdir(parents=True, exist_ok=True)
try:
public_link.symlink_to(external_dir, target_is_directory=True)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires SeCreateSymbolicLinkPrivilege")
raise
with pytest.raises(ValueError, match="must stay within"):
user_storage.validate_skill_file_path(deep_link / "SKILL.md")
with pytest.raises(ValueError, match="must stay within"):
user_storage.validate_skill_file_path(public_link / "SKILL.md")
def test_rejects_invalid_skill_name(self, user_storage: UserScopedSkillStorage): def test_rejects_invalid_skill_name(self, user_storage: UserScopedSkillStorage):
with pytest.raises(ValueError, match="hyphen-case"): with pytest.raises(ValueError, match="hyphen-case"):
user_storage.get_custom_skill_dir("../../escaped") user_storage.get_custom_skill_dir("../../escaped")