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
|
permissions: read-all
|
||||||
|
|
||||||
|
env:
|
||||||
|
CLAWHUB_CLI_VERSION: 0.7.0
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: skill-release-${{ github.ref }}
|
group: skill-release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
@@ -1006,7 +1009,51 @@ jobs:
|
|||||||
|
|
||||||
- name: Install clawhub CLI
|
- name: Install clawhub CLI
|
||||||
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
|
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
|
- name: Login to ClawHub
|
||||||
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
|
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
|
||||||
@@ -1117,7 +1164,50 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Install clawhub CLI
|
- 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
|
- name: Login to ClawHub
|
||||||
run: |
|
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/),
|
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).
|
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
|
## [0.0.2] - 2026-02-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ From within a NanoClaw agent session, the following tools should be available:
|
|||||||
|
|
||||||
**Signature Verification** (mcp-tools/signature-verification.ts):
|
**Signature Verification** (mcp-tools/signature-verification.ts):
|
||||||
- `clawsec_verify_skill_package` - Verify Ed25519 signature on skill packages
|
- `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):
|
**Integrity Monitoring** (mcp-tools/integrity-tools.ts):
|
||||||
- `clawsec_check_integrity` - Check protected files for unauthorized changes
|
- `clawsec_check_integrity` - Check protected files for unauthorized changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: clawsec-nanoclaw
|
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
|
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)
|
**Update Frequency**: Every 6 hours (automatic)
|
||||||
|
|
||||||
**Signature Verification**: Ed25519 signed feeds
|
**Signature Verification**: Ed25519 signed feeds
|
||||||
|
**Package Verification Policy**: pinned key only, bounded package/signature paths
|
||||||
|
|
||||||
**Cache Location**: `/workspace/project/data/clawsec-advisory-cache.json`
|
**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`
|
### MCP Tool: `clawsec_verify_skill_package`
|
||||||
|
|
||||||
**Parameters:**
|
**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)
|
- `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:**
|
**Returns:**
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
success: boolean, // Operation completed without errors
|
success: boolean, // Operation completed without errors
|
||||||
valid: boolean, // Signature is cryptographically valid
|
valid: boolean, // Signature is cryptographically valid
|
||||||
recommendation: string, // "install" | "block" | "review"
|
recommendation: string, // "install" | "block" | "review"
|
||||||
signer: string, // "clawsec" or custom signer
|
signer: string, // "clawsec"
|
||||||
algorithm: "Ed25519", // Signature algorithm
|
algorithm: "Ed25519", // Signature algorithm
|
||||||
verifiedAt: string, // ISO timestamp
|
verifiedAt: string, // ISO timestamp
|
||||||
packageInfo: {
|
packageInfo: {
|
||||||
@@ -335,22 +340,10 @@ openssl pkey -pubin -in feed-signing-public.pem -outform DER | \
|
|||||||
# Expected: <will be filled in after key generation>
|
# Expected: <will be filled in after key generation>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Custom Public Keys
|
### Public Key Policy
|
||||||
|
|
||||||
For organizational deployments with custom skill publishers:
|
The verifier always uses the pinned ClawSec public key from this skill package.
|
||||||
|
Runtime public-key overrides are intentionally not supported.
|
||||||
```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.
|
|
||||||
|
|
||||||
### Key Rotation
|
### Key Rotation
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ export class IntegrityMonitor {
|
|||||||
if (target.path) {
|
if (target.path) {
|
||||||
// Direct path
|
// Direct path
|
||||||
targets.push({
|
targets.push({
|
||||||
path: target.path,
|
path: path.resolve(target.path),
|
||||||
mode: target.mode,
|
mode: target.mode,
|
||||||
priority: target.priority
|
priority: target.priority
|
||||||
});
|
});
|
||||||
@@ -336,6 +336,18 @@ export class IntegrityMonitor {
|
|||||||
return targets;
|
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
|
// Baseline Management
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -343,7 +355,7 @@ export class IntegrityMonitor {
|
|||||||
private loadBaselines(): BaselinesManifest {
|
private loadBaselines(): BaselinesManifest {
|
||||||
if (fs.existsSync(this.baselinesPath)) {
|
if (fs.existsSync(this.baselinesPath)) {
|
||||||
const raw = fs.readFileSync(this.baselinesPath, 'utf-8');
|
const raw = fs.readFileSync(this.baselinesPath, 'utf-8');
|
||||||
return JSON.parse(raw);
|
return this.normalizeBaselines(JSON.parse(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -585,37 +597,43 @@ export class IntegrityMonitor {
|
|||||||
throw new Error('Baselines not loaded');
|
throw new Error('Baselines not loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
const normalizedFilePath = path.resolve(filePath);
|
||||||
throw new Error(`File not found: ${filePath}`);
|
|
||||||
|
if (!fs.existsSync(normalizedFilePath)) {
|
||||||
|
throw new Error(`File not found: ${normalizedFilePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
refuseSymlink(filePath);
|
refuseSymlink(normalizedFilePath);
|
||||||
|
|
||||||
const previousSha = this.baselines.files[filePath]?.sha256;
|
const targets = this.resolveTargets();
|
||||||
const currentSha = sha256File(filePath);
|
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
|
// 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 oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : '';
|
||||||
const newText = fs.readFileSync(filePath, 'utf-8');
|
const newText = fs.readFileSync(normalizedFilePath, 'utf-8');
|
||||||
const diff = unifiedDiff(oldText, newText, `approved/${path.basename(filePath)}`, path.basename(filePath));
|
const diff = unifiedDiff(
|
||||||
|
oldText,
|
||||||
|
newText,
|
||||||
|
`approved/${path.basename(normalizedFilePath)}`,
|
||||||
|
path.basename(normalizedFilePath)
|
||||||
|
);
|
||||||
|
|
||||||
const patchPath = path.join(
|
const patchPath = path.join(
|
||||||
this.patchesDir,
|
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);
|
fs.writeFileSync(patchPath, diff);
|
||||||
|
|
||||||
// Update baseline
|
// Update baseline
|
||||||
if (!this.baselines.files[filePath]) {
|
if (!this.baselines.files[normalizedFilePath]) {
|
||||||
// Find mode from policy
|
this.baselines.files[normalizedFilePath] = {
|
||||||
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] = {
|
|
||||||
sha256: currentSha,
|
sha256: currentSha,
|
||||||
approved_at: utcNowIso(),
|
approved_at: utcNowIso(),
|
||||||
approved_by: actor,
|
approved_by: actor,
|
||||||
@@ -623,13 +641,13 @@ export class IntegrityMonitor {
|
|||||||
priority: target.priority
|
priority: target.priority
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.baselines.files[filePath].sha256 = currentSha;
|
this.baselines.files[normalizedFilePath].sha256 = currentSha;
|
||||||
this.baselines.files[filePath].approved_at = utcNowIso();
|
this.baselines.files[normalizedFilePath].approved_at = utcNowIso();
|
||||||
this.baselines.files[filePath].approved_by = actor;
|
this.baselines.files[normalizedFilePath].approved_by = actor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update snapshot
|
// Update snapshot
|
||||||
fs.copyFileSync(filePath, snapshot);
|
fs.copyFileSync(normalizedFilePath, snapshot);
|
||||||
|
|
||||||
// Save and audit
|
// Save and audit
|
||||||
this.saveBaselines();
|
this.saveBaselines();
|
||||||
@@ -639,7 +657,7 @@ export class IntegrityMonitor {
|
|||||||
event: 'approve',
|
event: 'approve',
|
||||||
actor,
|
actor,
|
||||||
note,
|
note,
|
||||||
path: filePath,
|
path: normalizedFilePath,
|
||||||
expected_sha: previousSha,
|
expected_sha: previousSha,
|
||||||
found_sha: currentSha,
|
found_sha: currentSha,
|
||||||
patch_path: patchPath
|
patch_path: patchPath
|
||||||
@@ -656,8 +674,9 @@ export class IntegrityMonitor {
|
|||||||
throw new Error('Baselines not loaded');
|
throw new Error('Baselines not loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = filePath
|
const normalizedFilePath = filePath ? path.resolve(filePath) : null;
|
||||||
? { [filePath]: this.baselines.files[filePath] }
|
const files = normalizedFilePath
|
||||||
|
? { [normalizedFilePath]: this.baselines.files[normalizedFilePath] }
|
||||||
: this.baselines.files;
|
: this.baselines.files;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function handleAdvisoryIpc(
|
|||||||
|
|
||||||
case 'verify_skill_signature': {
|
case 'verify_skill_signature': {
|
||||||
// Skill signature verification (Phase 1)
|
// 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');
|
logger.info({ sourceGroup, requestId, packagePath }, 'Verifying skill signature');
|
||||||
|
|
||||||
@@ -73,8 +73,6 @@ export async function handleAdvisoryIpc(
|
|||||||
const result = await deps.signatureVerifier.verify({
|
const result = await deps.signatureVerifier.verify({
|
||||||
packagePath,
|
packagePath,
|
||||||
signaturePath,
|
signaturePath,
|
||||||
publicKeyPem,
|
|
||||||
allowUnsigned: allowUnsigned || false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeResponse(requestId, {
|
await writeResponse(requestId, {
|
||||||
|
|||||||
@@ -40,8 +40,81 @@ export interface VerificationResult {
|
|||||||
export interface VerifyParams {
|
export interface VerifyParams {
|
||||||
packagePath: string;
|
packagePath: string;
|
||||||
signaturePath: 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 {
|
const {
|
||||||
packagePath,
|
packagePath,
|
||||||
signaturePath,
|
signaturePath,
|
||||||
publicKeyPem,
|
|
||||||
allowUnsigned = false
|
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// Validate package file exists
|
let validatedPackagePath: string;
|
||||||
if (!fs.existsSync(packagePath)) {
|
let validatedSignaturePath: string;
|
||||||
|
try {
|
||||||
|
validatedPackagePath = validatePackagePath(packagePath);
|
||||||
|
validatedSignaturePath = validateSignaturePath(signaturePath);
|
||||||
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
signer: null,
|
signer: null,
|
||||||
packageHash: '',
|
packageHash: '',
|
||||||
verifiedAt: new Date().toISOString(),
|
verifiedAt: new Date().toISOString(),
|
||||||
algorithm: 'Ed25519',
|
algorithm: 'Ed25519',
|
||||||
error: `Package file not found: ${packagePath}`
|
error: error instanceof Error ? error.message : String(error),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check signature file exists
|
// Load pinned ClawSec key only
|
||||||
if (!fs.existsSync(signaturePath)) {
|
let keyPem: string;
|
||||||
if (allowUnsigned) {
|
try {
|
||||||
// Unsigned allowed - compute hash but mark invalid
|
if (!fs.existsSync(this.publicKeyPath)) {
|
||||||
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
|
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
signer: null,
|
signer: null,
|
||||||
packageHash: '',
|
packageHash: '',
|
||||||
verifiedAt: new Date().toISOString(),
|
verifiedAt: new Date().toISOString(),
|
||||||
algorithm: 'Ed25519',
|
algorithm: 'Ed25519',
|
||||||
error: `Signature file not found: ${signaturePath}`
|
error: `Public key file not found: ${this.publicKeyPath}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Load public key (either custom or pinned)
|
keyPem = fs.readFileSync(this.publicKeyPath, 'utf8');
|
||||||
let keyPem: string;
|
loadPublicKey(keyPem); // Validate pinned key
|
||||||
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
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SecurityPolicyError) {
|
if (error instanceof SecurityPolicyError) {
|
||||||
return {
|
return {
|
||||||
@@ -156,7 +199,7 @@ export class SkillSignatureVerifier {
|
|||||||
// Compute package hash (always, for integrity tracking)
|
// Compute package hash (always, for integrity tracking)
|
||||||
let packageHash: string;
|
let packageHash: string;
|
||||||
try {
|
try {
|
||||||
packageHash = sha256File(packagePath);
|
packageHash = sha256File(validatedPackagePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@@ -170,8 +213,8 @@ export class SkillSignatureVerifier {
|
|||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
const verificationResult = verifyDetachedSignatureWithDetails(
|
const verificationResult = verifyDetachedSignatureWithDetails(
|
||||||
packagePath,
|
validatedPackagePath,
|
||||||
signaturePath,
|
validatedSignaturePath,
|
||||||
keyPem
|
keyPem
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -224,8 +224,6 @@ export interface VerifySkillSignatureRequest {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
packagePath: string;
|
packagePath: string;
|
||||||
signaturePath: 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 TASKS_DIR: string;
|
||||||
declare const groupFolder: 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
|
// Result waiting helper
|
||||||
async function waitForResult(requestId: string, timeoutMs: number = 5000): Promise<any> {
|
async function waitForResult(requestId: string, timeoutMs: number = 5000): Promise<any> {
|
||||||
const resultDir = '/workspace/ipc/clawsec_results';
|
const resultDir = '/workspace/ipc/clawsec_results';
|
||||||
@@ -49,10 +98,13 @@ server.tool(
|
|||||||
},
|
},
|
||||||
async (args: { packagePath: string; signaturePath?: string }) => {
|
async (args: { packagePath: string; signaturePath?: string }) => {
|
||||||
const requestId = `verify-signature-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
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
|
try {
|
||||||
if (!fs.existsSync(args.packagePath)) {
|
packagePath = validatePackagePath(args.packagePath);
|
||||||
|
sigPath = validateSignaturePath(args.signaturePath || `${packagePath}.sig`);
|
||||||
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
@@ -60,7 +112,23 @@ server.tool(
|
|||||||
success: false,
|
success: false,
|
||||||
valid: false,
|
valid: false,
|
||||||
recommendation: 'block',
|
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)
|
}, null, 2)
|
||||||
}],
|
}],
|
||||||
isError: true
|
isError: true
|
||||||
@@ -73,7 +141,7 @@ server.tool(
|
|||||||
requestId,
|
requestId,
|
||||||
groupFolder,
|
groupFolder,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
packagePath: args.packagePath,
|
packagePath,
|
||||||
signaturePath: sigPath,
|
signaturePath: sigPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +158,7 @@ server.tool(
|
|||||||
success: false,
|
success: false,
|
||||||
valid: false,
|
valid: false,
|
||||||
recommendation: 'block',
|
recommendation: 'block',
|
||||||
packagePath: args.packagePath,
|
packagePath,
|
||||||
signaturePath: sigPath,
|
signaturePath: sigPath,
|
||||||
error: result.message || 'Verification failed',
|
error: result.message || 'Verification failed',
|
||||||
reason: result.error?.code || 'UNKNOWN_ERROR'
|
reason: result.error?.code || 'UNKNOWN_ERROR'
|
||||||
@@ -109,7 +177,7 @@ server.tool(
|
|||||||
success: true,
|
success: true,
|
||||||
valid: false,
|
valid: false,
|
||||||
recommendation: 'block',
|
recommendation: 'block',
|
||||||
packagePath: args.packagePath,
|
packagePath,
|
||||||
signaturePath: sigPath,
|
signaturePath: sigPath,
|
||||||
reason: result.data?.error || 'Signature verification failed',
|
reason: result.data?.error || 'Signature verification failed',
|
||||||
packageInfo: {
|
packageInfo: {
|
||||||
@@ -128,13 +196,13 @@ server.tool(
|
|||||||
success: true,
|
success: true,
|
||||||
valid: true,
|
valid: true,
|
||||||
recommendation: 'install',
|
recommendation: 'install',
|
||||||
packagePath: args.packagePath,
|
packagePath,
|
||||||
signaturePath: sigPath,
|
signaturePath: sigPath,
|
||||||
signer: result.data.signer,
|
signer: result.data.signer,
|
||||||
algorithm: result.data.algorithm,
|
algorithm: result.data.algorithm,
|
||||||
verifiedAt: result.data.verifiedAt,
|
verifiedAt: result.data.verifiedAt,
|
||||||
packageInfo: {
|
packageInfo: {
|
||||||
size: fs.statSync(args.packagePath).size,
|
size: fs.statSync(packagePath).size,
|
||||||
sha256: result.data.packageHash
|
sha256: result.data.packageHash
|
||||||
}
|
}
|
||||||
}, null, 2)
|
}, null, 2)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawsec-nanoclaw",
|
"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",
|
"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",
|
"author": "prompt-security",
|
||||||
"license": "AGPL-3.0-or-later",
|
"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