mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
fix(ci): temporary clawhub publish workaround for MIT-0 consent (#117)
* fix(ci): patch clawhub publish payload for temporary MIT-0 consent workaround * fix(ci): make clawhub publish patch self-contained for tag republish * fix(clawsec-nanoclaw): harden signature verification boundaries * chore(clawsec-nanoclaw): bump version to 0.0.3 * fix(clawsec-nanoclaw): normalize integrity policy and baseline paths
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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: <will be filled in after key generation>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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<string, FileBaseline> = {};
|
||||
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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<any> {
|
||||
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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user