diff --git a/.github/workflows/skill-release.yml b/.github/workflows/skill-release.yml index ab8d4f0..678903a 100644 --- a/.github/workflows/skill-release.yml +++ b/.github/workflows/skill-release.yml @@ -17,6 +17,9 @@ on: permissions: read-all +env: + CLAWHUB_CLI_VERSION: 0.7.0 + concurrency: group: skill-release-${{ github.ref }} cancel-in-progress: false @@ -1006,7 +1009,51 @@ jobs: - name: Install clawhub CLI if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != '' - run: npm install -g clawhub@0.7.0 + run: npm install -g clawhub@${CLAWHUB_CLI_VERSION} + + - name: Patch clawhub publish payload workaround + # Temporary: clawhub@0.7.0 publish payload is missing acceptLicenseTerms. + if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != '' + run: | + node <<'NODE' + const { execSync } = require("node:child_process"); + const fs = require("node:fs"); + const path = require("node:path"); + + const npmRoot = execSync("npm root -g", { encoding: "utf8" }).trim(); + const publishScriptPath = path.join( + npmRoot, + "clawhub", + "dist", + "cli", + "commands", + "publish.js" + ); + + if (!fs.existsSync(publishScriptPath)) { + throw new Error(`clawhub publish script not found: ${publishScriptPath}`); + } + + const original = fs.readFileSync(publishScriptPath, "utf8"); + if (original.includes("acceptLicenseTerms: true")) { + console.log(`[patch-clawhub] Already patched: ${publishScriptPath}`); + process.exit(0); + } + + const payloadPattern = /changelog,\r?\n(\s*)tags,/; + if (!payloadPattern.test(original)) { + throw new Error( + `[patch-clawhub] Could not find expected publish payload pattern in ${publishScriptPath}` + ); + } + + const patched = original.replace( + payloadPattern, + (_, indent) => `changelog,\n${indent}acceptLicenseTerms: true,\n${indent}tags,` + ); + fs.writeFileSync(publishScriptPath, patched, "utf8"); + console.log(`[patch-clawhub] Patched: ${publishScriptPath}`); + NODE - name: Login to ClawHub if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != '' @@ -1117,7 +1164,50 @@ jobs: node-version: 20 - name: Install clawhub CLI - run: npm install -g clawhub@0.7.0 + run: npm install -g clawhub@${CLAWHUB_CLI_VERSION} + + - name: Patch clawhub publish payload workaround + # Temporary: clawhub@0.7.0 publish payload is missing acceptLicenseTerms. + run: | + node <<'NODE' + const { execSync } = require("node:child_process"); + const fs = require("node:fs"); + const path = require("node:path"); + + const npmRoot = execSync("npm root -g", { encoding: "utf8" }).trim(); + const publishScriptPath = path.join( + npmRoot, + "clawhub", + "dist", + "cli", + "commands", + "publish.js" + ); + + if (!fs.existsSync(publishScriptPath)) { + throw new Error(`clawhub publish script not found: ${publishScriptPath}`); + } + + const original = fs.readFileSync(publishScriptPath, "utf8"); + if (original.includes("acceptLicenseTerms: true")) { + console.log(`[patch-clawhub] Already patched: ${publishScriptPath}`); + process.exit(0); + } + + const payloadPattern = /changelog,\r?\n(\s*)tags,/; + if (!payloadPattern.test(original)) { + throw new Error( + `[patch-clawhub] Could not find expected publish payload pattern in ${publishScriptPath}` + ); + } + + const patched = original.replace( + payloadPattern, + (_, indent) => `changelog,\n${indent}acceptLicenseTerms: true,\n${indent}tags,` + ); + fs.writeFileSync(publishScriptPath, patched, "utf8"); + console.log(`[patch-clawhub] Patched: ${publishScriptPath}`); + NODE - name: Login to ClawHub run: | diff --git a/skills/clawsec-nanoclaw/CHANGELOG.md b/skills/clawsec-nanoclaw/CHANGELOG.md index 5645a87..fdc1e7b 100644 --- a/skills/clawsec-nanoclaw/CHANGELOG.md +++ b/skills/clawsec-nanoclaw/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to the ClawSec NanoClaw compatibility skill will be document The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.3] - 2026-03-09 + +### Security + +- Removed runtime public-key override from host-side package signature verification; verification now always uses the pinned ClawSec key. +- Removed unsigned-package override path in host-side verification flow. +- Added strict package/signature path policy for signature verification (`/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads`) with absolute-path, extension, symlink, and realpath boundary checks. +- Added policy-bound path enforcement for integrity approvals: approvals now require normalized paths that are explicitly present in non-ignored integrity policy targets. + +### Changed + +- Updated MCP signature verification tool docs and behavior to align with bounded path policy and pinned-key-only verification. +- Added regression tests for signature-verification and integrity-approval hardening invariants. + ## [0.0.2] - 2026-02-28 ### Added diff --git a/skills/clawsec-nanoclaw/INSTALL.md b/skills/clawsec-nanoclaw/INSTALL.md index 74033e5..056f511 100644 --- a/skills/clawsec-nanoclaw/INSTALL.md +++ b/skills/clawsec-nanoclaw/INSTALL.md @@ -140,6 +140,8 @@ From within a NanoClaw agent session, the following tools should be available: **Signature Verification** (mcp-tools/signature-verification.ts): - `clawsec_verify_skill_package` - Verify Ed25519 signature on skill packages + - Uses pinned ClawSec public key (no runtime key override) + - Accepts staged package/signature paths only under `/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads` **Integrity Monitoring** (mcp-tools/integrity-tools.ts): - `clawsec_check_integrity` - Check protected files for unauthorized changes diff --git a/skills/clawsec-nanoclaw/SKILL.md b/skills/clawsec-nanoclaw/SKILL.md index 44905c4..a0cd185 100644 --- a/skills/clawsec-nanoclaw/SKILL.md +++ b/skills/clawsec-nanoclaw/SKILL.md @@ -1,6 +1,6 @@ --- name: clawsec-nanoclaw -version: 0.0.2 +version: 0.0.3 description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot --- @@ -186,6 +186,7 @@ if (advisory.exploitability_score === 'high' || advisory.severity === 'critical' **Update Frequency**: Every 6 hours (automatic) **Signature Verification**: Ed25519 signed feeds +**Package Verification Policy**: pinned key only, bounded package/signature paths **Cache Location**: `/workspace/project/data/clawsec-advisory-cache.json` diff --git a/skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md b/skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md index d8ec513..5719dd4 100644 --- a/skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md +++ b/skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md @@ -130,16 +130,21 @@ console.log('Safe to proceed with installation.'); ### MCP Tool: `clawsec_verify_skill_package` **Parameters:** -- `packagePath` (required): Absolute path to skill package (`.tar.gz` or `.zip`) +- `packagePath` (required): Absolute path to skill package (`.tar.gz`, `.tar`, `.tgz`, or `.zip`) - `signaturePath` (optional): Path to signature file (auto-detects `.sig` if omitted) +Path policy: +- Files must be under one of: `/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads` +- Symlinks are rejected +- Signatures must use `.sig` + **Returns:** ```typescript { success: boolean, // Operation completed without errors valid: boolean, // Signature is cryptographically valid recommendation: string, // "install" | "block" | "review" - signer: string, // "clawsec" or custom signer + signer: string, // "clawsec" algorithm: "Ed25519", // Signature algorithm verifiedAt: string, // ISO timestamp packageInfo: { @@ -335,22 +340,10 @@ openssl pkey -pubin -in feed-signing-public.pem -outform DER | \ # Expected: ``` -### Using Custom Public Keys +### Public Key Policy -For organizational deployments with custom skill publishers: - -```typescript -// Load custom public key -const customPublicKey = fs.readFileSync('/path/to/org-public.pem', 'utf8'); - -// Verify with custom key (not pinned ClawSec key) -const verification = await tools.clawsec_verify_skill_package({ - packagePath: '/tmp/org-skill.tar.gz', - publicKeyPath: '/path/to/org-public.pem' // Custom key -}); -``` - -**Note**: The MCP tool currently uses the pinned key. Custom key support via `publicKeyPem` parameter requires host-side implementation. +The verifier always uses the pinned ClawSec public key from this skill package. +Runtime public-key overrides are intentionally not supported. ### Key Rotation diff --git a/skills/clawsec-nanoclaw/guardian/integrity-monitor.ts b/skills/clawsec-nanoclaw/guardian/integrity-monitor.ts index 723e8ed..b7ba06d 100644 --- a/skills/clawsec-nanoclaw/guardian/integrity-monitor.ts +++ b/skills/clawsec-nanoclaw/guardian/integrity-monitor.ts @@ -312,7 +312,7 @@ export class IntegrityMonitor { if (target.path) { // Direct path targets.push({ - path: target.path, + path: path.resolve(target.path), mode: target.mode, priority: target.priority }); @@ -336,6 +336,18 @@ export class IntegrityMonitor { return targets; } + private normalizeBaselines(manifest: BaselinesManifest): BaselinesManifest { + const normalizedFiles: Record = {}; + for (const [filePath, baseline] of Object.entries(manifest.files || {})) { + normalizedFiles[path.resolve(filePath)] = baseline; + } + + return { + ...manifest, + files: normalizedFiles, + }; + } + // -------------------------------------------------------------------------- // Baseline Management // -------------------------------------------------------------------------- @@ -343,7 +355,7 @@ export class IntegrityMonitor { private loadBaselines(): BaselinesManifest { if (fs.existsSync(this.baselinesPath)) { const raw = fs.readFileSync(this.baselinesPath, 'utf-8'); - return JSON.parse(raw); + return this.normalizeBaselines(JSON.parse(raw)); } return { @@ -585,37 +597,43 @@ export class IntegrityMonitor { throw new Error('Baselines not loaded'); } - if (!fs.existsSync(filePath)) { - throw new Error(`File not found: ${filePath}`); + const normalizedFilePath = path.resolve(filePath); + + if (!fs.existsSync(normalizedFilePath)) { + throw new Error(`File not found: ${normalizedFilePath}`); } - refuseSymlink(filePath); + refuseSymlink(normalizedFilePath); - const previousSha = this.baselines.files[filePath]?.sha256; - const currentSha = sha256File(filePath); + const targets = this.resolveTargets(); + const target = targets.find(t => t.path === normalizedFilePath); + if (!target || target.mode === 'ignore') { + throw new Error(`File ${normalizedFilePath} not in policy`); + } + + const previousSha = this.baselines.files[normalizedFilePath]?.sha256; + const currentSha = sha256File(normalizedFilePath); // Generate diff - const snapshot = path.join(this.approvedDir, path.basename(filePath)); + const snapshot = path.join(this.approvedDir, path.basename(normalizedFilePath)); const oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : ''; - const newText = fs.readFileSync(filePath, 'utf-8'); - const diff = unifiedDiff(oldText, newText, `approved/${path.basename(filePath)}`, path.basename(filePath)); + const newText = fs.readFileSync(normalizedFilePath, 'utf-8'); + const diff = unifiedDiff( + oldText, + newText, + `approved/${path.basename(normalizedFilePath)}`, + path.basename(normalizedFilePath) + ); const patchPath = path.join( this.patchesDir, - `${new Date().toISOString().replace(/[:.]/g, '-')}-approve-${safePatchTag(path.basename(filePath))}.patch` + `${new Date().toISOString().replace(/[:.]/g, '-')}-approve-${safePatchTag(path.basename(normalizedFilePath))}.patch` ); fs.writeFileSync(patchPath, diff); // Update baseline - if (!this.baselines.files[filePath]) { - // Find mode from policy - const targets = this.resolveTargets(); - const target = targets.find(t => t.path === filePath); - if (!target) { - throw new Error(`File ${filePath} not in policy`); - } - - this.baselines.files[filePath] = { + if (!this.baselines.files[normalizedFilePath]) { + this.baselines.files[normalizedFilePath] = { sha256: currentSha, approved_at: utcNowIso(), approved_by: actor, @@ -623,13 +641,13 @@ export class IntegrityMonitor { priority: target.priority }; } else { - this.baselines.files[filePath].sha256 = currentSha; - this.baselines.files[filePath].approved_at = utcNowIso(); - this.baselines.files[filePath].approved_by = actor; + this.baselines.files[normalizedFilePath].sha256 = currentSha; + this.baselines.files[normalizedFilePath].approved_at = utcNowIso(); + this.baselines.files[normalizedFilePath].approved_by = actor; } // Update snapshot - fs.copyFileSync(filePath, snapshot); + fs.copyFileSync(normalizedFilePath, snapshot); // Save and audit this.saveBaselines(); @@ -639,7 +657,7 @@ export class IntegrityMonitor { event: 'approve', actor, note, - path: filePath, + path: normalizedFilePath, expected_sha: previousSha, found_sha: currentSha, patch_path: patchPath @@ -656,8 +674,9 @@ export class IntegrityMonitor { throw new Error('Baselines not loaded'); } - const files = filePath - ? { [filePath]: this.baselines.files[filePath] } + const normalizedFilePath = filePath ? path.resolve(filePath) : null; + const files = normalizedFilePath + ? { [normalizedFilePath]: this.baselines.files[normalizedFilePath] } : this.baselines.files; return { diff --git a/skills/clawsec-nanoclaw/host-services/ipc-handlers.ts b/skills/clawsec-nanoclaw/host-services/ipc-handlers.ts index 7d32d20..e4d86fd 100644 --- a/skills/clawsec-nanoclaw/host-services/ipc-handlers.ts +++ b/skills/clawsec-nanoclaw/host-services/ipc-handlers.ts @@ -61,7 +61,7 @@ export async function handleAdvisoryIpc( case 'verify_skill_signature': { // Skill signature verification (Phase 1) - const { requestId, packagePath, signaturePath, publicKeyPem, allowUnsigned } = task; + const { requestId, packagePath, signaturePath } = task; logger.info({ sourceGroup, requestId, packagePath }, 'Verifying skill signature'); @@ -73,8 +73,6 @@ export async function handleAdvisoryIpc( const result = await deps.signatureVerifier.verify({ packagePath, signaturePath, - publicKeyPem, - allowUnsigned: allowUnsigned || false, }); await writeResponse(requestId, { diff --git a/skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts b/skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts index 7cf1bf1..4c5b648 100644 --- a/skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts +++ b/skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts @@ -40,8 +40,81 @@ export interface VerificationResult { export interface VerifyParams { packagePath: string; signaturePath: string; - publicKeyPem?: string; // Optional override of pinned key - allowUnsigned?: boolean; // Allow missing signature (default: false) +} + +const ALLOWED_PACKAGE_ROOTS = [ + '/tmp', + '/var/tmp', + '/workspace/ipc', + '/workspace/project/data', + '/workspace/project/tmp', + '/workspace/project/downloads', +] as const; + +const ALLOWED_PACKAGE_EXTENSIONS = ['.zip', '.tar', '.tgz', '.tar.gz'] as const; + +function isWithinAllowedRoots(filePath: string): boolean { + return ALLOWED_PACKAGE_ROOTS.some((root) => filePath === root || filePath.startsWith(`${root}/`)); +} + +function hasAllowedPackageExtension(filePath: string): boolean { + return ALLOWED_PACKAGE_EXTENSIONS.some((ext) => filePath.endsWith(ext)); +} + +function normalizeAndValidatePath(rawPath: string, kind: 'package' | 'signature'): string { + if (!path.isAbsolute(rawPath)) { + throw new SecurityPolicyError(`${kind} path must be absolute`); + } + + const resolved = path.resolve(rawPath); + if (!isWithinAllowedRoots(resolved)) { + throw new SecurityPolicyError( + `${kind} path must be under allowed roots: ${ALLOWED_PACKAGE_ROOTS.join(', ')}` + ); + } + + if (kind === 'package' && !hasAllowedPackageExtension(resolved)) { + throw new SecurityPolicyError( + `package path must use one of: ${ALLOWED_PACKAGE_EXTENSIONS.join(', ')}` + ); + } + + if (kind === 'signature' && !resolved.endsWith('.sig')) { + throw new SecurityPolicyError('signature path must end with .sig'); + } + + return resolved; +} + +function ensureExistingRegularFile(filePath: string, kind: 'package' | 'signature'): string { + if (!fs.existsSync(filePath)) { + throw new SecurityPolicyError(`${kind} file not found: ${filePath}`); + } + + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) { + throw new SecurityPolicyError(`${kind} path cannot be a symlink`); + } + if (!stat.isFile()) { + throw new SecurityPolicyError(`${kind} path must be a regular file`); + } + + const realPath = fs.realpathSync(filePath); + if (!isWithinAllowedRoots(realPath)) { + throw new SecurityPolicyError(`${kind} real path escapes allowed roots`); + } + + return realPath; +} + +function validatePackagePath(rawPackagePath: string): string { + const resolved = normalizeAndValidatePath(rawPackagePath, 'package'); + return ensureExistingRegularFile(resolved, 'package'); +} + +function validateSignaturePath(rawSignaturePath: string): string { + const resolved = normalizeAndValidatePath(rawSignaturePath, 'signature'); + return ensureExistingRegularFile(resolved, 'signature'); } /** @@ -68,70 +141,40 @@ export class SkillSignatureVerifier { const { packagePath, signaturePath, - publicKeyPem, - allowUnsigned = false } = params; - // Validate package file exists - if (!fs.existsSync(packagePath)) { + let validatedPackagePath: string; + let validatedSignaturePath: string; + try { + validatedPackagePath = validatePackagePath(packagePath); + validatedSignaturePath = validateSignaturePath(signaturePath); + } catch (error) { return { valid: false, signer: null, packageHash: '', verifiedAt: new Date().toISOString(), algorithm: 'Ed25519', - error: `Package file not found: ${packagePath}` + error: error instanceof Error ? error.message : String(error), }; } - // Check signature file exists - if (!fs.existsSync(signaturePath)) { - if (allowUnsigned) { - // Unsigned allowed - compute hash but mark invalid - const packageHash = sha256File(packagePath); - return { - valid: false, - signer: null, - packageHash, - verifiedAt: new Date().toISOString(), - algorithm: 'Ed25519', - error: 'No signature file found (unsigned package)' - }; - } else { - // Unsigned not allowed - fail + // Load pinned ClawSec key only + let keyPem: string; + try { + if (!fs.existsSync(this.publicKeyPath)) { return { valid: false, signer: null, packageHash: '', verifiedAt: new Date().toISOString(), algorithm: 'Ed25519', - error: `Signature file not found: ${signaturePath}` + error: `Public key file not found: ${this.publicKeyPath}` }; } - } - // Load public key (either custom or pinned) - let keyPem: string; - try { - if (publicKeyPem) { - // Custom key provided - validate format - loadPublicKey(publicKeyPem); // Throws if invalid - keyPem = publicKeyPem; - } else { - // Load pinned ClawSec key - if (!fs.existsSync(this.publicKeyPath)) { - return { - valid: false, - signer: null, - packageHash: '', - verifiedAt: new Date().toISOString(), - algorithm: 'Ed25519', - error: `Public key file not found: ${this.publicKeyPath}` - }; - } - keyPem = fs.readFileSync(this.publicKeyPath, 'utf8'); - loadPublicKey(keyPem); // Validate pinned key - } + keyPem = fs.readFileSync(this.publicKeyPath, 'utf8'); + loadPublicKey(keyPem); // Validate pinned key } catch (error) { if (error instanceof SecurityPolicyError) { return { @@ -156,7 +199,7 @@ export class SkillSignatureVerifier { // Compute package hash (always, for integrity tracking) let packageHash: string; try { - packageHash = sha256File(packagePath); + packageHash = sha256File(validatedPackagePath); } catch (error) { return { valid: false, @@ -170,8 +213,8 @@ export class SkillSignatureVerifier { // Verify signature const verificationResult = verifyDetachedSignatureWithDetails( - packagePath, - signaturePath, + validatedPackagePath, + validatedSignaturePath, keyPem ); diff --git a/skills/clawsec-nanoclaw/lib/types.ts b/skills/clawsec-nanoclaw/lib/types.ts index 8fd068d..bb6afe0 100644 --- a/skills/clawsec-nanoclaw/lib/types.ts +++ b/skills/clawsec-nanoclaw/lib/types.ts @@ -224,8 +224,6 @@ export interface VerifySkillSignatureRequest { timestamp: string; packagePath: string; signaturePath: string; - publicKeyPem?: string; // Optional: override default public key - allowUnsigned?: boolean; // Optional: allow missing signature (default: false) } /** diff --git a/skills/clawsec-nanoclaw/mcp-tools/signature-verification.ts b/skills/clawsec-nanoclaw/mcp-tools/signature-verification.ts index beb91ab..216e818 100644 --- a/skills/clawsec-nanoclaw/mcp-tools/signature-verification.ts +++ b/skills/clawsec-nanoclaw/mcp-tools/signature-verification.ts @@ -18,6 +18,55 @@ declare function writeIpcFile(dir: string, data: any): void; declare const TASKS_DIR: string; declare const groupFolder: string; +const ALLOWED_VERIFICATION_ROOTS = [ + '/tmp', + '/var/tmp', + '/workspace/ipc', + '/workspace/project/data', + '/workspace/project/tmp', + '/workspace/project/downloads', +] as const; + +const ALLOWED_PACKAGE_EXTENSIONS = ['.zip', '.tar', '.tgz', '.tar.gz'] as const; + +function isWithinAllowedRoots(filePath: string): boolean { + return ALLOWED_VERIFICATION_ROOTS.some((root) => filePath === root || filePath.startsWith(`${root}/`)); +} + +function validatePackagePath(rawPath: string): string { + if (!path.isAbsolute(rawPath)) { + throw new Error('packagePath must be absolute'); + } + + const resolved = path.resolve(rawPath); + if (!isWithinAllowedRoots(resolved)) { + throw new Error(`packagePath must be under: ${ALLOWED_VERIFICATION_ROOTS.join(', ')}`); + } + + if (!ALLOWED_PACKAGE_EXTENSIONS.some((ext) => resolved.endsWith(ext))) { + throw new Error(`packagePath must end with one of: ${ALLOWED_PACKAGE_EXTENSIONS.join(', ')}`); + } + + return resolved; +} + +function validateSignaturePath(rawPath: string): string { + if (!path.isAbsolute(rawPath)) { + throw new Error('signaturePath must be absolute'); + } + + const resolved = path.resolve(rawPath); + if (!isWithinAllowedRoots(resolved)) { + throw new Error(`signaturePath must be under: ${ALLOWED_VERIFICATION_ROOTS.join(', ')}`); + } + + if (!resolved.endsWith('.sig')) { + throw new Error('signaturePath must end with .sig'); + } + + return resolved; +} + // Result waiting helper async function waitForResult(requestId: string, timeoutMs: number = 5000): Promise { const resultDir = '/workspace/ipc/clawsec_results'; @@ -49,10 +98,13 @@ server.tool( }, async (args: { packagePath: string; signaturePath?: string }) => { const requestId = `verify-signature-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const sigPath = args.signaturePath || `${args.packagePath}.sig`; + let packagePath: string; + let sigPath: string; - // Validate package file exists - if (!fs.existsSync(args.packagePath)) { + try { + packagePath = validatePackagePath(args.packagePath); + sigPath = validateSignaturePath(args.signaturePath || `${packagePath}.sig`); + } catch (error) { return { content: [{ type: 'text' as const, @@ -60,7 +112,23 @@ server.tool( success: false, valid: false, recommendation: 'block', - error: `Package file not found: ${args.packagePath}` + error: error instanceof Error ? error.message : String(error), + }, null, 2) + }], + isError: true + }; + } + + // Validate package file exists + if (!fs.existsSync(packagePath)) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: false, + valid: false, + recommendation: 'block', + error: `Package file not found: ${packagePath}` }, null, 2) }], isError: true @@ -73,7 +141,7 @@ server.tool( requestId, groupFolder, timestamp: new Date().toISOString(), - packagePath: args.packagePath, + packagePath, signaturePath: sigPath, }); @@ -90,7 +158,7 @@ server.tool( success: false, valid: false, recommendation: 'block', - packagePath: args.packagePath, + packagePath, signaturePath: sigPath, error: result.message || 'Verification failed', reason: result.error?.code || 'UNKNOWN_ERROR' @@ -109,7 +177,7 @@ server.tool( success: true, valid: false, recommendation: 'block', - packagePath: args.packagePath, + packagePath, signaturePath: sigPath, reason: result.data?.error || 'Signature verification failed', packageInfo: { @@ -128,13 +196,13 @@ server.tool( success: true, valid: true, recommendation: 'install', - packagePath: args.packagePath, + packagePath, signaturePath: sigPath, signer: result.data.signer, algorithm: result.data.algorithm, verifiedAt: result.data.verifiedAt, packageInfo: { - size: fs.statSync(args.packagePath).size, + size: fs.statSync(packagePath).size, sha256: result.data.packageHash } }, null, 2) diff --git a/skills/clawsec-nanoclaw/skill.json b/skills/clawsec-nanoclaw/skill.json index 62d55a5..81f68b5 100644 --- a/skills/clawsec-nanoclaw/skill.json +++ b/skills/clawsec-nanoclaw/skill.json @@ -1,6 +1,6 @@ { "name": "clawsec-nanoclaw", - "version": "0.0.2", + "version": "0.0.3", "description": "ClawSec security suite for NanoClaw - Advisory feed monitoring, MCP tools for vulnerability checking, and Ed25519 signature verification for containerized WhatsApp bot agents", "author": "prompt-security", "license": "AGPL-3.0-or-later", diff --git a/skills/clawsec-nanoclaw/test/security-hardening.test.mjs b/skills/clawsec-nanoclaw/test/security-hardening.test.mjs new file mode 100644 index 0000000..867a4e1 --- /dev/null +++ b/skills/clawsec-nanoclaw/test/security-hardening.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const SKILL_ROOT = path.resolve(__dirname, '..'); + +function readSkillFile(relativePath) { + return fs.readFileSync(path.join(SKILL_ROOT, relativePath), 'utf8'); +} + +test('signature verifier enforces pinned key and path policy', () => { + const source = readSkillFile('host-services/skill-signature-handler.ts'); + + assert.ok(!source.includes('publicKeyPem?: string'), 'publicKeyPem override must be removed'); + assert.ok(!source.includes('allowUnsigned?: boolean'), 'allowUnsigned override must be removed'); + + assert.ok(source.includes('const ALLOWED_PACKAGE_ROOTS'), 'must define allowed package roots'); + assert.ok(source.includes('validatePackagePath('), 'must validate package path before hashing'); + assert.ok(source.includes('validateSignaturePath('), 'must validate signature path before verification'); +}); + +test('IPC advisory handler does not forward key or unsigned overrides', () => { + const source = readSkillFile('host-services/ipc-handlers.ts'); + + assert.ok(!source.includes('publicKeyPem'), 'IPC handler must not accept publicKeyPem override'); + assert.ok(!source.includes('allowUnsigned'), 'IPC handler must not accept allowUnsigned override'); +}); + +test('MCP signature tool validates filesystem boundaries', () => { + const source = readSkillFile('mcp-tools/signature-verification.ts'); + + assert.ok(source.includes('const ALLOWED_VERIFICATION_ROOTS'), 'must define allowed verification roots'); + assert.ok(source.includes('validatePackagePath('), 'must validate package path in MCP layer'); + assert.ok(source.includes('validateSignaturePath('), 'must validate signature path in MCP layer'); +}); + +test('integrity approvals are restricted to policy targets', () => { + const source = readSkillFile('guardian/integrity-monitor.ts'); + + assert.ok(source.includes('const normalizedFilePath = path.resolve(filePath);'), 'must normalize approved path'); + assert.ok( + source.includes("if (!target || target.mode === 'ignore')"), + 'must require approved file to exist in non-ignored policy target list' + ); +}); + +test('integrity targets and baselines use normalized absolute paths', () => { + const source = readSkillFile('guardian/integrity-monitor.ts'); + + assert.ok(source.includes('path: path.resolve(target.path)'), 'resolveTargets must normalize direct target paths'); + assert.ok(source.includes('const normalizedFilePath = path.resolve(filePath);'), 'status/approval lookups must normalize file paths'); + assert.ok(source.includes('normalizedFiles[path.resolve(filePath)] = baseline;'), 'loaded baselines must be normalized to absolute keys'); +});