Skip to content

Commit 20b3742

Browse files
scotty595claude
andcommitted
Add mask outcome for non-blocking data redaction
New PolicyOutcome "mask" that redacts sensitive data instead of blocking. Includes masking helpers, presets (maskSensitiveOutput, maskOutputPattern), and maskedText field on EnforcementDecision. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aa9ed20 commit 20b3742

7 files changed

Lines changed: 196 additions & 19 deletions

File tree

packages/governance/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "governance-sdk",
3-
"version": "0.5.2",
3+
"version": "0.6.0",
44
"description": "AI Agent Governance for TypeScript — policy enforcement, scoring, compliance, and audit for AI agents",
55
"type": "module",
66
"main": "./dist/index.js",

packages/governance/src/dry-run.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export interface DryRunSummary {
6262
wouldAllow: number;
6363
wouldRequireApproval: number;
6464
wouldWarn: number;
65+
wouldMask: number;
6566
blockRate: number;
6667
rulesTriggered: string[];
6768
}
@@ -129,6 +130,7 @@ export async function dryRun(
129130
let wouldAllow = 0;
130131
let wouldRequireApproval = 0;
131132
let wouldWarn = 0;
133+
let wouldMask = 0;
132134

133135
for (const action of scenario.actions) {
134136
const ctx = {
@@ -164,6 +166,8 @@ export async function dryRun(
164166
wouldBlock++;
165167
} else if (decision.outcome === "warn") {
166168
wouldWarn++;
169+
} else if (decision.outcome === "mask") {
170+
wouldMask++;
167171
} else {
168172
wouldAllow++;
169173
}
@@ -182,6 +186,7 @@ export async function dryRun(
182186
wouldAllow,
183187
wouldRequireApproval,
184188
wouldWarn,
189+
wouldMask,
185190
blockRate: totalActions > 0 ? wouldBlock / totalActions : 0,
186191
rulesTriggered: [...rulesTriggered],
187192
},

packages/governance/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ export { RemoteEnforcementError } from "./remote-enforce.js";
391391
export { composePolicies, securityBaseline, complianceOverlay, platformDefaults } from "./policy-compose.js";
392392
export type { PolicySet, ConflictStrategy, ComposeConfig, ComposeResult, PolicyConflict } from "./policy-compose.js";
393393
export { getDefaultStage } from "./policy-stage-defaults.js";
394-
export { inputBlocklist, inputLength, inputPattern, networkAllowlist, scopeBoundary, costBudget, concurrentLimit, outputLength, outputPattern, sensitiveDataFilter } from "./policy-presets-extended.js";
394+
export { inputBlocklist, inputLength, inputPattern, networkAllowlist, scopeBoundary, costBudget, concurrentLimit, outputLength, outputPattern, sensitiveDataFilter, maskSensitiveOutput, maskOutputPattern } from "./policy-presets-extended.js";
395395
export { SENSITIVE_PATTERNS, getSensitivePatterns } from "./conditions/sensitive-patterns.js";
396396
export type { SensitivePattern } from "./conditions/sensitive-patterns.js";
397+
export { maskSensitiveData, maskPattern, maskBlocklistTerms } from "./mask.js";

packages/governance/src/mask.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Masking helpers — redact sensitive data instead of blocking.
3+
*
4+
* Used by the policy engine when a rule's outcome is "mask".
5+
* Applies the same detection patterns as the condition evaluators
6+
* but replaces matched content with [REDACTED].
7+
*/
8+
9+
import { getSensitivePatterns } from "./conditions/sensitive-patterns.js";
10+
11+
const REDACTED = "[REDACTED]";
12+
13+
/**
14+
* Mask sensitive data detected by the built-in sensitive_data_filter patterns.
15+
* Returns the text with all matches replaced by [REDACTED].
16+
*/
17+
export function maskSensitiveData(text: string, patternIds?: string[]): string {
18+
const patterns = getSensitivePatterns(patternIds);
19+
let result = text;
20+
for (const p of patterns) {
21+
// Clone with global flag to replace all occurrences
22+
const global = new RegExp(p.pattern.source, p.pattern.flags.includes("g") ? p.pattern.flags : p.pattern.flags + "g");
23+
result = result.replace(global, REDACTED);
24+
}
25+
return result;
26+
}
27+
28+
/**
29+
* Mask text matching a custom regex pattern.
30+
* Used for output_pattern / input_pattern conditions with mask outcome.
31+
*/
32+
export function maskPattern(text: string, pattern: string, flags?: string): string {
33+
const f = flags ?? "";
34+
const global = new RegExp(pattern, f.includes("g") ? f : f + "g");
35+
return text.replace(global, REDACTED);
36+
}
37+
38+
/**
39+
* Mask blocklisted terms in text.
40+
* Used for blocklist condition with mask outcome.
41+
*/
42+
export function maskBlocklistTerms(text: string, terms: string[]): string {
43+
let result = text;
44+
for (const term of terms) {
45+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46+
const regex = new RegExp(escaped, "gi");
47+
result = result.replace(regex, REDACTED);
48+
}
49+
return result;
50+
}

packages/governance/src/policy-presets-extended.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,31 @@ export function sensitiveDataFilter(patterns?: string[], reason?: string): Polic
146146
stage: "postprocess",
147147
};
148148
}
149+
150+
/** Mask (redact) leaked credentials/keys instead of blocking */
151+
export function maskSensitiveOutput(patterns?: string[], reason?: string): PolicyRule {
152+
return {
153+
id: `mask-sensitive-data`,
154+
name: `Mask sensitive data`,
155+
condition: { type: "sensitive_data_filter", params: { patterns } },
156+
outcome: "mask",
157+
reason: reason ?? `Sensitive data redacted from output`,
158+
priority: 95,
159+
enabled: true,
160+
stage: "postprocess",
161+
};
162+
}
163+
164+
/** Mask (redact) patterns in output instead of blocking */
165+
export function maskOutputPattern(pattern: string, flags?: string, reason?: string): PolicyRule {
166+
return {
167+
id: `mask-output-pattern-${pattern.slice(0, 20).replace(/[^a-z0-9]/gi, "")}`,
168+
name: `Mask output pattern: /${pattern}/`,
169+
condition: { type: "output_pattern", params: { pattern, flags } },
170+
outcome: "mask",
171+
reason: reason ?? `Output pattern redacted`,
172+
priority: 90,
173+
enabled: true,
174+
stage: "postprocess",
175+
};
176+
}

packages/governance/src/policy.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,3 +492,76 @@ describe("decision metadata", () => {
492492
assert.equal(d.rulesEvaluated, 2);
493493
});
494494
});
495+
496+
describe("mask outcome", () => {
497+
test("mask outcome is non-blocking", () => {
498+
const engine = createPolicyEngine({
499+
rules: [makeRule({ outcome: "mask" })],
500+
});
501+
const d = engine.evaluate(makeCtx({ tool: "danger" }));
502+
assert.equal(d.blocked, false);
503+
assert.equal(d.outcome, "mask");
504+
});
505+
506+
test("mask populates maskedText for sensitive_data_filter", () => {
507+
const engine = createPolicyEngine({
508+
rules: [makeRule({
509+
condition: { type: "sensitive_data_filter", params: {} },
510+
outcome: "mask",
511+
reason: "Sensitive data masked",
512+
stage: "postprocess",
513+
})],
514+
});
515+
const d = engine.evaluateStage(
516+
makeCtx({ outputText: "Here is your key: sk-proj-abc123def456ghi789jkl" }),
517+
"postprocess",
518+
);
519+
assert.equal(d.outcome, "mask");
520+
assert.equal(d.blocked, false);
521+
assert.ok(d.maskedText);
522+
assert.ok(!d.maskedText!.includes("sk-proj-"), "API key should be redacted");
523+
assert.ok(d.maskedText!.includes("[REDACTED]"), "Should contain [REDACTED]");
524+
});
525+
526+
test("mask populates maskedText for output_pattern", () => {
527+
const engine = createPolicyEngine({
528+
rules: [makeRule({
529+
condition: { type: "output_pattern", params: { pattern: "\\b\\d{3}-\\d{2}-\\d{4}\\b" } },
530+
outcome: "mask",
531+
reason: "SSN masked",
532+
stage: "postprocess",
533+
})],
534+
});
535+
const d = engine.evaluateStage(
536+
makeCtx({ outputText: "SSN is 123-45-6789 thanks" }),
537+
"postprocess",
538+
);
539+
assert.equal(d.outcome, "mask");
540+
assert.ok(d.maskedText);
541+
assert.ok(!d.maskedText!.includes("123-45-6789"), "SSN should be redacted");
542+
});
543+
544+
test("mask does not populate maskedText when no text available", () => {
545+
const engine = createPolicyEngine({
546+
rules: [makeRule({
547+
condition: { type: "tool_blocked", params: { tools: ["danger"] } },
548+
outcome: "mask",
549+
})],
550+
});
551+
const d = engine.evaluate(makeCtx({ tool: "danger" }));
552+
assert.equal(d.outcome, "mask");
553+
// maskedText may be undefined when there's no text to mask
554+
});
555+
556+
test("block outcome takes priority over mask at same level", () => {
557+
const engine = createPolicyEngine({
558+
rules: [
559+
makeRule({ id: "block-rule", outcome: "block", priority: 100 }),
560+
makeRule({ id: "mask-rule", outcome: "mask", priority: 50 }),
561+
],
562+
});
563+
const d = engine.evaluate(makeCtx({ tool: "danger" }));
564+
assert.equal(d.outcome, "block");
565+
assert.equal(d.blocked, true);
566+
});
567+
});

packages/governance/src/policy.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { getBuiltinConditions } from "./conditions/builtins.js";
99
import { getDefaultStage } from "./policy-stage-defaults.js";
10+
import { maskSensitiveData, maskPattern, maskBlocklistTerms } from "./mask.js";
1011

1112
// ─── Types ──────────────────────────────────────────────────────
1213

@@ -20,7 +21,7 @@ export type PolicyAction =
2021
| "payment"
2122
| "custom";
2223

23-
export type PolicyOutcome = "allow" | "block" | "warn" | "require_approval";
24+
export type PolicyOutcome = "allow" | "block" | "warn" | "require_approval" | "mask";
2425

2526
export type PolicyStage = "preprocess" | "process" | "postprocess";
2627

@@ -80,6 +81,8 @@ export interface EnforcementDecision {
8081
outcome: PolicyOutcome;
8182
evaluatedAt: string;
8283
rulesEvaluated: number;
84+
/** Redacted text when outcome is "mask" — the transformed version with sensitive data replaced */
85+
maskedText?: string;
8386
}
8487

8588
// ─── Condition Registry ─────────────────────────────────────────
@@ -179,19 +182,43 @@ export function createPolicyEngine(config: PolicyEngineConfig = {}): PolicyEngin
179182
const rules: PolicyRule[] = [...(config.rules ?? [])];
180183
const defaultOutcome = config.defaultOutcome ?? "allow";
181184

185+
/** Compute masked text when outcome is "mask" based on the condition type and context. */
186+
function computeMaskedText(rule: PolicyRule, ctx: EnforcementContext): string | undefined {
187+
const { type, params } = rule.condition;
188+
const text = ctx.outputText ?? (ctx.input?.prompt as string | undefined) ?? "";
189+
if (!text) return undefined;
190+
191+
if (type === "sensitive_data_filter") {
192+
return maskSensitiveData(text, params.patterns as string[] | undefined);
193+
}
194+
if (type === "output_pattern" || type === "input_pattern") {
195+
return maskPattern(text, params.pattern as string, params.flags as string | undefined);
196+
}
197+
if (type === "blocklist") {
198+
return maskBlocklistTerms(text, params.terms as string[]);
199+
}
200+
// Fallback: return text unchanged (condition detected something but we don't know how to mask)
201+
return text;
202+
}
203+
204+
function buildDecision(rule: PolicyRule, ctx: EnforcementContext, rulesEvaluated: number): EnforcementDecision {
205+
return {
206+
blocked: rule.outcome === "block" || rule.outcome === "require_approval",
207+
reason: rule.reason,
208+
ruleId: rule.id,
209+
outcome: rule.outcome,
210+
evaluatedAt: new Date().toISOString(),
211+
rulesEvaluated,
212+
...(rule.outcome === "mask" ? { maskedText: computeMaskedText(rule, ctx) } : {}),
213+
};
214+
}
215+
182216
function evaluate(ctx: EnforcementContext): EnforcementDecision {
183217
const active = rules.filter((r) => r.enabled).sort((a, b) => b.priority - a.priority);
184218

185219
for (const rule of active) {
186220
if (evaluateCondition(rule.condition, ctx)) {
187-
return {
188-
blocked: rule.outcome === "block" || rule.outcome === "require_approval",
189-
reason: rule.reason,
190-
ruleId: rule.id,
191-
outcome: rule.outcome,
192-
evaluatedAt: new Date().toISOString(),
193-
rulesEvaluated: active.length,
194-
};
221+
return buildDecision(rule, ctx, active.length);
195222
}
196223
}
197224

@@ -223,14 +250,7 @@ export function createPolicyEngine(config: PolicyEngineConfig = {}): PolicyEngin
223250

224251
for (const rule of active) {
225252
if (evaluateCondition(rule.condition, ctx)) {
226-
return {
227-
blocked: rule.outcome === "block" || rule.outcome === "require_approval",
228-
reason: rule.reason,
229-
ruleId: rule.id,
230-
outcome: rule.outcome,
231-
evaluatedAt: new Date().toISOString(),
232-
rulesEvaluated: active.length,
233-
};
253+
return buildDecision(rule, ctx, active.length);
234254
}
235255
}
236256

0 commit comments

Comments
 (0)