mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
add xguard skill
This commit is contained in:
@@ -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 <command> [args] [options]
|
||||
```
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get <mode>` | GET current options for the mode (`text` or `prompt`) |
|
||||
| `defaults <mode>` | GET hardcoded defaults baked into the orchestrator |
|
||||
| `put <mode>` | PUT new options for the mode (requires `--writable` + `--file`) |
|
||||
| `reset <mode>` | 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 <path>`, `-f <path>` | Read the request body (JSON) from a file. Required for `put` and `import`. |
|
||||
| `--output <path>`, `-o <path>` | Save response to a file (instead of printing to stdout). |
|
||||
| `--quiet`, `-q` | Only print the response body, no connection headers. |
|
||||
| `--timeout <s>`, `-t <s>` | 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`.
|
||||
@@ -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 <mode>
|
||||
* node .claude/skills/xguard-manager/manage.mjs defaults <mode>
|
||||
* node .claude/skills/xguard-manager/manage.mjs put <mode> -f file.json --writable
|
||||
* node .claude/skills/xguard-manager/manage.mjs reset <mode> --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 <command> [args] [options]
|
||||
|
||||
Commands:
|
||||
get <mode> GET current options (mode: text | prompt)
|
||||
defaults <mode> GET hardcoded defaults
|
||||
put <mode> PUT options (requires --writable + --file)
|
||||
reset <mode> 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 <path> JSON body for put / import
|
||||
--output, -o <path> Save response to file
|
||||
--quiet, -q Only print response body
|
||||
--timeout, -t <s> 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 <path> 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();
|
||||
Reference in New Issue
Block a user