shroud-privacy 2.2.9 → 2.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -11
- package/dist/agent-session.d.ts +259 -0
- package/dist/agent-session.js +693 -0
- package/dist/dashboard.d.ts +42 -0
- package/dist/dashboard.js +1558 -0
- package/dist/detectors/context.js +7 -0
- package/dist/detectors/injection-multilingual.d.ts +27 -0
- package/dist/detectors/injection-multilingual.js +399 -0
- package/dist/detectors/injection-signatures.d.ts +26 -0
- package/dist/detectors/injection-signatures.js +508 -0
- package/dist/detectors/injection.d.ts +56 -0
- package/dist/detectors/injection.js +269 -0
- package/dist/detectors/regex.js +2 -2
- package/dist/detectors/tool-guard.d.ts +27 -0
- package/dist/detectors/tool-guard.js +418 -0
- package/dist/event-grader.d.ts +97 -0
- package/dist/event-grader.js +214 -0
- package/dist/policy.d.ts +93 -43
- package/dist/policy.js +193 -86
- package/dist/profiler-analysis.d.ts +35 -0
- package/dist/profiler-analysis.js +230 -0
- package/dist/profiler-store.d.ts +33 -0
- package/dist/profiler-store.js +118 -0
- package/dist/profiler-types.d.ts +128 -0
- package/dist/profiler-types.js +16 -0
- package/dist/profiler.d.ts +81 -0
- package/dist/profiler.js +392 -0
- package/dist/security-event.d.ts +70 -0
- package/dist/security-event.js +80 -0
- package/dist/siem.d.ts +38 -24
- package/dist/siem.js +90 -68
- package/dist/signature-loader.d.ts +113 -0
- package/dist/signature-loader.js +255 -0
- package/openclaw.plugin.json +155 -30
- package/package.json +4 -4
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-based security event grading.
|
|
3
|
+
*
|
|
4
|
+
* Accumulates flagged security events, batches them, and sends to an LLM
|
|
5
|
+
* via OpenClaw gateway session to classify as TRUE_POSITIVE, FALSE_POSITIVE,
|
|
6
|
+
* or NEEDS_REVIEW. Results are stored on the event for dashboard display.
|
|
7
|
+
*
|
|
8
|
+
* Self-whitelisting: grading sessions use a marker in the session key so
|
|
9
|
+
* Shroud's own scanner doesn't flag the grading prompt (which contains
|
|
10
|
+
* real injection examples by definition).
|
|
11
|
+
*
|
|
12
|
+
* Zero external dependencies — uses Node.js built-in child_process to call
|
|
13
|
+
* `openclaw gateway call sessions.create`.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
/** Marker prefix for grading session keys. */
|
|
17
|
+
export const GRADING_SESSION_PREFIX = "shroud-grading-";
|
|
18
|
+
/** The agent label that grading sessions will produce via extractLabel. */
|
|
19
|
+
export const GRADING_AGENT_LABEL = "Security Event Grader";
|
|
20
|
+
/** The grading system prompt. */
|
|
21
|
+
const GRADING_PROMPT = `You are a security event grader for an AI agent firewall (Shroud).
|
|
22
|
+
|
|
23
|
+
For each flagged event below, classify it as one of:
|
|
24
|
+
- TRUE_POSITIVE: This is a genuine security concern (injection attempt, data exfiltration, etc.)
|
|
25
|
+
- FALSE_POSITIVE: This is benign — the pattern triggered on normal content being discussed, not an actual attack
|
|
26
|
+
- NEEDS_REVIEW: Ambiguous — a human should review this
|
|
27
|
+
|
|
28
|
+
Consider:
|
|
29
|
+
- Is the matched text being DISCUSSED/QUOTED (benign) or EXECUTED/INJECTED (malicious)?
|
|
30
|
+
- Is this a normal part of the agent's role? (e.g. a security researcher discussing injection techniques is expected)
|
|
31
|
+
- What is the agent's classification and context?
|
|
32
|
+
|
|
33
|
+
Respond with ONLY a JSON array. Each element:
|
|
34
|
+
{"index": N, "verdict": "TRUE_POSITIVE|FALSE_POSITIVE|NEEDS_REVIEW", "reasoning": "one sentence"}
|
|
35
|
+
|
|
36
|
+
Do not include any other text outside the JSON array.`;
|
|
37
|
+
export class EventGrader {
|
|
38
|
+
_pending = [];
|
|
39
|
+
_graded = new Map();
|
|
40
|
+
_batchLog = [];
|
|
41
|
+
_timer = null;
|
|
42
|
+
_threshold;
|
|
43
|
+
_intervalMs;
|
|
44
|
+
_gatewayUrl;
|
|
45
|
+
_openclawBin;
|
|
46
|
+
_running = false;
|
|
47
|
+
constructor(opts) {
|
|
48
|
+
this._threshold = opts.threshold;
|
|
49
|
+
this._intervalMs = opts.intervalSec * 1000;
|
|
50
|
+
this._gatewayUrl = opts.gatewayUrl;
|
|
51
|
+
// Find openclaw binary
|
|
52
|
+
this._openclawBin = process.env.OPENCLAW_BIN || "openclaw";
|
|
53
|
+
try {
|
|
54
|
+
const which = execFileSync("which", ["openclaw"], { encoding: "utf-8" }).trim();
|
|
55
|
+
if (which)
|
|
56
|
+
this._openclawBin = which;
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
}
|
|
60
|
+
/** Start the grading timer. */
|
|
61
|
+
start() {
|
|
62
|
+
if (this._timer)
|
|
63
|
+
return;
|
|
64
|
+
this._timer = setInterval(() => this._tryGrade(), this._intervalMs);
|
|
65
|
+
if (this._timer.unref)
|
|
66
|
+
this._timer.unref();
|
|
67
|
+
}
|
|
68
|
+
/** Stop the grading timer. */
|
|
69
|
+
stop() {
|
|
70
|
+
if (this._timer) {
|
|
71
|
+
clearInterval(this._timer);
|
|
72
|
+
this._timer = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Add an event to the pending grading queue. */
|
|
76
|
+
addEvent(event) {
|
|
77
|
+
// Don't grade events from grading sessions (prevent recursion)
|
|
78
|
+
if (event.agentSessionId?.startsWith(GRADING_SESSION_PREFIX))
|
|
79
|
+
return;
|
|
80
|
+
// Don't re-grade
|
|
81
|
+
if (this._graded.has(event.timestamp))
|
|
82
|
+
return;
|
|
83
|
+
this._pending.push(event);
|
|
84
|
+
// Trigger immediately if threshold met
|
|
85
|
+
if (this._pending.length >= this._threshold) {
|
|
86
|
+
this._tryGrade("threshold");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Get the verdict for an event (by timestamp). */
|
|
90
|
+
getVerdict(eventTimestamp) {
|
|
91
|
+
return this._graded.get(eventTimestamp) ?? null;
|
|
92
|
+
}
|
|
93
|
+
/** Get all graded events. */
|
|
94
|
+
getAllGraded() {
|
|
95
|
+
return [...this._graded.values()];
|
|
96
|
+
}
|
|
97
|
+
/** Get grading stats. */
|
|
98
|
+
getStats() {
|
|
99
|
+
let tp = 0, fp = 0, nr = 0;
|
|
100
|
+
for (const g of this._graded.values()) {
|
|
101
|
+
if (g.verdict === "TRUE_POSITIVE")
|
|
102
|
+
tp++;
|
|
103
|
+
else if (g.verdict === "FALSE_POSITIVE")
|
|
104
|
+
fp++;
|
|
105
|
+
else if (g.verdict === "NEEDS_REVIEW")
|
|
106
|
+
nr++;
|
|
107
|
+
}
|
|
108
|
+
return { pending: this._pending.length, graded: this._graded.size, truePositive: tp, falsePositive: fp, needsReview: nr };
|
|
109
|
+
}
|
|
110
|
+
/** Get the grading batch log (last 50 batches). */
|
|
111
|
+
getBatchLog() {
|
|
112
|
+
return this._batchLog;
|
|
113
|
+
}
|
|
114
|
+
/** Attempt a grading batch. */
|
|
115
|
+
_tryGrade(trigger = "timer") {
|
|
116
|
+
if (this._running || this._pending.length === 0)
|
|
117
|
+
return;
|
|
118
|
+
this._running = true;
|
|
119
|
+
const batch = this._pending.splice(0, 20);
|
|
120
|
+
const startTime = Date.now();
|
|
121
|
+
const log = {
|
|
122
|
+
timestamp: startTime,
|
|
123
|
+
trigger,
|
|
124
|
+
eventCount: batch.length,
|
|
125
|
+
prompt: "",
|
|
126
|
+
rawResponse: "",
|
|
127
|
+
verdicts: [],
|
|
128
|
+
success: false,
|
|
129
|
+
error: "",
|
|
130
|
+
responseTimeMs: 0,
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
const { verdicts, prompt, rawResponse } = this._callGateway(batch);
|
|
134
|
+
log.prompt = prompt;
|
|
135
|
+
log.rawResponse = rawResponse;
|
|
136
|
+
log.responseTimeMs = Date.now() - startTime;
|
|
137
|
+
log.success = true;
|
|
138
|
+
for (const v of verdicts) {
|
|
139
|
+
if (v.index >= 0 && v.index < batch.length) {
|
|
140
|
+
const event = batch[v.index];
|
|
141
|
+
const graded = {
|
|
142
|
+
eventTimestamp: event.timestamp,
|
|
143
|
+
signatureId: event.signatureId,
|
|
144
|
+
agentLabel: event.agentLabel || "unknown",
|
|
145
|
+
matchedText: event.matchedText?.slice(0, 100) || "",
|
|
146
|
+
verdict: v.verdict,
|
|
147
|
+
reasoning: v.reasoning || "",
|
|
148
|
+
gradedAt: Date.now(),
|
|
149
|
+
};
|
|
150
|
+
this._graded.set(event.timestamp, graded);
|
|
151
|
+
log.verdicts.push({
|
|
152
|
+
signatureId: event.signatureId,
|
|
153
|
+
agentLabel: event.agentLabel || "unknown",
|
|
154
|
+
verdict: v.verdict,
|
|
155
|
+
reasoning: v.reasoning || "",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
log.error = err?.message || "Unknown error";
|
|
162
|
+
log.responseTimeMs = Date.now() - startTime;
|
|
163
|
+
this._pending.unshift(...batch);
|
|
164
|
+
}
|
|
165
|
+
this._batchLog.push(log);
|
|
166
|
+
if (this._batchLog.length > 50)
|
|
167
|
+
this._batchLog.shift();
|
|
168
|
+
this._running = false;
|
|
169
|
+
}
|
|
170
|
+
/** Call the OpenClaw gateway to grade a batch of events. */
|
|
171
|
+
_callGateway(batch) {
|
|
172
|
+
const eventsText = batch.map((e, i) => `[${i}] sig=${e.signatureId} sev=${e.severity} agent="${e.agentLabel || "?"}" ` +
|
|
173
|
+
`class=${e.threatClass} match="${(e.matchedText || "").slice(0, 150)}" ` +
|
|
174
|
+
`desc="${e.description || ""}"`).join("\n");
|
|
175
|
+
const message = `Grade these ${batch.length} security events:\n\n${eventsText}`;
|
|
176
|
+
const fullPrompt = `${GRADING_PROMPT}\n\n${message}`;
|
|
177
|
+
const sessionKey = `${GRADING_SESSION_PREFIX}${Date.now()}`;
|
|
178
|
+
try {
|
|
179
|
+
const result = execFileSync(this._openclawBin, [
|
|
180
|
+
"gateway", "call", "sessions.create",
|
|
181
|
+
"--expect-final",
|
|
182
|
+
"--timeout", "30000",
|
|
183
|
+
"--json",
|
|
184
|
+
"--params", JSON.stringify({
|
|
185
|
+
key: sessionKey,
|
|
186
|
+
message,
|
|
187
|
+
systemPrompt: GRADING_PROMPT,
|
|
188
|
+
}),
|
|
189
|
+
], {
|
|
190
|
+
timeout: 35_000,
|
|
191
|
+
encoding: "utf-8",
|
|
192
|
+
env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: "0" },
|
|
193
|
+
});
|
|
194
|
+
const jsonMatch = result.match(/\[[\s\S]*?\]/);
|
|
195
|
+
if (!jsonMatch)
|
|
196
|
+
return { verdicts: [], prompt: fullPrompt, rawResponse: result.slice(0, 2000) };
|
|
197
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
198
|
+
if (!Array.isArray(parsed))
|
|
199
|
+
return { verdicts: [], prompt: fullPrompt, rawResponse: result.slice(0, 2000) };
|
|
200
|
+
const verdicts = parsed
|
|
201
|
+
.filter((v) => typeof v.index === "number" &&
|
|
202
|
+
["TRUE_POSITIVE", "FALSE_POSITIVE", "NEEDS_REVIEW"].includes(v.verdict))
|
|
203
|
+
.map((v) => ({
|
|
204
|
+
index: v.index,
|
|
205
|
+
verdict: v.verdict,
|
|
206
|
+
reasoning: typeof v.reasoning === "string" ? v.reasoning.slice(0, 200) : "",
|
|
207
|
+
}));
|
|
208
|
+
return { verdicts, prompt: fullPrompt, rawResponse: result.slice(0, 2000) };
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
throw new Error(err?.message?.slice(0, 200) || "Gateway call failed");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,49 +1,99 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Per-agent firewall policy engine.
|
|
3
|
+
*
|
|
4
|
+
* Maps agent build IDs to security policy overrides.
|
|
5
|
+
* Supports hot-reload from a JSON policy file.
|
|
6
|
+
* Enables per-agent WAF rules — different agents get different
|
|
7
|
+
* injection detection thresholds, disabled signatures, and action modes.
|
|
8
|
+
*
|
|
9
|
+
* Policy file format (~/.shroud/policy.json):
|
|
10
|
+
* {
|
|
11
|
+
* "default": {
|
|
12
|
+
* "injectionDetection": "flag",
|
|
13
|
+
* "injectionMinSeverity": "low",
|
|
14
|
+
* "injectionDisabledSignatures": []
|
|
15
|
+
* },
|
|
16
|
+
* "agents": {
|
|
17
|
+
* "a1b2c3d4e5f6g7h8": {
|
|
18
|
+
* "label": "research-agent",
|
|
19
|
+
* "injectionDetection": "flag",
|
|
20
|
+
* "injectionMinSeverity": "medium",
|
|
21
|
+
* "injectionDisabledSignatures": ["rs_jailbreak"],
|
|
22
|
+
* "notes": "Research agent legitimately discusses injection patterns"
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
4
26
|
*/
|
|
5
|
-
|
|
6
|
-
export interface
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
minConfidence?: number;
|
|
15
|
-
/** Maximum global confidence threshold (prevent over-filtering). */
|
|
16
|
-
maxConfidence?: number;
|
|
17
|
-
/** Values that MUST be in the denylist. */
|
|
18
|
-
mandatoryDenylist?: string[];
|
|
19
|
-
/** Values that MUST NOT be in the allowlist. */
|
|
20
|
-
prohibitedAllowlist?: string[];
|
|
21
|
-
/** Audit must be enabled. */
|
|
22
|
-
requireAudit?: boolean;
|
|
23
|
-
/** Canary must be enabled. */
|
|
24
|
-
requireCanary?: boolean;
|
|
25
|
-
/** Redaction level must be one of these. */
|
|
26
|
-
allowedRedactionLevels?: string[];
|
|
27
|
-
/** Custom rules. */
|
|
28
|
-
customRules?: Array<{
|
|
29
|
-
name: string;
|
|
30
|
-
check: string;
|
|
31
|
-
message: string;
|
|
32
|
-
}>;
|
|
27
|
+
/** Per-agent policy overrides. */
|
|
28
|
+
export interface AgentPolicy {
|
|
29
|
+
label?: string;
|
|
30
|
+
injectionDetection?: "flag" | "block" | "off";
|
|
31
|
+
injectionMinSeverity?: "low" | "medium" | "high";
|
|
32
|
+
injectionDisabledSignatures?: string[];
|
|
33
|
+
injectionScanResponses?: boolean;
|
|
34
|
+
profilingMode?: "learning" | "active" | "strict";
|
|
35
|
+
notes?: string;
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
/** Full policy file structure. */
|
|
38
|
+
export interface PolicyFile {
|
|
39
|
+
default: AgentPolicy;
|
|
40
|
+
agents: Record<string, AgentPolicy>;
|
|
41
|
+
}
|
|
42
|
+
/** A versioned policy commit. */
|
|
43
|
+
export interface PolicyCommit {
|
|
44
|
+
version: number;
|
|
45
|
+
timestamp: string;
|
|
46
|
+
description: string;
|
|
47
|
+
policy: PolicyFile;
|
|
48
|
+
}
|
|
49
|
+
/** Policy history — stored alongside the policy file. */
|
|
50
|
+
export interface PolicyHistory {
|
|
51
|
+
current: number;
|
|
52
|
+
commits: PolicyCommit[];
|
|
38
53
|
}
|
|
39
54
|
/**
|
|
40
|
-
*
|
|
41
|
-
*/
|
|
42
|
-
export declare function loadPolicy(filePath: string): PolicyDef | null;
|
|
43
|
-
/**
|
|
44
|
-
* Validate a ShroudConfig against a policy.
|
|
45
|
-
* Returns an array of violations (empty = compliant).
|
|
55
|
+
* Policy engine with hot-reload support.
|
|
46
56
|
*/
|
|
47
|
-
export declare
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
export declare class PolicyEngine {
|
|
58
|
+
private _policyPath;
|
|
59
|
+
private _historyPath;
|
|
60
|
+
private _policy;
|
|
61
|
+
private _history;
|
|
62
|
+
private _watching;
|
|
63
|
+
private _onReload;
|
|
64
|
+
constructor(policyPath: string);
|
|
65
|
+
/** Get the effective policy for an agent (merged with defaults). */
|
|
66
|
+
getPolicy(agentBuildId: string): AgentPolicy;
|
|
67
|
+
/** Get the full policy file. */
|
|
68
|
+
getFullPolicy(): PolicyFile;
|
|
69
|
+
/** Update policy for a specific agent. Saves to disk. */
|
|
70
|
+
setAgentPolicy(agentBuildId: string, policy: AgentPolicy): void;
|
|
71
|
+
/** Update the default policy. Saves to disk. */
|
|
72
|
+
setDefaultPolicy(policy: AgentPolicy): void;
|
|
73
|
+
/** Remove agent-specific policy (falls back to default). */
|
|
74
|
+
removeAgentPolicy(agentBuildId: string): void;
|
|
75
|
+
/**
|
|
76
|
+
* Commit the current policy state with a description.
|
|
77
|
+
* Creates a versioned snapshot that can be rolled back to.
|
|
78
|
+
*/
|
|
79
|
+
commit(description: string): PolicyCommit;
|
|
80
|
+
/**
|
|
81
|
+
* Rollback to a specific version number.
|
|
82
|
+
* Returns the restored commit or null if version not found.
|
|
83
|
+
*/
|
|
84
|
+
rollback(version: number): PolicyCommit | null;
|
|
85
|
+
/** Get the commit history. */
|
|
86
|
+
getHistory(): PolicyHistory;
|
|
87
|
+
/** Get the current version number. */
|
|
88
|
+
getCurrentVersion(): number;
|
|
89
|
+
/** Start watching the policy file for external changes (hot-reload). */
|
|
90
|
+
startWatching(): void;
|
|
91
|
+
/** Stop watching. */
|
|
92
|
+
stopWatching(): void;
|
|
93
|
+
/** Register a callback for policy reload events. */
|
|
94
|
+
onReload(callback: () => void): void;
|
|
95
|
+
private _loadOrDefault;
|
|
96
|
+
private _save;
|
|
97
|
+
private _loadHistory;
|
|
98
|
+
private _saveHistory;
|
|
99
|
+
}
|
package/dist/policy.js
CHANGED
|
@@ -1,105 +1,212 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Per-agent firewall policy engine.
|
|
3
|
+
*
|
|
4
|
+
* Maps agent build IDs to security policy overrides.
|
|
5
|
+
* Supports hot-reload from a JSON policy file.
|
|
6
|
+
* Enables per-agent WAF rules — different agents get different
|
|
7
|
+
* injection detection thresholds, disabled signatures, and action modes.
|
|
8
|
+
*
|
|
9
|
+
* Policy file format (~/.shroud/policy.json):
|
|
10
|
+
* {
|
|
11
|
+
* "default": {
|
|
12
|
+
* "injectionDetection": "flag",
|
|
13
|
+
* "injectionMinSeverity": "low",
|
|
14
|
+
* "injectionDisabledSignatures": []
|
|
15
|
+
* },
|
|
16
|
+
* "agents": {
|
|
17
|
+
* "a1b2c3d4e5f6g7h8": {
|
|
18
|
+
* "label": "research-agent",
|
|
19
|
+
* "injectionDetection": "flag",
|
|
20
|
+
* "injectionMinSeverity": "medium",
|
|
21
|
+
* "injectionDisabledSignatures": ["rs_jailbreak"],
|
|
22
|
+
* "notes": "Research agent legitimately discusses injection patterns"
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
4
26
|
*/
|
|
5
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
27
|
+
import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, mkdirSync } from "node:fs";
|
|
28
|
+
import { dirname } from "node:path";
|
|
6
29
|
/**
|
|
7
|
-
*
|
|
30
|
+
* Policy engine with hot-reload support.
|
|
8
31
|
*/
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
32
|
+
export class PolicyEngine {
|
|
33
|
+
_policyPath;
|
|
34
|
+
_historyPath;
|
|
35
|
+
_policy;
|
|
36
|
+
_history;
|
|
37
|
+
_watching = false;
|
|
38
|
+
_onReload = [];
|
|
39
|
+
constructor(policyPath) {
|
|
40
|
+
this._policyPath = policyPath;
|
|
41
|
+
this._historyPath = policyPath.replace(/\.json$/, ".history.json");
|
|
42
|
+
this._policy = this._loadOrDefault();
|
|
43
|
+
this._history = this._loadHistory();
|
|
15
44
|
}
|
|
16
|
-
|
|
17
|
-
|
|
45
|
+
/** Get the effective policy for an agent (merged with defaults). */
|
|
46
|
+
getPolicy(agentBuildId) {
|
|
47
|
+
const agentOverride = this._policy.agents[agentBuildId];
|
|
48
|
+
if (!agentOverride)
|
|
49
|
+
return { ...this._policy.default };
|
|
50
|
+
return {
|
|
51
|
+
...this._policy.default,
|
|
52
|
+
...agentOverride,
|
|
53
|
+
// Merge disabled signatures (union of default + agent-specific)
|
|
54
|
+
injectionDisabledSignatures: [
|
|
55
|
+
...(this._policy.default.injectionDisabledSignatures || []),
|
|
56
|
+
...(agentOverride.injectionDisabledSignatures || []),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
18
59
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
60
|
+
/** Get the full policy file. */
|
|
61
|
+
getFullPolicy() {
|
|
62
|
+
return this._policy;
|
|
63
|
+
}
|
|
64
|
+
/** Update policy for a specific agent. Saves to disk. */
|
|
65
|
+
setAgentPolicy(agentBuildId, policy) {
|
|
66
|
+
this._policy.agents[agentBuildId] = {
|
|
67
|
+
...this._policy.agents[agentBuildId],
|
|
68
|
+
...policy,
|
|
69
|
+
};
|
|
70
|
+
this._save();
|
|
71
|
+
}
|
|
72
|
+
/** Update the default policy. Saves to disk. */
|
|
73
|
+
setDefaultPolicy(policy) {
|
|
74
|
+
this._policy.default = { ...this._policy.default, ...policy };
|
|
75
|
+
this._save();
|
|
76
|
+
}
|
|
77
|
+
/** Remove agent-specific policy (falls back to default). */
|
|
78
|
+
removeAgentPolicy(agentBuildId) {
|
|
79
|
+
delete this._policy.agents[agentBuildId];
|
|
80
|
+
this._save();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Commit the current policy state with a description.
|
|
84
|
+
* Creates a versioned snapshot that can be rolled back to.
|
|
85
|
+
*/
|
|
86
|
+
commit(description) {
|
|
87
|
+
const version = (this._history.commits.length > 0
|
|
88
|
+
? this._history.commits[this._history.commits.length - 1].version
|
|
89
|
+
: 0) + 1;
|
|
90
|
+
const commit = {
|
|
91
|
+
version,
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
description,
|
|
94
|
+
policy: JSON.parse(JSON.stringify(this._policy)), // deep clone
|
|
95
|
+
};
|
|
96
|
+
this._history.commits.push(commit);
|
|
97
|
+
this._history.current = version;
|
|
98
|
+
// Keep last 50 commits max
|
|
99
|
+
if (this._history.commits.length > 50) {
|
|
100
|
+
this._history.commits = this._history.commits.slice(-50);
|
|
38
101
|
}
|
|
102
|
+
this._saveHistory();
|
|
103
|
+
this._save();
|
|
104
|
+
return commit;
|
|
39
105
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Rollback to a specific version number.
|
|
108
|
+
* Returns the restored commit or null if version not found.
|
|
109
|
+
*/
|
|
110
|
+
rollback(version) {
|
|
111
|
+
const commit = this._history.commits.find(c => c.version === version);
|
|
112
|
+
if (!commit)
|
|
113
|
+
return null;
|
|
114
|
+
this._policy = JSON.parse(JSON.stringify(commit.policy)); // deep clone
|
|
115
|
+
this._history.current = version;
|
|
116
|
+
this._saveHistory();
|
|
117
|
+
this._save();
|
|
118
|
+
for (const cb of this._onReload)
|
|
119
|
+
cb();
|
|
120
|
+
return commit;
|
|
47
121
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
rule: "maxConfidence",
|
|
52
|
-
severity: "warning",
|
|
53
|
-
message: `Global minConfidence (${config.minConfidence}) exceeds policy maximum (${policy.maxConfidence}) — may over-filter`,
|
|
54
|
-
});
|
|
122
|
+
/** Get the commit history. */
|
|
123
|
+
getHistory() {
|
|
124
|
+
return this._history;
|
|
55
125
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
126
|
+
/** Get the current version number. */
|
|
127
|
+
getCurrentVersion() {
|
|
128
|
+
return this._history.current;
|
|
129
|
+
}
|
|
130
|
+
/** Start watching the policy file for external changes (hot-reload). */
|
|
131
|
+
startWatching() {
|
|
132
|
+
if (this._watching)
|
|
133
|
+
return;
|
|
134
|
+
this._watching = true;
|
|
135
|
+
watchFile(this._policyPath, { interval: 2000 }, () => {
|
|
136
|
+
try {
|
|
137
|
+
this._policy = this._loadOrDefault();
|
|
138
|
+
for (const cb of this._onReload)
|
|
139
|
+
cb();
|
|
65
140
|
}
|
|
66
|
-
|
|
141
|
+
catch {
|
|
142
|
+
// Corrupt file — keep current policy
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** Stop watching. */
|
|
147
|
+
stopWatching() {
|
|
148
|
+
if (!this._watching)
|
|
149
|
+
return;
|
|
150
|
+
this._watching = false;
|
|
151
|
+
unwatchFile(this._policyPath);
|
|
67
152
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
153
|
+
/** Register a callback for policy reload events. */
|
|
154
|
+
onReload(callback) {
|
|
155
|
+
this._onReload.push(callback);
|
|
156
|
+
}
|
|
157
|
+
_loadOrDefault() {
|
|
158
|
+
if (existsSync(this._policyPath)) {
|
|
159
|
+
try {
|
|
160
|
+
const raw = readFileSync(this._policyPath, "utf-8");
|
|
161
|
+
const parsed = JSON.parse(raw);
|
|
162
|
+
return {
|
|
163
|
+
default: parsed.default || {},
|
|
164
|
+
agents: parsed.agents || {},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Corrupt file
|
|
77
169
|
}
|
|
78
170
|
}
|
|
171
|
+
return {
|
|
172
|
+
default: {
|
|
173
|
+
injectionDetection: "flag",
|
|
174
|
+
injectionMinSeverity: "low",
|
|
175
|
+
injectionDisabledSignatures: [],
|
|
176
|
+
},
|
|
177
|
+
agents: {},
|
|
178
|
+
};
|
|
79
179
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
180
|
+
_save() {
|
|
181
|
+
try {
|
|
182
|
+
mkdirSync(dirname(this._policyPath), { recursive: true });
|
|
183
|
+
writeFileSync(this._policyPath, JSON.stringify(this._policy, null, 2) + "\n", "utf-8");
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Best-effort — don't crash if path is unwritable
|
|
187
|
+
}
|
|
87
188
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
189
|
+
_loadHistory() {
|
|
190
|
+
if (existsSync(this._historyPath)) {
|
|
191
|
+
try {
|
|
192
|
+
const raw = readFileSync(this._historyPath, "utf-8");
|
|
193
|
+
const parsed = JSON.parse(raw);
|
|
194
|
+
return {
|
|
195
|
+
current: parsed.current || 0,
|
|
196
|
+
commits: parsed.commits || [],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catch { /* corrupt file */ }
|
|
200
|
+
}
|
|
201
|
+
return { current: 0, commits: [] };
|
|
95
202
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
203
|
+
_saveHistory() {
|
|
204
|
+
try {
|
|
205
|
+
mkdirSync(dirname(this._historyPath), { recursive: true });
|
|
206
|
+
writeFileSync(this._historyPath, JSON.stringify(this._history, null, 2) + "\n", "utf-8");
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Best-effort
|
|
210
|
+
}
|
|
103
211
|
}
|
|
104
|
-
return violations;
|
|
105
212
|
}
|