From 16abef241083a2a9bcc55b91023d91eb1e6e1ebf Mon Sep 17 00:00:00 2001 From: Briant Diehl Date: Fri, 15 May 2026 12:57:34 -0600 Subject: [PATCH] add xguard skill --- .claude/skills/xguard-manager/SKILL.md | 92 ++++++++ .claude/skills/xguard-manager/manage.mjs | 265 +++++++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 .claude/skills/xguard-manager/SKILL.md create mode 100644 .claude/skills/xguard-manager/manage.mjs diff --git a/.claude/skills/xguard-manager/SKILL.md b/.claude/skills/xguard-manager/SKILL.md new file mode 100644 index 0000000000..5474c62c10 --- /dev/null +++ b/.claude/skills/xguard-manager/SKILL.md @@ -0,0 +1,92 @@ +--- +name: xguard-manager +description: Read, replace, reset, export, and import XGuard policy options on the orchestrator. Use when you need to inspect current per-label policies for text or prompt scans, ship a refined policy, restore defaults, or back up the policy registry. Read-only by default; destructive operations require an explicit `--writable` flag. +--- + +# XGuard Policy Manager + +Use this skill to manage XGuard policy options against the orchestrator's `/v1/manager/xguard/*` admin endpoints. + +Each label's policy is the natural-language prompt text + threshold + action that the orchestrator applies when evaluating an XGuard call. See [docs/features/scanner-prompt-tuning.md](../../../docs/features/scanner-prompt-tuning.md) for how this fits into the scanner refinement lifecycle. Phase 4 (refine policy) is the typical use of this skill: fetch the current options, edit a policy, PUT the new version, then track FP/FN rates against the new `policyHash` in the audit log. + +## Setup + +The skill reads `ORCHESTRATOR_ENDPOINT` and `ORCHESTRATOR_ACCESS_TOKEN` from the project `.env` (or `.claude/skills/xguard-manager/.env` if you want skill-specific overrides). No additional setup beyond having those env vars set. + +## Commands + +```bash +node .claude/skills/xguard-manager/manage.mjs [args] [options] +``` + +| Command | Description | +|---------|-------------| +| `get ` | GET current options for the mode (`text` or `prompt`) | +| `defaults ` | GET hardcoded defaults baked into the orchestrator | +| `put ` | PUT new options for the mode (requires `--writable` + `--file`) | +| `reset ` | POST reset back to defaults (requires `--writable`) | +| `export` | GET bulk export across all modes | +| `import` | PUT bulk import across all modes (requires `--writable` + `--file`) | + +## Options + +| Flag | Description | +|------|-------------| +| `--writable` | Allow destructive operations (PUT / POST). Required for `put`, `reset`, `import`. | +| `--file `, `-f ` | Read the request body (JSON) from a file. Required for `put` and `import`. | +| `--output `, `-o ` | Save response to a file (instead of printing to stdout). | +| `--quiet`, `-q` | Only print the response body, no connection headers. | +| `--timeout `, `-t ` | Request timeout in seconds (default: 30). | + +## Examples + +```bash +# Inspect current prompt-mode options +node .claude/skills/xguard-manager/manage.mjs get prompt + +# Save the current prompt-mode options to a file you can edit +node .claude/skills/xguard-manager/manage.mjs get prompt -o /tmp/prompt-policies.json + +# See what the defaults look like (useful for scaffolding a new label) +node .claude/skills/xguard-manager/manage.mjs defaults prompt + +# Ship an edited policy +node .claude/skills/xguard-manager/manage.mjs put prompt -f /tmp/prompt-policies.json --writable + +# Wipe prompt-mode policies back to defaults (destructive) +node .claude/skills/xguard-manager/manage.mjs reset prompt --writable + +# Back up the entire policy registry before a risky edit +node .claude/skills/xguard-manager/manage.mjs export -o /tmp/xguard-backup.json + +# Restore from a backup +node .claude/skills/xguard-manager/manage.mjs import -f /tmp/xguard-backup.json --writable +``` + +## Safety + +1. **Read-only by default**: `get`, `defaults`, and `export` always work without `--writable`. +2. **Destructive operations require explicit `--writable`**: `put`, `reset`, and `import` change orchestrator state and refuse to run without the flag. +3. **Always ask the user before using `--writable`**: policy changes are global — every subsequent scan will use the new policy text. Confirm the intent before running. +4. **Back up before editing**: run `export -o backup.json` before any non-trivial change so you can `import` to restore if needed. + +## Typical workflow for refining a policy (Phase 4) + +```bash +# 1. Snapshot current state for safe rollback +node .claude/skills/xguard-manager/manage.mjs export -o /tmp/xguard-backup-$(date +%Y%m%d).json + +# 2. Pull the current options for the mode you're editing +node .claude/skills/xguard-manager/manage.mjs get prompt -o /tmp/prompt-current.json + +# 3. Edit /tmp/prompt-current.json with the refined policy text + +# 4. Ship it (after user confirms) +node .claude/skills/xguard-manager/manage.mjs put prompt -f /tmp/prompt-current.json --writable + +# 5. Track FP/FN rate of new policyHash via the audit log + focused review +``` + +## Why this is separate from the postgres-query / clickhouse-query skills + +Those query the audit *data* (verdicts, scores, matched terms). This skill manages the *policy* itself — the prompt text the orchestrator hands to XGuard. A typical tuning loop uses both: query data to identify FP patterns, edit the policy here, then re-query after new scans land under the new `policyHash`. diff --git a/.claude/skills/xguard-manager/manage.mjs b/.claude/skills/xguard-manager/manage.mjs new file mode 100644 index 0000000000..7469bc71e6 --- /dev/null +++ b/.claude/skills/xguard-manager/manage.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node + +/** + * XGuard Policy Manager + * + * Read, replace, reset, export, and import XGuard policy options on the + * orchestrator (`/v1/manager/xguard/*` admin endpoints). + * + * Usage: + * node .claude/skills/xguard-manager/manage.mjs get + * node .claude/skills/xguard-manager/manage.mjs defaults + * node .claude/skills/xguard-manager/manage.mjs put -f file.json --writable + * node .claude/skills/xguard-manager/manage.mjs reset --writable + * node .claude/skills/xguard-manager/manage.mjs export [-o file.json] + * node .claude/skills/xguard-manager/manage.mjs import -f file.json --writable + * + * Options: + * --writable Allow destructive operations (put / reset / import) + * --file, -f Path to JSON body (required for put / import) + * --output, -o Save response to file instead of stdout + * --quiet, -q Only print the response body + * --timeout, -t Request timeout in seconds (default: 30) + * + * Env (from project .env): + * ORCHESTRATOR_ENDPOINT base URL of the orchestrator + * ORCHESTRATOR_ACCESS_TOKEN system bearer token + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const skillDir = __dirname; +const projectRoot = resolve(__dirname, '../../..'); + +function loadEnv() { + const envFiles = [resolve(skillDir, '.env'), resolve(projectRoot, '.env')]; + for (const envPath of envFiles) { + try { + const envContent = readFileSync(envPath, 'utf-8'); + for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) continue; + const key = trimmed.slice(0, eqIndex); + const value = trimmed.slice(eqIndex + 1); + if (!process.env[key]) process.env[key] = value; + } + } catch { + // file not found, continue + } + } +} +loadEnv(); + +const DEFAULT_TIMEOUT_SECONDS = 30; +const VALID_MODES = new Set(['text', 'prompt']); +const DESTRUCTIVE_COMMANDS = new Set(['put', 'reset', 'import']); + +// Parse args +const args = process.argv.slice(2); +let command = ''; +let mode = ''; +let writable = false; +let filePath = ''; +let outputPath = ''; +let quiet = false; +let timeoutSeconds = DEFAULT_TIMEOUT_SECONDS; +const positional = []; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--writable') { + writable = true; + } else if (arg === '--file' || arg === '-f') { + filePath = args[++i] || ''; + } else if (arg === '--output' || arg === '-o') { + outputPath = args[++i] || ''; + } else if (arg === '--quiet' || arg === '-q') { + quiet = true; + } else if (arg === '--timeout' || arg === '-t') { + const val = args[++i]; + if (!val || isNaN(parseInt(val, 10))) { + console.error('Error: --timeout requires a number (seconds)'); + process.exit(1); + } + timeoutSeconds = parseInt(val, 10); + } else if (!arg.startsWith('-')) { + positional.push(arg); + } else { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } +} + +command = positional[0] || ''; + +function usage(msg) { + if (msg) console.error(`Error: ${msg}\n`); + console.error( + `Usage: node manage.mjs [args] [options] + +Commands: + get GET current options (mode: text | prompt) + defaults GET hardcoded defaults + put PUT options (requires --writable + --file) + reset POST reset to defaults (requires --writable) + export GET bulk export across all modes + import PUT bulk import (requires --writable + --file) + +Options: + --writable Allow destructive operations + --file, -f JSON body for put / import + --output, -o Save response to file + --quiet, -q Only print response body + --timeout, -t Request timeout in seconds (default: ${DEFAULT_TIMEOUT_SECONDS}) + +Examples: + node manage.mjs get prompt + node manage.mjs defaults prompt -o defaults.json + node manage.mjs put prompt -f policies.json --writable + node manage.mjs reset prompt --writable + node manage.mjs export -o backup.json + node manage.mjs import -f backup.json --writable` + ); + process.exit(1); +} + +if (!command) usage(); + +const needsMode = ['get', 'defaults', 'put', 'reset'].includes(command); +if (needsMode) { + mode = positional[1] || ''; + if (!mode) usage(`Command "${command}" requires a mode argument (text | prompt)`); + if (!VALID_MODES.has(mode)) usage(`Invalid mode "${mode}". Must be one of: text, prompt`); +} + +if (DESTRUCTIVE_COMMANDS.has(command) && !writable) { + usage( + `Command "${command}" is destructive — pass --writable to confirm.\n` + + `This will change orchestrator state for every subsequent XGuard scan.` + ); +} + +if ((command === 'put' || command === 'import') && !filePath) { + usage(`Command "${command}" requires --file for the JSON body.`); +} + +const endpoint = process.env.ORCHESTRATOR_ENDPOINT; +const token = process.env.ORCHESTRATOR_ACCESS_TOKEN; +if (!endpoint) { + console.error('Error: ORCHESTRATOR_ENDPOINT is not set in env (.env)'); + process.exit(1); +} +if (!token) { + console.error('Error: ORCHESTRATOR_ACCESS_TOKEN is not set in env (.env)'); + process.exit(1); +} + +function pathFor() { + switch (command) { + case 'get': + return { method: 'GET', path: `/v1/manager/xguard/options/${mode}` }; + case 'defaults': + return { method: 'GET', path: `/v1/manager/xguard/options/${mode}/defaults` }; + case 'put': + return { method: 'PUT', path: `/v1/manager/xguard/options/${mode}` }; + case 'reset': + return { method: 'POST', path: `/v1/manager/xguard/options/${mode}/reset` }; + case 'export': + return { method: 'GET', path: `/v1/manager/xguard/export` }; + case 'import': + return { method: 'PUT', path: `/v1/manager/xguard/import` }; + default: + usage(`Unknown command: ${command}`); + return null; + } +} + +function readBody() { + if (!filePath) return undefined; + let raw; + try { + raw = readFileSync(resolve(process.cwd(), filePath), 'utf-8'); + } catch (e) { + console.error(`Error reading body file ${filePath}: ${e.message}`); + process.exit(1); + } + try { + return JSON.parse(raw); + } catch (e) { + console.error(`Error: --file ${filePath} is not valid JSON: ${e.message}`); + process.exit(1); + } +} + +async function main() { + const { method, path } = pathFor(); + const url = `${endpoint}${path}`; + + const hasBody = method === 'PUT' || method === 'POST'; + let body; + if (hasBody) { + const fileBody = readBody(); + // reset has no body; put/import require a file + body = fileBody !== undefined ? fileBody : command === 'reset' ? {} : undefined; + } + + if (!quiet) { + const safetyTag = DESTRUCTIVE_COMMANDS.has(command) ? ' [DESTRUCTIVE]' : ''; + console.error(`${method} ${url}${safetyTag} (timeout: ${timeoutSeconds}s)\n`); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000); + + let res; + try { + res = await fetch(url, { + method, + signal: controller.signal, + headers: { + Authorization: `Bearer ${token}`, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch (e) { + clearTimeout(timeoutId); + if (e.name === 'AbortError') { + console.error(`Error: Request timed out after ${timeoutSeconds} seconds`); + } else { + console.error(`Error: ${e.message}`); + } + process.exit(1); + } + clearTimeout(timeoutId); + + const text = await res.text(); + let parsed; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + + if (!res.ok) { + console.error(`HTTP ${res.status} ${res.statusText}`); + if (parsed != null) console.error(typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2)); + process.exit(1); + } + + const output = typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2); + + if (outputPath) { + writeFileSync(resolve(process.cwd(), outputPath), output); + if (!quiet) console.error(`Saved response to ${outputPath}`); + } else { + console.log(output); + } +} + +main();