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.
@@ -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
- * Policy-as-code enforcement — validate Shroud config against policy constraints.
3
- * Policy files define required categories, minimum confidence levels, and mandatory denylist entries.
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 { ShroudConfig } from "./types.js";
6
- export interface PolicyDef {
7
- /** Policy version. */
8
- version: string;
9
- /** Human-readable policy name. */
10
- name: string;
11
- /** Categories that MUST be enabled (not overridden to disabled). */
12
- requiredCategories?: string[];
13
- /** Minimum global confidence threshold. */
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
- export interface PolicyViolation {
35
- rule: string;
36
- severity: "error" | "warning";
37
- message: string;
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
- * Load a policy from a JSON file.
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 function validatePolicy(config: ShroudConfig, policy: PolicyDef, detectorOverrides?: Record<string, {
48
- enabled?: boolean;
49
- }>): PolicyViolation[];
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
- * Policy-as-code enforcement — validate Shroud config against policy constraints.
3
- * Policy files define required categories, minimum confidence levels, and mandatory denylist entries.
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
- * Load a policy from a JSON file.
30
+ * Policy engine with hot-reload support.
8
31
  */
9
- export function loadPolicy(filePath) {
10
- if (!filePath || !existsSync(filePath))
11
- return null;
12
- try {
13
- const raw = readFileSync(filePath, "utf-8");
14
- return JSON.parse(raw);
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
- catch (e) {
17
- return null;
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
- * Validate a ShroudConfig against a policy.
22
- * Returns an array of violations (empty = compliant).
23
- */
24
- export function validatePolicy(config, policy, detectorOverrides) {
25
- const violations = [];
26
- // Required categories
27
- if (policy.requiredCategories) {
28
- const overrides = detectorOverrides ?? config.detectorOverrides ?? {};
29
- for (const cat of policy.requiredCategories) {
30
- // Check if any rule for this category is explicitly disabled
31
- const disabledRules = Object.entries(overrides)
32
- .filter(([, v]) => v.enabled === false)
33
- .map(([k]) => k);
34
- // This is a simplified check — in practice you'd need to map rules to categories
35
- if (disabledRules.length > 0) {
36
- // Log a warning, not an error, since we can't easily map rule→category here
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
- // Minimum confidence
41
- if (policy.minConfidence !== undefined && config.minConfidence < policy.minConfidence) {
42
- violations.push({
43
- rule: "minConfidence",
44
- severity: "error",
45
- message: `Global minConfidence (${config.minConfidence}) is below policy minimum (${policy.minConfidence})`,
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
- // Maximum confidence
49
- if (policy.maxConfidence !== undefined && config.minConfidence > policy.maxConfidence) {
50
- violations.push({
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
- // Mandatory denylist
57
- if (policy.mandatoryDenylist) {
58
- for (const entry of policy.mandatoryDenylist) {
59
- if (!config.denylist.includes(entry)) {
60
- violations.push({
61
- rule: "mandatoryDenylist",
62
- severity: "error",
63
- message: `Required denylist entry missing: "${entry}"`,
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
- // Prohibited allowlist
69
- if (policy.prohibitedAllowlist) {
70
- for (const entry of policy.prohibitedAllowlist) {
71
- if (config.allowlist.includes(entry)) {
72
- violations.push({
73
- rule: "prohibitedAllowlist",
74
- severity: "error",
75
- message: `Prohibited allowlist entry found: "${entry}"`,
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
- // Audit required
81
- if (policy.requireAudit && !config.auditEnabled) {
82
- violations.push({
83
- rule: "requireAudit",
84
- severity: "error",
85
- message: "Policy requires audit logging to be enabled",
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
- // Canary required
89
- if (policy.requireCanary && !config.canaryEnabled) {
90
- violations.push({
91
- rule: "requireCanary",
92
- severity: "warning",
93
- message: "Policy recommends canary token injection",
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
- // Allowed redaction levels
97
- if (policy.allowedRedactionLevels && !policy.allowedRedactionLevels.includes(config.redactionLevel)) {
98
- violations.push({
99
- rule: "redactionLevel",
100
- severity: "error",
101
- message: `Redaction level "${config.redactionLevel}" is not allowed by policy. Allowed: ${policy.allowedRedactionLevels.join(", ")}`,
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
  }