Enhance suppression mechanism in OpenClaw Audit Watchdog

- Updated README.md to clarify suppression configuration and activation requirements.
- Improved SKILL.md with examples for suppressing known findings.
- Refactored load_suppression_config.mjs to implement opt-in gating for suppressions.
- Modified render_report.mjs to support suppression flag in report generation.
- Enhanced run_audit_and_format.sh and runner.sh scripts to accept --enable-suppressions flag.
- Added test cases for suppression configuration, including validation for enabledFor sentinel and opt-in behavior.
- Introduced new test files for empty and invalid suppression configurations.
This commit is contained in:
David Abutbul
2026-02-16 17:35:15 +02:00
parent 83a520aaf1
commit ac9759e0d7
18 changed files with 1233 additions and 145 deletions
-13
View File
@@ -1019,14 +1019,6 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
"version": "19.2.11",
"integrity": "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
@@ -1731,11 +1723,6 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"peer": true
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+89
View File
@@ -257,6 +257,95 @@ If an advisory indicates a malicious or removal-recommended skill and that skill
The suite hook and heartbeat guidance are intentionally non-destructive by default.
## Advisory Suppression / Allowlist
The advisory guardian pipeline supports opt-in suppression for advisories that have been reviewed and accepted by your security team. This is useful for first-party tooling or advisories that do not apply to your deployment.
### Activation
Advisory suppression requires a single gate: the configuration file must contain `"enabledFor"` with `"advisory"` in the array. No CLI flag is needed -- the sentinel in the config file IS the opt-in gate.
If the `enabledFor` array is missing, empty, or does not include `"advisory"`, all advisories are reported normally.
### Config File Resolution (4-tier)
The advisory guardian resolves the suppression config using the same priority order as the audit pipeline:
1. Explicit `--config <path>` argument
2. `OPENCLAW_AUDIT_CONFIG` environment variable
3. `~/.openclaw/security-audit.json`
4. `.clawsec/allowlist.json`
### Config Format
```json
{
"enabledFor": ["advisory"],
"suppressions": [
{
"checkId": "CVE-2026-25593",
"skill": "clawsec-suite",
"reason": "First-party security tooling — reviewed by security team",
"suppressedAt": "2026-02-15"
},
{
"checkId": "CLAW-2026-0001",
"skill": "example-skill",
"reason": "Advisory does not apply to our deployment configuration",
"suppressedAt": "2026-02-16"
}
]
}
```
### Sentinel Semantics
- `"enabledFor": ["advisory"]` -- only advisory suppression active
- `"enabledFor": ["audit"]` -- only audit suppression active (no effect on advisory pipeline)
- `"enabledFor": ["audit", "advisory"]` -- both pipelines honor suppressions
- Missing or empty `enabledFor` -- no suppression active (safe default)
### Matching Rules
- **checkId:** exact match against the advisory ID (e.g., `CVE-2026-25593` or `CLAW-2026-0001`)
- **skill:** case-insensitive match against the affected skill name from the advisory
- Both fields must match for an advisory to be suppressed
### Required Fields per Suppression Entry
| Field | Description | Example |
|-------|-------------|---------|
| `checkId` | Advisory ID to suppress | `CVE-2026-25593` |
| `skill` | Affected skill name | `clawsec-suite` |
| `reason` | Justification for audit trail (required) | `First-party tooling, reviewed by security team` |
| `suppressedAt` | ISO 8601 date (YYYY-MM-DD) | `2026-02-15` |
### Shared Config with Audit Pipeline
The advisory and audit pipelines share the same config file. Use the `enabledFor` array to control which pipelines honor the suppression list:
```json
{
"enabledFor": ["audit", "advisory"],
"suppressions": [
{
"checkId": "skills.code_safety",
"skill": "clawsec-suite",
"reason": "First-party tooling — audit finding accepted",
"suppressedAt": "2026-02-15"
},
{
"checkId": "CVE-2026-25593",
"skill": "clawsec-suite",
"reason": "First-party tooling — advisory reviewed",
"suppressedAt": "2026-02-15"
}
]
}
```
Audit entries (with check identifiers like `skills.code_safety`) are only matched by the audit pipeline. Advisory entries (with advisory IDs like `CVE-2026-25593` or `CLAW-2026-0001`) are only matched by the advisory pipeline. Each pipeline filters for its own relevant entries.
## Optional Skill Installation
Discover currently available installable skills dynamically, then install the ones you want:
@@ -6,6 +6,7 @@ import { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.m
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
import { loadState, persistState } from "./lib/state.ts";
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
import { loadAdvisorySuppression, isAdvisorySuppressed } from "./lib/suppression.mjs";
const DEFAULT_FEED_URL =
"https://clawsec.prompt.security/advisories/feed.json";
@@ -171,13 +172,33 @@ const handler = async (event: HookEvent): Promise<void> => {
state.known_advisories = uniqueStrings([...state.known_advisories, ...advisoryIds]);
const installedSkills = await discoverInstalledSkills(installRoot);
const matches = findMatches(feed, installedSkills);
const allMatches = findMatches(feed, installedSkills);
if (matches.length === 0) {
if (allMatches.length === 0) {
await persistState(stateFile, state);
return;
}
// Load advisory suppression config (sentinel-gated: requires enabledFor: ["advisory"])
let suppressionConfig;
try {
suppressionConfig = await loadAdvisorySuppression();
} catch (err) {
console.warn(`[clawsec-advisory-guardian] failed to load suppression config: ${String(err)}`);
suppressionConfig = { suppressions: [], enabledFor: [], source: "none" };
}
// Partition matches into active and suppressed
const matches: AdvisoryMatch[] = [];
const suppressedMatches: AdvisoryMatch[] = [];
for (const match of allMatches) {
if (isAdvisorySuppressed(match, suppressionConfig.suppressions)) {
suppressedMatches.push(match);
} else {
matches.push(match);
}
}
const unseenMatches: AdvisoryMatch[] = [];
for (const match of matches) {
const key = matchKey(match);
@@ -188,10 +209,24 @@ const handler = async (event: HookEvent): Promise<void> => {
state.notified_matches[key] = nowIso;
}
// Track suppressed matches in state (so they aren't re-evaluated) but don't alert
for (const match of suppressedMatches) {
const key = matchKey(match);
if (!state.notified_matches[key]) {
state.notified_matches[key] = nowIso;
}
}
if (unseenMatches.length > 0 && Array.isArray(event.messages)) {
event.messages.push(buildAlertMessage(unseenMatches, installRoot));
}
if (suppressedMatches.length > 0 && Array.isArray(event.messages)) {
event.messages.push(
`[clawsec-advisory-guardian] ${suppressedMatches.length} advisory match(es) suppressed by allowlist config.`,
);
}
await persistState(stateFile, state);
};
@@ -0,0 +1,142 @@
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { isObject, normalizeSkillName } from "./utils.mjs";
const DEFAULT_PRIMARY_PATH = path.join(os.homedir(), ".openclaw", "security-audit.json");
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.json";
const EMPTY_CONFIG = Object.freeze({
suppressions: [],
enabledFor: [],
source: "none",
});
/**
* @param {unknown} entry
* @param {number} index
* @param {string} source
* @returns {{ checkId: string, skill: string, reason: string, suppressedAt: string }}
*/
function normalizeRule(entry, index, source) {
if (!isObject(entry)) {
throw new Error(`Suppression entry at index ${index} in ${source} must be an object`);
}
const checkId = typeof entry.checkId === "string" ? entry.checkId.trim() : "";
const skill = typeof entry.skill === "string" ? entry.skill.trim() : "";
const reason = typeof entry.reason === "string" ? entry.reason.trim() : "";
const suppressedAt = typeof entry.suppressedAt === "string" ? entry.suppressedAt.trim() : "";
if (!checkId) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: checkId`);
if (!skill) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: skill`);
if (!reason) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: reason`);
if (!suppressedAt) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: suppressedAt`);
return { checkId, skill, reason, suppressedAt };
}
/**
* @param {unknown} raw
* @param {string} source
* @returns {{ suppressions: Array, enabledFor: string[], source: string }}
*/
function parseConfig(raw, source) {
if (!isObject(raw)) {
throw new Error(`Config at ${source} must be a JSON object`);
}
if (!Array.isArray(raw.suppressions)) {
throw new Error(`Config at ${source} missing 'suppressions' array`);
}
const suppressions = [];
for (let i = 0; i < raw.suppressions.length; i++) {
suppressions.push(normalizeRule(raw.suppressions[i], i, source));
}
const enabledFor = Array.isArray(raw.enabledFor)
? raw.enabledFor
.filter((v) => typeof v === "string" && v.trim() !== "")
.map((v) => v.trim().toLowerCase())
: [];
return { suppressions, enabledFor, source };
}
/**
* @param {string} configPath
* @returns {Promise<{ suppressions: Array, enabledFor: string[], source: string } | null>}
*/
async function loadConfigFromPath(configPath) {
try {
const raw = await fs.readFile(configPath, "utf8");
return parseConfig(JSON.parse(raw), configPath);
} catch (err) {
if (err.code === "ENOENT") return null;
if (err.code === "EACCES") throw new Error(`Permission denied reading config: ${configPath}`, { cause: err });
if (err instanceof SyntaxError) throw new Error(`Malformed JSON in ${configPath}: ${err.message}`, { cause: err });
throw err;
}
}
/**
* Load advisory suppression config using the same 4-tier path resolution
* as the audit watchdog config loader.
*
* The config file must include "advisory" in its enabledFor sentinel
* array for advisory suppression to activate. No CLI flag needed -- the
* sentinel in the config file IS the gate.
*
* @param {string} [configPath] - Optional explicit config file path
* @returns {Promise<{ suppressions: Array, enabledFor: string[], source: string }>}
*/
export async function loadAdvisorySuppression(configPath) {
// Priority 1: Explicit path
if (configPath) {
const config = await loadConfigFromPath(configPath);
if (!config) throw new Error(`Advisory suppression config not found: ${configPath}`);
if (!config.enabledFor.includes("advisory")) return { ...EMPTY_CONFIG };
return config;
}
// Priority 2: Environment variable
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
if (typeof envPath === "string" && envPath.trim()) {
const config = await loadConfigFromPath(envPath.trim());
if (config && config.enabledFor.includes("advisory")) return config;
return { ...EMPTY_CONFIG };
}
// Priority 3: Primary default path
const primary = await loadConfigFromPath(DEFAULT_PRIMARY_PATH);
if (primary && primary.enabledFor.includes("advisory")) return primary;
// Priority 4: Fallback path
const fallback = await loadConfigFromPath(DEFAULT_FALLBACK_PATH);
if (fallback && fallback.enabledFor.includes("advisory")) return fallback;
return { ...EMPTY_CONFIG };
}
/**
* Check if an advisory match should be suppressed.
*
* Matching requires BOTH:
* - advisory.id === rule.checkId (exact)
* - normalizeSkillName(skill.name) === normalizeSkillName(rule.skill) (case-insensitive)
*
* @param {{ advisory: { id?: string }, skill: { name: string } }} match
* @param {Array<{ checkId: string, skill: string }>} suppressions
* @returns {boolean}
*/
export function isAdvisorySuppressed(match, suppressions) {
if (!Array.isArray(suppressions) || suppressions.length === 0) return false;
const advisoryId = match.advisory.id ?? "";
const skillName = normalizeSkillName(match.skill.name);
return suppressions.some(
(rule) => rule.checkId === advisoryId && normalizeSkillName(rule.skill) === skillName,
);
}
@@ -33,6 +33,7 @@ function requireOpenClawCli() {
throw new Error(
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
`Original error: ${String(error)}`,
{ cause: error },
);
}
}
@@ -37,6 +37,7 @@ function requireOpenClawCli() {
throw new Error(
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
`Original error: ${String(error)}`,
{ cause: error },
);
}
}
@@ -0,0 +1,368 @@
#!/usr/bin/env node
/**
* Advisory suppression tests for clawsec-suite.
*
* Tests cover:
* - isAdvisorySuppressed matching logic (exact checkId + normalized skill name)
* - Partial matches do not suppress (checkId only, skill only)
* - Empty suppressions never suppress
* - loadAdvisorySuppression sentinel gating (enabledFor: ["advisory"])
* - Missing sentinel returns empty config
* - Wrong sentinel (only "audit") returns empty config
*
* Run: node skills/clawsec-suite/test/advisory_suppression.test.mjs
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
const { isAdvisorySuppressed, loadAdvisorySuppression } = await import(
`${LIB_PATH}/suppression.mjs`
);
let tempDir;
let passCount = 0;
let failCount = 0;
function pass(name) {
passCount++;
console.log(`\u2713 ${name}`);
}
function fail(name, error) {
failCount++;
console.error(`\u2717 ${name}`);
console.error(` ${String(error)}`);
}
async function setupTestDir() {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "advisory-suppression-test-"));
}
async function cleanupTestDir() {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
function makeMatch(advisoryId, skillName, version = "1.0.0") {
return {
advisory: { id: advisoryId, severity: "high", title: `Advisory ${advisoryId}` },
skill: { name: skillName, dirName: skillName, version },
matchedAffected: [`${skillName}@<=${version}`],
};
}
function makeRules(entries) {
return entries.map(([checkId, skill, reason]) => ({
checkId,
skill,
reason: reason || "Test suppression",
suppressedAt: "2026-02-15",
}));
}
// ---------------------------------------------------------------------------
// isAdvisorySuppressed tests
// ---------------------------------------------------------------------------
async function testExactMatch() {
const testName = "isAdvisorySuppressed: exact match suppresses";
try {
const match = makeMatch("CVE-2026-25593", "clawsec-suite");
const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
if (isAdvisorySuppressed(match, rules) === true) {
pass(testName);
} else {
fail(testName, "Expected suppression but got false");
}
} catch (error) {
fail(testName, error);
}
}
async function testCaseInsensitiveSkillMatch() {
const testName = "isAdvisorySuppressed: case-insensitive skill name match";
try {
const match = makeMatch("CVE-2026-25593", "ClawSec-Suite");
const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
if (isAdvisorySuppressed(match, rules) === true) {
pass(testName);
} else {
fail(testName, "Expected case-insensitive match to suppress");
}
} catch (error) {
fail(testName, error);
}
}
async function testCheckIdMismatch() {
const testName = "isAdvisorySuppressed: checkId mismatch does not suppress";
try {
const match = makeMatch("CVE-2026-99999", "clawsec-suite");
const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
if (isAdvisorySuppressed(match, rules) === false) {
pass(testName);
} else {
fail(testName, "Expected no suppression for mismatched checkId");
}
} catch (error) {
fail(testName, error);
}
}
async function testSkillMismatch() {
const testName = "isAdvisorySuppressed: skill mismatch does not suppress";
try {
const match = makeMatch("CVE-2026-25593", "other-skill");
const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
if (isAdvisorySuppressed(match, rules) === false) {
pass(testName);
} else {
fail(testName, "Expected no suppression for mismatched skill");
}
} catch (error) {
fail(testName, error);
}
}
async function testEmptySuppressions() {
const testName = "isAdvisorySuppressed: empty suppressions never suppress";
try {
const match = makeMatch("CVE-2026-25593", "clawsec-suite");
if (isAdvisorySuppressed(match, []) === false) {
pass(testName);
} else {
fail(testName, "Expected no suppression with empty rules");
}
} catch (error) {
fail(testName, error);
}
}
async function testMultipleRules() {
const testName = "isAdvisorySuppressed: multiple rules match correct one";
try {
const match = makeMatch("CLAW-2026-0001", "openclaw-audit-watchdog");
const rules = makeRules([
["CVE-2026-25593", "clawsec-suite"],
["CLAW-2026-0001", "openclaw-audit-watchdog"],
]);
if (isAdvisorySuppressed(match, rules) === true) {
pass(testName);
} else {
fail(testName, "Expected match against second rule");
}
} catch (error) {
fail(testName, error);
}
}
async function testMissingAdvisoryId() {
const testName = "isAdvisorySuppressed: missing advisory.id does not suppress";
try {
const match = {
advisory: { severity: "high", title: "No ID advisory" },
skill: { name: "clawsec-suite", dirName: "clawsec-suite", version: "1.0.0" },
matchedAffected: [],
};
const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
if (isAdvisorySuppressed(match, rules) === false) {
pass(testName);
} else {
fail(testName, "Expected no suppression when advisory has no id");
}
} catch (error) {
fail(testName, error);
}
}
// ---------------------------------------------------------------------------
// loadAdvisorySuppression tests
// ---------------------------------------------------------------------------
async function testLoadWithAdvisorySentinel() {
const testName = "loadAdvisorySuppression: loads config with advisory sentinel";
try {
const configFile = path.join(tempDir, "advisory-config.json");
await fs.writeFile(configFile, JSON.stringify({
enabledFor: ["advisory"],
suppressions: [{
checkId: "CVE-2026-25593",
skill: "clawsec-suite",
reason: "First-party tooling",
suppressedAt: "2026-02-15",
}],
}));
const config = await loadAdvisorySuppression(configFile);
if (config.suppressions.length === 1 && config.source === configFile) {
pass(testName);
} else {
fail(testName, `Expected 1 suppression from ${configFile}, got: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
}
}
async function testLoadWithMissingSentinel() {
const testName = "loadAdvisorySuppression: missing sentinel returns empty config";
try {
const configFile = path.join(tempDir, "no-sentinel.json");
await fs.writeFile(configFile, JSON.stringify({
suppressions: [{
checkId: "CVE-2026-25593",
skill: "clawsec-suite",
reason: "First-party tooling",
suppressedAt: "2026-02-15",
}],
}));
const config = await loadAdvisorySuppression(configFile);
if (config.suppressions.length === 0) {
pass(testName);
} else {
fail(testName, `Expected empty suppressions without sentinel, got: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
}
}
async function testLoadWithAuditOnlySentinel() {
const testName = "loadAdvisorySuppression: audit-only sentinel returns empty for advisory";
try {
const configFile = path.join(tempDir, "audit-only.json");
await fs.writeFile(configFile, JSON.stringify({
enabledFor: ["audit"],
suppressions: [{
checkId: "CVE-2026-25593",
skill: "clawsec-suite",
reason: "First-party tooling",
suppressedAt: "2026-02-15",
}],
}));
const config = await loadAdvisorySuppression(configFile);
if (config.suppressions.length === 0) {
pass(testName);
} else {
fail(testName, `Expected empty for audit-only sentinel, got: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
}
}
async function testLoadWithBothSentinels() {
const testName = "loadAdvisorySuppression: both audit+advisory sentinels activates advisory";
try {
const configFile = path.join(tempDir, "both-sentinel.json");
await fs.writeFile(configFile, JSON.stringify({
enabledFor: ["audit", "advisory"],
suppressions: [{
checkId: "CVE-2026-25593",
skill: "clawsec-suite",
reason: "First-party tooling",
suppressedAt: "2026-02-15",
}],
}));
const config = await loadAdvisorySuppression(configFile);
if (config.suppressions.length === 1) {
pass(testName);
} else {
fail(testName, `Expected 1 suppression with both sentinels, got: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
}
}
async function testLoadNonexistentExplicitPath() {
const testName = "loadAdvisorySuppression: explicit nonexistent path throws";
try {
await loadAdvisorySuppression(path.join(tempDir, "does-not-exist.json"));
fail(testName, "Expected error for nonexistent explicit path");
} catch (error) {
if (String(error).includes("not found")) {
pass(testName);
} else {
fail(testName, `Unexpected error: ${error}`);
}
}
}
async function testLoadNoConfigReturnsEmpty() {
const testName = "loadAdvisorySuppression: no config available returns empty";
try {
// Clear env var to ensure no ambient config
const savedEnv = process.env.OPENCLAW_AUDIT_CONFIG;
delete process.env.OPENCLAW_AUDIT_CONFIG;
try {
// Call without explicit path and with no env var — falls through to default paths
// which likely don't exist in test environment
const config = await loadAdvisorySuppression();
if (config.suppressions.length === 0 && config.source === "none") {
pass(testName);
} else {
fail(testName, `Expected empty config, got: ${JSON.stringify(config)}`);
}
} finally {
if (savedEnv !== undefined) process.env.OPENCLAW_AUDIT_CONFIG = savedEnv;
else delete process.env.OPENCLAW_AUDIT_CONFIG;
}
} catch (error) {
fail(testName, error);
}
}
// ---------------------------------------------------------------------------
// Main test runner
// ---------------------------------------------------------------------------
async function runAllTests() {
console.log("=== Advisory Suppression Tests ===\n");
await setupTestDir();
try {
// isAdvisorySuppressed tests
await testExactMatch();
await testCaseInsensitiveSkillMatch();
await testCheckIdMismatch();
await testSkillMismatch();
await testEmptySuppressions();
await testMultipleRules();
await testMissingAdvisoryId();
// loadAdvisorySuppression tests
await testLoadWithAdvisorySentinel();
await testLoadWithMissingSentinel();
await testLoadWithAuditOnlySentinel();
await testLoadWithBothSentinels();
await testLoadNonexistentExplicitPath();
await testLoadNoConfigReturnsEmpty();
} finally {
await cleanupTestDir();
}
console.log("");
console.log(`=== Results: ${passCount} passed, ${failCount} failed ===`);
if (failCount > 0) {
process.exit(1);
}
}
runAllTests().catch((err) => {
console.error("Test runner failed:", err);
process.exit(1);
});
+52 -21
View File
@@ -39,23 +39,35 @@ export PROMPTSEC_HOST_LABEL="prod-agent-1"
| `PROMPTSEC_GIT_PULL` | Pull latest before audit (0/1) | `0` |
| `OPENCLAW_AUDIT_CONFIG` | Path to suppression config file | Auto-detected |
## Suppression Configuration
## Suppression / Allowlist
Manage false-positive findings with the built-in suppression mechanism. Suppressed findings remain visible in reports but don't count toward critical/warning totals.
Manage false-positive findings with the built-in suppression mechanism. Suppressed findings remain visible in reports but are demoted to informational status and do not count toward critical/warning totals.
### Config File Location
Suppression is **opt-in with defense in depth**: the audit pipeline requires BOTH a CLI flag AND a config-file sentinel before any finding is suppressed. This prevents accidental or unauthorized suppression.
The audit scanner checks these locations (in priority order):
### Activation (Two Gates)
1. `--config` flag argument
Both of the following must be true for audit suppressions to take effect:
1. **CLI flag:** Pass `--enable-suppressions` when invoking the runner.
2. **Config sentinel:** The configuration file must contain `"enabledFor": ["audit"]` (or a list that includes `"audit"`).
If either gate is missing, the suppression list is ignored entirely and all findings are reported normally.
### Config File Resolution
The audit scanner resolves the suppression config file using this 4-tier priority:
1. `--config <path>` CLI argument (highest priority)
2. `OPENCLAW_AUDIT_CONFIG` environment variable
3. `~/.openclaw/security-audit.json` (primary)
3. `~/.openclaw/security-audit.json`
4. `.clawsec/allowlist.json` (fallback)
### Example Configuration
```json
{
"enabledFor": ["audit"],
"suppressions": [
{
"checkId": "skills.code_safety",
@@ -73,30 +85,51 @@ The audit scanner checks these locations (in priority order):
}
```
### Required Fields
The `enabledFor` array controls which pipelines honor the suppression list:
- **checkId**: Security check identifier (e.g., `skills.code_safety`)
- **skill**: Exact skill name to suppress
- **reason**: Justification for audit trail (required)
- **suppressedAt**: ISO 8601 date (YYYY-MM-DD)
| Value | Effect |
|-------|--------|
| `["audit"]` | Only audit suppression active (still requires `--enable-suppressions` flag) |
| `["advisory"]` | Only advisory suppression active (used by clawsec-suite) |
| `["audit", "advisory"]` | Both pipelines honor suppressions |
| Missing or `[]` | No suppression in any pipeline (safe default) |
### Required Fields per Suppression Entry
| Field | Description | Example |
|-------|-------------|---------|
| `checkId` | Audit check identifier to suppress | `skills.code_safety` |
| `skill` | Skill name the suppression applies to | `clawsec-suite` |
| `reason` | Justification for audit trail (required) | `First-party tooling, reviewed by security team` |
| `suppressedAt` | ISO 8601 date (YYYY-MM-DD) | `2026-02-15` |
**Matching:** Suppression requires an exact `checkId` match and a case-insensitive `skill` name match. Both must match for a finding to be suppressed.
### Usage
```bash
# Use default config location
./scripts/runner.sh
# Enable suppressions with default config location
./scripts/runner.sh --enable-suppressions
# Specify custom config
./scripts/runner.sh --config /path/to/config.json
# Enable suppressions with explicit config path
./scripts/runner.sh --enable-suppressions --config /path/to/config.json
# Or set via environment
# Enable suppressions with config via environment variable
export OPENCLAW_AUDIT_CONFIG=~/.openclaw/custom-audit.json
./scripts/runner.sh --enable-suppressions
```
Without `--enable-suppressions`, the config file is not consulted for suppressions:
```bash
# Suppressions NOT active (flag missing)
./scripts/runner.sh
./scripts/runner.sh --config /path/to/config.json
```
### Report Output
Suppressed findings appear in a separate section:
Suppressed findings appear in a separate informational section:
```
CRITICAL (0):
@@ -106,14 +139,12 @@ WARNINGS (1):
[skills.network] some-skill: Unrestricted network access
INFO - SUPPRESSED (2):
[skills.code_safety] clawsec-suite: dangerous-exec detected
[skills.code_safety] clawsec-suite: dangerous-exec detected
Reason: First-party security tooling, reviewed 2026-02-13
[skills.permissions] my-tool: Broad permission scope
[skills.permissions] my-tool: Broad permission scope
Reason: Validated by security team, suppressedAt 2026-02-16
```
**Important**: Suppressions require BOTH `checkId` AND `skill` to match. This prevents over-suppression and maintains audit integrity.
See `examples/security-audit-config.example.json` for a complete template.
## Scripts
+73
View File
@@ -184,6 +184,79 @@ export PROMPTSEC_DM_TO="@oncall"
Each will send reports with clear host identification.
### Example 7: Suppressing Known Findings
To suppress audit findings that have been reviewed and accepted, pass the `--enable-suppressions` flag and ensure the config file includes the `"enabledFor": ["audit"]` sentinel:
```bash
# Create or edit the suppression config
cat > ~/.openclaw/security-audit.json <<'JSON'
{
"enabledFor": ["audit"],
"suppressions": [
{
"checkId": "skills.code_safety",
"skill": "clawsec-suite",
"reason": "First-party security tooling — reviewed by security team",
"suppressedAt": "2026-02-15"
}
]
}
JSON
# Run with suppressions enabled
/openclaw-audit-watchdog --enable-suppressions
```
Suppressed findings still appear in the report under an informational section but are excluded from critical/warning totals.
## Suppression / Allowlist
The audit pipeline supports an opt-in suppression mechanism for managing reviewed findings. Suppression uses defense-in-depth activation: two independent gates must both be satisfied.
### Activation Requirements
1. **CLI flag:** The `--enable-suppressions` flag must be passed at invocation.
2. **Config sentinel:** The configuration file must include `"enabledFor"` with `"audit"` in the array.
If either gate is absent, all findings are reported normally and the suppression list is ignored.
### Config File Resolution (4-tier)
1. Explicit `--config <path>` argument
2. `OPENCLAW_AUDIT_CONFIG` environment variable
3. `~/.openclaw/security-audit.json`
4. `.clawsec/allowlist.json`
### Config Format
```json
{
"enabledFor": ["audit"],
"suppressions": [
{
"checkId": "skills.code_safety",
"skill": "clawsec-suite",
"reason": "First-party security tooling — reviewed by security team",
"suppressedAt": "2026-02-15"
}
]
}
```
### Sentinel Semantics
- `"enabledFor": ["audit"]` -- audit suppression active (requires `--enable-suppressions` flag too)
- `"enabledFor": ["advisory"]` -- only advisory pipeline suppression (no effect on audit)
- `"enabledFor": ["audit", "advisory"]` -- both pipelines honor suppressions
- Missing or empty `enabledFor` -- no suppression active (safe default)
### Matching Rules
- **checkId:** exact match against the audit finding's check identifier (e.g., `skills.code_safety`)
- **skill:** case-insensitive match against the skill name from the finding
- Both fields must match for a finding to be suppressed
## Installation flow (interactive)
Provisioning (MDM-friendly): prefer environment variables (no prompts).
@@ -84,12 +84,18 @@ function normalizeSuppressionConfig(payload, source) {
const normalized = validateSuppression(rawSuppressions[i], i);
suppressions.push(normalized);
} catch (err) {
throw new Error(`Invalid suppression at index ${i} in ${source}: ${err.message}`);
throw new Error(`Invalid suppression at index ${i} in ${source}: ${err.message}`, { cause: err });
}
}
// Extract enabledFor sentinel (array of pipeline names this config activates for)
const enabledFor = Array.isArray(payload.enabledFor)
? payload.enabledFor.filter((v) => typeof v === "string" && v.trim() !== "").map((v) => v.trim().toLowerCase())
: [];
return {
suppressions,
enabledFor,
source,
};
}
@@ -105,29 +111,23 @@ async function loadConfigFromPath(configPath) {
return null;
}
if (err.code === "EACCES") {
throw new Error(`Permission denied reading config file: ${configPath}`);
throw new Error(`Permission denied reading config file: ${configPath}`, { cause: err });
}
if (err instanceof SyntaxError) {
throw new Error(`Malformed JSON in config file ${configPath}: ${err.message}`);
throw new Error(`Malformed JSON in config file ${configPath}: ${err.message}`, { cause: err });
}
// Re-throw validation errors or other errors
throw err;
}
}
const EMPTY_RESULT = Object.freeze({ suppressions: [], source: "none" });
/**
* Load suppression configuration with multi-path fallback.
*
* Behavior:
* - Checks primary path: ~/.openclaw/security-audit.json (or OPENCLAW_AUDIT_CONFIG env var)
* - Falls back to: .clawsec/allowlist.json
* - Returns empty suppressions array if no config found
* - Throws on malformed JSON or validation errors
*
* @param {string} [customPath] - Optional custom config file path
* @returns {Promise<{suppressions: Array, source: string}>}
* Resolve config from the 4-tier priority chain.
* Returns the loaded config or null if no config found.
*/
export async function loadSuppressionConfig(customPath = null) {
async function resolveConfig(customPath) {
// Priority 1: Custom path provided as argument
if (customPath) {
const config = await loadConfigFromPath(customPath);
@@ -148,35 +148,70 @@ export async function loadSuppressionConfig(customPath = null) {
}
// Priority 3: Primary default path
const primaryPath = DEFAULT_PRIMARY_PATH;
const primaryConfig = await loadConfigFromPath(primaryPath);
if (primaryConfig) {
return primaryConfig;
}
const primaryConfig = await loadConfigFromPath(DEFAULT_PRIMARY_PATH);
if (primaryConfig) return primaryConfig;
// Priority 4: Fallback path
const fallbackPath = DEFAULT_FALLBACK_PATH;
const fallbackConfig = await loadConfigFromPath(fallbackPath);
if (fallbackConfig) {
return fallbackConfig;
const fallbackConfig = await loadConfigFromPath(DEFAULT_FALLBACK_PATH);
if (fallbackConfig) return fallbackConfig;
return null;
}
/**
* Load suppression configuration with multi-path fallback and opt-in gating.
*
* Suppression requires explicit opt-in to prevent ambient activation:
* 1. The `enabled` flag must be true (set via --enable-suppressions CLI flag)
* 2. The config file must contain an `enabledFor` array including "audit"
*
* Without both gates, returns empty suppressions.
*
* @param {string} [customPath] - Optional custom config file path
* @param {object} [options]
* @param {boolean} [options.enabled=false] - Whether suppression is explicitly enabled
* @param {string} [options.pipeline="audit"] - Pipeline to check in enabledFor sentinel
* @returns {Promise<{suppressions: Array, source: string}>}
*/
export async function loadSuppressionConfig(customPath = null, { enabled = false, pipeline = "audit" } = {}) {
// Gate 1: suppression must be explicitly opted-in via CLI flag
if (!enabled) {
return EMPTY_RESULT;
}
// No config found - return empty suppressions (graceful fallback)
return {
suppressions: [],
source: "none",
};
const config = await resolveConfig(customPath);
if (!config) {
return EMPTY_RESULT;
}
// Gate 2: config must declare this pipeline in enabledFor sentinel
if (!Array.isArray(config.enabledFor) || !config.enabledFor.includes(pipeline)) {
return EMPTY_RESULT;
}
process.stderr.write(
`WARNING: Suppression mechanism is enabled for "${pipeline}" pipeline via --enable-suppressions flag.\n`
);
return config;
}
// CLI usage when run directly
if (import.meta.url === `file://${process.argv[1]}`) {
const customPath = process.argv[2];
const args = process.argv.slice(2);
const enableFlag = args.includes("--enable-suppressions");
const customPath = args.find((a) => !a.startsWith("--")) || null;
if (!enableFlag) {
process.stdout.write("Suppression is disabled. Pass --enable-suppressions to activate.\n");
process.exit(0);
}
try {
const config = await loadSuppressionConfig(customPath || null);
const config = await loadSuppressionConfig(customPath, { enabled: true });
if (config.suppressions.length === 0) {
process.stdout.write("No suppression config found - graceful fallback to empty suppressions\n");
process.stdout.write("No active suppressions (config missing, no enabledFor sentinel, or empty)\n");
process.stdout.write(JSON.stringify(config, null, 2) + "\n");
process.exit(0);
}
@@ -3,7 +3,7 @@
* Render a human-readable security audit report from openclaw JSON.
*
* Usage:
* node render_report.mjs --audit audit.json --deep deep.json --label "host label" [--config config.json]
* node render_report.mjs --audit audit.json --deep deep.json --label "host label" [--enable-suppressions] [--config config.json]
*/
import fs from "node:fs";
@@ -194,6 +194,7 @@ function parseArgs(argv) {
else if (a === "--deep") out.deep = argv[++i];
else if (a === "--label") out.label = argv[++i];
else if (a === "--config") out.config = argv[++i];
else if (a === "--enable-suppressions") out.enableSuppressions = true;
}
return out;
}
@@ -201,8 +202,10 @@ function parseArgs(argv) {
// Main execution
const args = parseArgs(process.argv.slice(2));
// Load suppression config (async)
const suppressionConfig = await loadSuppressionConfig(args.config || null);
// Load suppression config (requires explicit opt-in)
const suppressionConfig = await loadSuppressionConfig(args.config || null, {
enabled: !!args.enableSuppressions,
});
const suppressions = suppressionConfig.suppressions || [];
// Read audit results
@@ -11,9 +11,10 @@ show_help() {
Usage: run_audit_and_format.sh [OPTIONS]
Options:
--label <text> Custom label for the report
--config <path> Path to config file (e.g., allowlist.json)
--help Show this help message
--label <text> Custom label for the report
--config <path> Path to config file (e.g., allowlist.json)
--enable-suppressions Explicitly enable the suppression mechanism
--help Show this help message
EOF
exit 0
@@ -21,12 +22,15 @@ EOF
LABEL=""
CONFIG=""
ENABLE_SUPPRESSIONS=0
while [[ $# -gt 0 ]]; do
case "$1" in
--label)
LABEL="${2:-}"; shift 2 ;;
--config)
CONFIG="${2:-}"; shift 2 ;;
--enable-suppressions)
ENABLE_SUPPRESSIONS=1; shift ;;
--help)
show_help ;;
*)
@@ -90,6 +94,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Build args for render_report
RENDER_ARGS=(--audit "$AUDIT_JSON" --deep "$DEEP_JSON" --label "$LABEL")
if [[ "$ENABLE_SUPPRESSIONS" -eq 1 ]]; then
RENDER_ARGS+=(--enable-suppressions)
fi
if [[ -n "$CONFIG" ]]; then
RENDER_ARGS+=(--config "$CONFIG")
fi
@@ -10,10 +10,24 @@ set -euo pipefail
COMPANY_EMAIL="${PROMPTSEC_EMAIL_TO:-target@example.com}"
HOST_LABEL="${PROMPTSEC_HOST_LABEL:-}"
DO_PULL="${PROMPTSEC_GIT_PULL:-0}"
ENABLE_SUPPRESSIONS=0
AUDIT_CONFIG=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Parse CLI arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--enable-suppressions)
ENABLE_SUPPRESSIONS=1; shift ;;
--config)
AUDIT_CONFIG="${2:-}"; shift 2 ;;
*)
shift ;;
esac
done
if [[ "$DO_PULL" == "1" ]]; then
if command -v git >/dev/null 2>&1 && [[ -d "$ROOT_DIR/.git" ]]; then
git -C "$ROOT_DIR" pull --ff-only >/dev/null 2>&1 || true
@@ -24,6 +38,12 @@ args=( )
if [[ -n "$HOST_LABEL" ]]; then
args+=(--label "$HOST_LABEL")
fi
if [[ "$ENABLE_SUPPRESSIONS" -eq 1 ]]; then
args+=(--enable-suppressions)
fi
if [[ -n "$AUDIT_CONFIG" ]]; then
args+=(--config "$AUDIT_CONFIG")
fi
REPORT="$($SCRIPT_DIR/run_audit_and_format.sh "${args[@]}")"
SUBJECT_HOST="${HOST_LABEL:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo unknown-host)}"
@@ -0,0 +1,3 @@
{
"suppressions": []
}
@@ -0,0 +1,5 @@
{
"suppressions": [
invalid json here
]
}
@@ -0,0 +1,8 @@
{
"suppressions": [
{
"checkId": "test.check",
"skill": "test-skill"
}
]
}
@@ -71,9 +71,10 @@ function createAuditJson(findings) {
});
}
function createConfigJson(suppressions) {
function createConfigJson(suppressions, enabledFor = ["audit"]) {
return JSON.stringify({
suppressions: suppressions,
enabledFor,
suppressions,
});
}
@@ -137,6 +138,7 @@ async function testSuppressedFindingsDisplayed() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -199,6 +201,7 @@ async function testActiveFindingsDisplayed() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -268,6 +271,7 @@ async function testSummaryExcludesSuppressed() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -361,6 +365,7 @@ async function testPartialMatchCheckIdOnly() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -417,6 +422,7 @@ async function testPartialMatchSkillOnly() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -491,6 +497,7 @@ async function testMultipleSuppressions() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -549,6 +556,7 @@ async function testSkillNameExtractionFromPath() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -604,6 +612,7 @@ async function testSkillNameExtractionFromTitle() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -650,6 +659,7 @@ async function testEmptySuppressions() {
auditFile,
"--deep",
deepFile,
"--enable-suppressions",
"--config",
configFile,
]);
@@ -669,6 +679,63 @@ async function testEmptySuppressions() {
}
}
// -----------------------------------------------------------------------------
// Test: Config without --enable-suppressions flag does NOT suppress
// -----------------------------------------------------------------------------
async function testConfigWithoutEnableFlagDoesNotSuppress() {
const testName = "render_report: config without --enable-suppressions flag does not suppress";
try {
const auditFile = path.join(tempDir, "audit.json");
const deepFile = path.join(tempDir, "deep.json");
const configFile = path.join(tempDir, "config.json");
const findings = [
{
severity: "critical",
checkId: "skills.code_safety",
skill: "clawsec-suite",
title: "dangerous-exec detected",
},
];
const suppressions = [
{
checkId: "skills.code_safety",
skill: "clawsec-suite",
reason: "First-party security tooling",
suppressedAt: "2026-02-13",
},
];
await fs.writeFile(auditFile, createAuditJson(findings));
await fs.writeFile(deepFile, createAuditJson([]));
await fs.writeFile(configFile, createConfigJson(suppressions));
// Pass --config but NOT --enable-suppressions
const result = await runRenderReport([
"--audit",
auditFile,
"--deep",
deepFile,
"--config",
configFile,
]);
// Findings should NOT be suppressed without the explicit opt-in flag
if (
result.stdout.includes("Summary: 1 critical") &&
result.stdout.includes("Findings (critical/warn):") &&
!result.stdout.includes("INFO-SUPPRESSED:")
) {
pass(testName);
} else {
fail(testName, `Config alone should not suppress without --enable-suppressions: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
@@ -686,6 +753,7 @@ async function runAllTests() {
await testSkillNameExtractionFromPath();
await testSkillNameExtractionFromTitle();
await testEmptySuppressions();
await testConfigWithoutEnableFlagDoesNotSuppress();
} finally {
await cleanupTestDir();
}
@@ -10,6 +10,8 @@
* - Malformed JSON error handling
* - File not found graceful fallback
* - Multi-path priority (custom path > env var > primary > fallback)
* - Opt-in gate (enabled flag must be true)
* - enabledFor sentinel validation
*
* Run: node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
*/
@@ -24,12 +26,12 @@ let failCount = 0;
function pass(name) {
passCount += 1;
console.log(` ${name}`);
console.log(`\u2713 ${name}`);
}
function fail(name, error) {
failCount += 1;
console.error(` ${name}`);
console.error(`\u2717 ${name}`);
console.error(` ${String(error)}`);
}
@@ -68,6 +70,22 @@ async function withEnv(key, value, fn) {
}
}
/** Suppress stderr output during a function call (avoids noisy warnings in test output). */
async function silenceStderr(fn) {
const original = process.stderr.write;
process.stderr.write = () => true;
try {
return await fn();
} finally {
process.stderr.write = original;
}
}
/** Create a valid config JSON string with enabledFor sentinel. */
function makeConfig(suppressions, enabledFor = ["audit"]) {
return JSON.stringify({ enabledFor, suppressions });
}
// -----------------------------------------------------------------------------
// Test: valid config with all required fields
// -----------------------------------------------------------------------------
@@ -76,25 +94,25 @@ async function testValidConfig() {
let fixture = null;
try {
const validConfig = JSON.stringify({
suppressions: [
{
checkId: "SCAN-001",
skill: "soul-guardian",
reason: "False positive - reviewed by security team",
suppressedAt: "2026-02-15",
},
{
checkId: "SCAN-002",
skill: "clawtributor",
reason: "Accepted risk for legacy code",
suppressedAt: "2026-02-14",
},
],
});
const validConfig = makeConfig([
{
checkId: "SCAN-001",
skill: "soul-guardian",
reason: "False positive - reviewed by security team",
suppressedAt: "2026-02-15",
},
{
checkId: "SCAN-002",
skill: "clawtributor",
reason: "Accepted risk for legacy code",
suppressedAt: "2026-02-14",
},
]);
fixture = await withTempFile(validConfig);
const config = await loadSuppressionConfig(fixture.path);
const config = await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (
config.source === fixture.path &&
@@ -127,16 +145,14 @@ async function testMalformedDateWarning() {
let fixture = null;
try {
const configWithBadDate = JSON.stringify({
suppressions: [
{
checkId: "SCAN-003",
skill: "soul-guardian",
reason: "Test suppression",
suppressedAt: "02/15/2026",
},
],
});
const configWithBadDate = makeConfig([
{
checkId: "SCAN-003",
skill: "soul-guardian",
reason: "Test suppression",
suppressedAt: "02/15/2026",
},
]);
fixture = await withTempFile(configWithBadDate);
@@ -149,7 +165,7 @@ async function testMalformedDateWarning() {
};
try {
const config = await loadSuppressionConfig(fixture.path);
const config = await loadSuppressionConfig(fixture.path, { enabled: true });
if (
config.suppressions.length === 1 &&
@@ -182,20 +198,20 @@ async function testMissingRequiredField() {
let fixture = null;
try {
const configMissingReason = JSON.stringify({
suppressions: [
{
checkId: "SCAN-004",
skill: "soul-guardian",
suppressedAt: "2026-02-15",
},
],
});
const configMissingReason = makeConfig([
{
checkId: "SCAN-004",
skill: "soul-guardian",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(configMissingReason);
try {
await loadSuppressionConfig(fixture.path);
await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
fail(testName, "Expected error for missing required field");
} catch (err) {
if (err.message.includes("missing required field: reason")) {
@@ -226,7 +242,9 @@ async function testMalformedJSON() {
fixture = await withTempFile(invalidJSON);
try {
await loadSuppressionConfig(fixture.path);
await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
fail(testName, "Expected error for malformed JSON");
} catch (err) {
if (err.message.includes("Malformed JSON")) {
@@ -263,7 +281,9 @@ async function testFileNotFoundGracefulFallback() {
// Expected - file should not exist
}
const config = await loadSuppressionConfig();
const config = await silenceStderr(() =>
loadSuppressionConfig(null, { enabled: true })
);
if (config.source === "none" && Array.isArray(config.suppressions) && config.suppressions.length === 0) {
pass(testName);
@@ -284,19 +304,19 @@ async function testCustomPathPriority() {
let fixture = null;
try {
const customConfig = JSON.stringify({
suppressions: [
{
checkId: "CUSTOM-001",
skill: "custom-skill",
reason: "Custom path config",
suppressedAt: "2026-02-15",
},
],
});
const customConfig = makeConfig([
{
checkId: "CUSTOM-001",
skill: "custom-skill",
reason: "Custom path config",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(customConfig);
const config = await loadSuppressionConfig(fixture.path);
const config = await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (
config.source === fixture.path &&
@@ -324,21 +344,21 @@ async function testEnvironmentVariableOverride() {
let fixture = null;
try {
const envConfig = JSON.stringify({
suppressions: [
{
checkId: "ENV-001",
skill: "env-skill",
reason: "Environment variable config",
suppressedAt: "2026-02-15",
},
],
});
const envConfig = makeConfig([
{
checkId: "ENV-001",
skill: "env-skill",
reason: "Environment variable config",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(envConfig);
await withEnv("OPENCLAW_AUDIT_CONFIG", fixture.path, async () => {
const config = await loadSuppressionConfig();
const config = await silenceStderr(() =>
loadSuppressionConfig(null, { enabled: true })
);
if (
config.source === fixture.path &&
@@ -368,13 +388,16 @@ async function testMissingSuppressions() {
try {
const configWithoutSuppressions = JSON.stringify({
enabledFor: ["audit"],
note: "This config is missing the suppressions array",
});
fixture = await withTempFile(configWithoutSuppressions);
try {
await loadSuppressionConfig(fixture.path);
await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
fail(testName, "Expected error for missing suppressions array");
} catch (err) {
if (err.message.includes("missing 'suppressions' array")) {
@@ -400,12 +423,12 @@ async function testEmptySuppressions() {
let fixture = null;
try {
const emptyConfig = JSON.stringify({
suppressions: [],
});
const emptyConfig = makeConfig([], ["audit"]);
fixture = await withTempFile(emptyConfig);
const config = await loadSuppressionConfig(fixture.path);
const config = await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (config.source === fixture.path && config.suppressions.length === 0) {
pass(testName);
@@ -431,7 +454,9 @@ async function testCustomPathNotFoundFails() {
const nonExistentPath = path.join(os.tmpdir(), "absolutely-does-not-exist-12345.json");
try {
await loadSuppressionConfig(nonExistentPath);
await silenceStderr(() =>
loadSuppressionConfig(nonExistentPath, { enabled: true })
);
fail(testName, "Expected error for custom path not found");
} catch (err) {
if (err.message.includes("Custom config file not found")) {
@@ -445,6 +470,188 @@ async function testCustomPathNotFoundFails() {
}
}
// -----------------------------------------------------------------------------
// Test: disabled by default (enabled flag not set)
// -----------------------------------------------------------------------------
async function testDisabledByDefault() {
const testName = "loadSuppressionConfig: returns empty when enabled flag is not set";
let fixture = null;
try {
const validConfig = makeConfig([
{
checkId: "SCAN-001",
skill: "test-skill",
reason: "Should not be loaded",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(validConfig);
// Custom path provided but enabled=false (default)
const config1 = await loadSuppressionConfig(fixture.path);
if (config1.source !== "none" || config1.suppressions.length !== 0) {
fail(testName, "Custom path should be ignored when enabled is not set");
return;
}
// Env var set but enabled=false (default)
await withEnv("OPENCLAW_AUDIT_CONFIG", fixture.path, async () => {
const config2 = await loadSuppressionConfig();
if (config2.source !== "none" || config2.suppressions.length !== 0) {
fail(testName, "Env var should be ignored when enabled is not set");
return;
}
});
pass(testName);
} catch (error) {
fail(testName, error);
} finally {
if (fixture) await fixture.cleanup();
}
}
// -----------------------------------------------------------------------------
// Test: enabled explicitly loads config
// -----------------------------------------------------------------------------
async function testEnabledExplicitly() {
const testName = "loadSuppressionConfig: loads config when explicitly enabled with sentinel";
let fixture = null;
try {
const validConfig = makeConfig([
{
checkId: "SCAN-001",
skill: "test-skill",
reason: "Should be loaded",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(validConfig);
const config = await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (config.source === fixture.path && config.suppressions.length === 1) {
pass(testName);
} else {
fail(testName, `Expected config to be loaded: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) await fixture.cleanup();
}
}
// -----------------------------------------------------------------------------
// Test: env var alone does not activate suppression
// -----------------------------------------------------------------------------
async function testEnvVarAloneDoesNotActivate() {
const testName = "loadSuppressionConfig: OPENCLAW_AUDIT_CONFIG alone does not activate suppression";
let fixture = null;
try {
const validConfig = makeConfig([
{
checkId: "ENV-ATTACK",
skill: "target-skill",
reason: "Attacker suppression",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(validConfig);
await withEnv("OPENCLAW_AUDIT_CONFIG", fixture.path, async () => {
// Without enabled: true, env var should be ignored
const config = await loadSuppressionConfig(null, { enabled: false });
if (config.source === "none" && config.suppressions.length === 0) {
pass(testName);
} else {
fail(testName, `Env var should not activate suppression: ${JSON.stringify(config)}`);
}
});
} catch (error) {
fail(testName, error);
} finally {
if (fixture) await fixture.cleanup();
}
}
// -----------------------------------------------------------------------------
// Test: missing enabledFor sentinel returns empty
// -----------------------------------------------------------------------------
async function testMissingSentinel() {
const testName = "loadSuppressionConfig: missing enabledFor sentinel returns empty";
let fixture = null;
try {
// Config has suppressions but NO enabledFor field
const configNoSentinel = JSON.stringify({
suppressions: [
{
checkId: "SCAN-001",
skill: "test-skill",
reason: "Should not activate",
suppressedAt: "2026-02-15",
},
],
});
fixture = await withTempFile(configNoSentinel);
const config = await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (config.source === "none" && config.suppressions.length === 0) {
pass(testName);
} else {
fail(testName, `Missing sentinel should return empty: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) await fixture.cleanup();
}
}
// -----------------------------------------------------------------------------
// Test: wrong enabledFor sentinel returns empty
// -----------------------------------------------------------------------------
async function testWrongSentinel() {
const testName = "loadSuppressionConfig: wrong enabledFor sentinel returns empty for audit";
let fixture = null;
try {
// Config has enabledFor: ["advisory"] but not "audit"
const configWrongSentinel = makeConfig(
[
{
checkId: "SCAN-001",
skill: "test-skill",
reason: "Should not activate for audit",
suppressedAt: "2026-02-15",
},
],
["advisory"]
);
fixture = await withTempFile(configWrongSentinel);
const config = await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (config.source === "none" && config.suppressions.length === 0) {
pass(testName);
} else {
fail(testName, `Wrong sentinel should return empty: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) await fixture.cleanup();
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
@@ -461,6 +668,11 @@ async function runTests() {
await testMissingSuppressions();
await testEmptySuppressions();
await testCustomPathNotFoundFails();
await testDisabledByDefault();
await testEnabledExplicitly();
await testEnvVarAloneDoesNotActivate();
await testMissingSentinel();
await testWrongSentinel();
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);