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:
davida-ps
2026-03-09 19:30:22 +02:00
committed by GitHub
parent 19b53609c1
commit 81c2e60513
12 changed files with 394 additions and 111 deletions
+92 -2
View File
@@ -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: |
+14
View File
@@ -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
+2
View File
@@ -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
+2 -1
View File
@@ -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`
+10 -17
View File
@@ -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,57 +141,27 @@ 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
return {
valid: false,
signer: null,
packageHash: '',
verifiedAt: new Date().toISOString(),
algorithm: 'Ed25519',
error: `Signature file not found: ${signaturePath}`
};
}
}
// Load public key (either custom or pinned)
// Load pinned ClawSec key only
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,
@@ -129,9 +172,9 @@ export class SkillSignatureVerifier {
error: `Public key file not found: ${this.publicKeyPath}`
};
}
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
);
-2
View File
@@ -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 -1
View File
@@ -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');
});