Files
clawsec/skills/clawsec-suite/test/guarded_install.test.mjs
T
Aldo Delgado 7cdb4ab7e2 fix(portability): harden cross-platform path handling and install workflows (#62)
* docs: add agent collaboration and git safety rules to AGENTS.md

* fix(portability): harden cross-platform path handling and install workflows

- add shared path resolution utility for advisory guardian components
- expand and normalize home-path tokens: ~, $HOME, ${HOME}, %USERPROFILE%, $env:USERPROFILE
- reject unresolved/escaped home tokens to prevent literal "$HOME" directory creation
- fix install/runtime path handling in:
  - openclaw-audit-watchdog setup_cron and suppression config loader
  - clawsec-suite advisory hook handler, suppression loader, and guarded installer
- remove hardcoded Homebrew binary assumptions in watchdog scripts/tests
- add LF enforcement via .gitattributes to reduce CRLF script breakage
- expand CI Node checks to linux/macos/windows matrix
- add cross-platform test coverage for path expansion and token rejection
- update README and SKILL docs with bash/zsh/PowerShell-safe path guidance
- add compatibility deliverables:
  - docs/COMPATIBILITY_REPORT.md
  - docs/REMEDIATION_PLAN.md
  - docs/PLATFORM_VERIFICATION.md

Validation:
- node skills/clawsec-suite/test/path_resolution.test.mjs
- node skills/clawsec-suite/test/guarded_install.test.mjs
- node skills/clawsec-suite/test/advisory_suppression.test.mjs
- node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
- node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs

* fix(advisory): avoid fail-open on invalid path vars and cover watchdog tests

* docs: move signing runbooks into docs folder

* docs: remove root-level signing runbooks after move

* chore(clawsec-suite): bump version to 0.1.3

* chore(openclaw-audit-watchdog): bump version to 0.1.1

* docs(changelog): add entries for clawsec-suite 0.1.3 and watchdog 0.1.1

* docs(changelog): credit @aldodelgado for PR #62 contributions

* feat(clawsec-suite): scope advisories to openclaw application

* fix(ci): run advisory scope tests without TypeScript loader

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-02-25 13:24:31 +02:00

430 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Guarded skill install tests for clawsec-suite.
*
* Tests cover:
* - Conservative matching when version is omitted
* - Precise version matching when version is provided
* - Exit code 42 for advisory match requiring confirmation
* - High-risk advisory detection
*
* Run: node skills/clawsec-suite/test/guarded_install.test.mjs
*/
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "guarded_skill_install.mjs");
let tempDir;
let passCount = 0;
let failCount = 0;
function pass(name) {
passCount++;
console.log(`${name}`);
}
function fail(name, error) {
failCount++;
console.error(`${name}`);
console.error(` ${String(error)}`);
}
function generateEd25519KeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
return { publicKeyPem, privateKeyPem };
}
function signPayload(data, privateKeyPem) {
const privateKey = crypto.createPrivateKey(privateKeyPem);
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
return signature.toString("base64");
}
function createFeed(advisories) {
return JSON.stringify(
{
version: "1.0.0",
updated: "2026-02-08T12:00:00Z",
advisories,
},
null,
2,
);
}
function createChecksumManifest(files) {
const checksums = {};
for (const [name, content] of Object.entries(files)) {
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
}
return JSON.stringify(
{
schema_version: "1.0",
algorithm: "sha256",
files: checksums,
},
null,
2,
);
}
async function setupTestDir() {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-install-test-"));
}
async function cleanupTestDir() {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
async function setupSignedFeed(advisories, keyPair) {
const feedContent = createFeed(advisories);
const feedSignature = signPayload(feedContent, keyPair.privateKeyPem);
const checksumManifest = createChecksumManifest({
"feed.json": feedContent,
"feed.json.sig": feedSignature + "\n",
"feed-signing-public.pem": keyPair.publicKeyPem,
});
const checksumSignature = signPayload(checksumManifest, keyPair.privateKeyPem);
const advisoriesDir = path.join(tempDir, "advisories");
await fs.mkdir(advisoriesDir, { recursive: true });
await fs.writeFile(path.join(advisoriesDir, "feed.json"), feedContent);
await fs.writeFile(path.join(advisoriesDir, "feed.json.sig"), feedSignature + "\n");
await fs.writeFile(path.join(advisoriesDir, "checksums.json"), checksumManifest);
await fs.writeFile(path.join(advisoriesDir, "checksums.json.sig"), checksumSignature + "\n");
await fs.writeFile(path.join(advisoriesDir, "feed-signing-public.pem"), keyPair.publicKeyPem);
return advisoriesDir;
}
function runGuardedInstall(args, env) {
return new Promise((resolve) => {
const proc = spawn("node", [SCRIPT_PATH, ...args], {
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
resolve({ code, stdout, stderr });
});
});
}
// -----------------------------------------------------------------------------
// Test: Conservative matching when version is omitted
// -----------------------------------------------------------------------------
async function testConservativeMatchingWithoutVersion() {
const testName = "guarded_install: conservative matching without version triggers advisory";
try {
const keyPair = generateEd25519KeyPair();
const advisoriesDir = await setupSignedFeed(
[
{
id: "TEST-001",
severity: "high",
affected: ["test-skill@1.0.0", "test-skill@1.0.1"],
},
],
keyPair,
);
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
CLAWSEC_FEED_URL: "file:///nonexistent", // Force local fallback
});
if (result.code === 42 && result.stdout.includes("Conservative")) {
pass(testName);
} else {
fail(testName, `Expected exit 42 with conservative message, got ${result.code}: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Precise version matching
// -----------------------------------------------------------------------------
async function testPreciseVersionMatching() {
const testName = "guarded_install: precise version matching only matches exact version";
try {
const keyPair = generateEd25519KeyPair();
const advisoriesDir = await setupSignedFeed(
[
{
id: "TEST-001",
severity: "high",
affected: ["test-skill@1.0.0"],
},
],
keyPair,
);
// Version 2.0.0 should NOT match advisory for 1.0.0
const result = await runGuardedInstall(
["--skill", "test-skill", "--version", "2.0.0", "--dry-run"],
{
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
CLAWSEC_FEED_URL: "file:///nonexistent",
},
);
if (result.code === 0 && !result.stdout.includes("Advisory matches")) {
pass(testName);
} else {
fail(testName, `Expected exit 0 without match, got ${result.code}: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Version match triggers confirmation requirement
// -----------------------------------------------------------------------------
async function testVersionMatchTriggersConfirmation() {
const testName = "guarded_install: matching version triggers exit 42";
try {
const keyPair = generateEd25519KeyPair();
const advisoriesDir = await setupSignedFeed(
[
{
id: "TEST-001",
severity: "high",
affected: ["test-skill@1.0.0"],
},
],
keyPair,
);
const result = await runGuardedInstall(
["--skill", "test-skill", "--version", "1.0.0", "--dry-run"],
{
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
CLAWSEC_FEED_URL: "file:///nonexistent",
},
);
if (result.code === 42 && result.stdout.includes("Advisory matches")) {
pass(testName);
} else {
fail(testName, `Expected exit 42 with advisory match, got ${result.code}: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: --confirm-advisory allows proceeding
// -----------------------------------------------------------------------------
async function testConfirmAdvisoryAllowsProceeding() {
const testName = "guarded_install: --confirm-advisory with --dry-run proceeds";
try {
const keyPair = generateEd25519KeyPair();
const advisoriesDir = await setupSignedFeed(
[
{
id: "TEST-001",
severity: "high",
affected: ["test-skill@1.0.0"],
},
],
keyPair,
);
const result = await runGuardedInstall(
["--skill", "test-skill", "--version", "1.0.0", "--confirm-advisory", "--dry-run"],
{
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
CLAWSEC_FEED_URL: "file:///nonexistent",
},
);
if (result.code === 0 && result.stdout.includes("Dry run")) {
pass(testName);
} else {
fail(testName, `Expected exit 0 with dry run message, got ${result.code}: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: allowUnsigned bypass warning
// -----------------------------------------------------------------------------
async function testAllowUnsignedWarning() {
const testName = "guarded_install: CLAWSEC_ALLOW_UNSIGNED_FEED shows warning";
try {
// Create unsigned feed (no signatures)
const feedContent = createFeed([]);
const feedPath = path.join(tempDir, "unsigned-feed.json");
await fs.writeFile(feedPath, feedContent);
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
CLAWSEC_LOCAL_FEED: feedPath,
CLAWSEC_ALLOW_UNSIGNED_FEED: "1",
CLAWSEC_VERIFY_CHECKSUM_MANIFEST: "0",
CLAWSEC_FEED_URL: "file:///nonexistent",
});
if (result.stderr.includes("CLAWSEC_ALLOW_UNSIGNED_FEED")) {
pass(testName);
} else {
fail(testName, `Expected unsigned mode warning, got: ${result.stderr}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Missing signature fails without allowUnsigned
// -----------------------------------------------------------------------------
async function testMissingSignatureFails() {
const testName = "guarded_install: missing signature fails without allowUnsigned";
try {
const feedContent = createFeed([]);
const feedPath = path.join(tempDir, "nosig-feed.json");
await fs.writeFile(feedPath, feedContent);
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
CLAWSEC_LOCAL_FEED: feedPath,
CLAWSEC_FEED_URL: "file:///nonexistent",
});
if (result.code === 1) {
pass(testName);
} else {
fail(testName, `Expected exit 1 for missing signature, got ${result.code}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: $HOME path expansion for local feed paths
// -----------------------------------------------------------------------------
async function testHomeExpansionForLocalFeedPaths() {
const testName = "guarded_install: expands $HOME in local feed env paths";
try {
const keyPair = generateEd25519KeyPair();
await setupSignedFeed([], keyPair);
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
HOME: tempDir,
CLAWSEC_LOCAL_FEED: "$HOME/advisories/feed.json",
CLAWSEC_LOCAL_FEED_SIG: "$HOME/advisories/feed.json.sig",
CLAWSEC_LOCAL_FEED_CHECKSUMS: "$HOME/advisories/checksums.json",
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: "$HOME/advisories/checksums.json.sig",
CLAWSEC_FEED_PUBLIC_KEY: "$HOME/advisories/feed-signing-public.pem",
CLAWSEC_FEED_URL: "file:///nonexistent",
});
if (result.code === 0 && result.stdout.includes("Advisory source: local:")) {
pass(testName);
} else {
fail(testName, `Expected local feed success, got ${result.code}: ${result.stdout} ${result.stderr}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: escaped home token is rejected
// -----------------------------------------------------------------------------
async function testEscapedHomeTokenRejected() {
const testName = "guarded_install: escaped $HOME token is rejected";
try {
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
CLAWSEC_LOCAL_FEED: "\\$HOME/advisories/feed.json",
});
if (result.code === 1 && result.stderr.includes("Unexpanded home token")) {
pass(testName);
} else {
fail(testName, `Expected token validation error, got ${result.code}: ${result.stderr || result.stdout}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
async function runTests() {
console.log("=== ClawSec Guarded Install Tests ===\n");
await setupTestDir();
try {
await testConservativeMatchingWithoutVersion();
await testPreciseVersionMatching();
await testVersionMatchTriggersConfirmation();
await testConfirmAdvisoryAllowsProceeding();
await testAllowUnsignedWarning();
await testMissingSignatureFails();
await testHomeExpansionForLocalFeedPaths();
await testEscapedHomeTokenRejected();
} finally {
await cleanupTestDir();
}
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
if (failCount > 0) {
process.exit(1);
}
}
runTests().catch((error) => {
console.error("Test runner failed:", error);
process.exit(1);
});