In-process IO Sanitizer and Guard: in-memory output truncation, secret redaction, Rule 875 writing style linter and branch target protection.

In-process IO Sanitizer and Guard for Claude Code and AI Harness.
git commit, git push) e bloqueia violacoes de travessao, emojis e aspas tipograficas.targets.yaml e bloqueia comandos git push --force ou pushes direcionados para branches de protecao (main, master, develop)./cleanwriting: higieniza rapidamente qualquer texto fornecido como argumento..claude-plugin/: Manifesto do plugin Claude Code e definicao de marketplace.hooks/: Registrador de eventos tool.call, session.start e command.run.test/: Testes unitarios executados com Bun (bun test).bun test
claude plugin validate .hooks/register.ts 59 lines1import type { Register } from "claude-code";
2import {
3 cleanWriting,
4 guardCommand,
5 sanitizeOutput,
6} from "./io-sanitizer.ts";
7
8export const register: Register = (on, options) => {
9 const maxLines = typeof options?.maxLines === "number" ? options.maxLines : 150;
10 const shouldEnforceWriting = options?.enforceRule875 !== false;
11 const shouldEnforceBranch = options?.enforceBranchTargets !== false;
12
13 // Intercept all tool calls
14 on("tool.call", async ($, event, next) => {
15 // PreToolUse Guardrail
16 if (event.tool === "Bash" && (shouldEnforceWriting || shouldEnforceBranch)) {
17 const input = event.input as { command?: string } | undefined;
18 const cmd = input?.command;
19 if (cmd) {
20 const guard = guardCommand(cmd);
21 if (!guard.allowed) {
22 $.ui.log(`[io-guard] BLOQUEADO: ${guard.reason}`);
23 $.ui.toast(`Ação cancelada: ${guard.reason}`, { timeoutMs: 10_000 });
24 return { deny: guard.reason ?? "Ação bloqueada pelo IO-Guard." };
25 }
26 }
27 }
28
29 // Execute the tool beneath
30 const outcome = await next(event);
31
32 // PostToolUse Sanitizer (in-memory redaction & truncation)
33 if (outcome && "text" in outcome && typeof outcome.text === "string") {
34 outcome.text = sanitizeOutput(outcome.text, { maxLines });
35 }
36
37 return outcome;
38 });
39
40 // Register instant command /cleanwriting
41 on("session.start", async ($, event, next) => {
42 await $.command.register({
43 name: "cleanwriting",
44 description: "Higieniza texto removendo travessões e emojis para conformidade com a Regra 875",
45 argumentHint: "[texto]",
46 });
47 return next(event);
48 });
49
50 on("command.run", { command: "cleanwriting" }, async ($, event) => {
51 const text = (event.args || "").trim();
52 if (!text) {
53 return { text: "Uso: /cleanwriting <texto a higienizar>" };
54 }
55 const cleaned = cleanWriting(text);
56 return { text: `Original: ${text}\nHigienizado: ${cleaned}` };
57 });
58};
59hooks/io-sanitizer.ts 247 lines1/**
2 * Shared IO Sanitizer & Guard for AI Harness
3 * Pure TypeScript module with zero external dependencies.
4 * Used by:
5 * - Claude Code Mod (in-process via register(on))
6 * - OpenCode Plugin (in-process via opencode-plugin.js)
7 * - Codex / Kimi / Antigravity (via ai-harness-hook dispatcher)
8 */
9
10export interface TruncateOptions {
11 maxLines?: number;
12 keepHead?: number;
13 keepTail?: number;
14 preserveTests?: boolean;
15}
16
17export interface GuardResult {
18 allowed: boolean;
19 reason?: string;
20 violations?: string[];
21}
22
23export interface WritingValidationResult {
24 valid: boolean;
25 violations: string[];
26}
27
28/* -------------------------------------------------------------------------- */
29/* SECRET REDACTION */
30/* -------------------------------------------------------------------------- */
31
32const PREFIX_PATTERNS = [
33 /sk-[A-Za-z0-9_-]{10,}/g,
34 /ghp_[A-Za-z0-9]{10,}/g,
35 /github_pat_[A-Za-z0-9_]{10,}/g,
36 /gho_[A-Za-z0-9]{10,}/g,
37 /ghu_[A-Za-z0-9]{10,}/g,
38 /ghs_[A-Za-z0-9]{10,}/g,
39 /ghr_[A-Za-z0-9]{10,}/g,
40 /xox[baprs]-[A-Za-z0-9-]{10,}/g,
41 /AIza[A-Za-z0-9_-]{30,}/g,
42 /pplx-[A-Za-z0-9]{10,}/g,
43 /fal_[A-Za-z0-9_-]{10,}/g,
44 /fc-[A-Za-z0-9]{10,}/g,
45 /bb_live_[A-Za-z0-9_-]{10,}/g,
46 /gAAAA[A-Za-z0-9_=-]{20,}/g,
47 /AKIA[A-Z0-9]{16}/g,
48 /sk_live_[A-Za-z0-9]{10,}/g,
49 /sk_test_[A-Za-z0-9]{10,}/g,
50 /rk_live_[A-Za-z0-9]{10,}/g,
51 /SG\.[A-Za-z0-9_-]{10,}/g,
52 /hf_[A-Za-z0-9]{10,}/g,
53 /r8_[A-Za-z0-9]{10,}/g,
54 /npm_[A-Za-z0-9]{10,}/g,
55 /pypi-[A-Za-z0-9_-]{10,}/g,
56 /dop_v1_[A-Za-z0-9]{10,}/g,
57 /doo_v1_[A-Za-z0-9]{10,}/g,
58 /sk_[A-Za-z0-9_]{10,}/g,
59 /tvly-[A-Za-z0-9]{10,}/g,
60 /exa_[A-Za-z0-9]{10,}/g,
61 /gsk_[A-Za-z0-9]{10,}/g,
62 /syt_[A-Za-z0-9]{10,}/g,
63 /mem0_[A-Za-z0-9]{10,}/g,
64 /brv_[A-Za-z0-9]{10,}/g,
65 /fcaebeeb[A-Za-z0-9]{20,}/g,
66];
67
68export function maskToken(token: string): string {
69 if (!token || token.length < 18) return "***";
70 return `${token.slice(0, 6)}...${token.slice(-4)}`;
71}
72
73export function redactSecrets(text: string): string {
74 if (!text) return text;
75 let result = text;
76
77 for (const pattern of PREFIX_PATTERNS) {
78 result = result.replace(pattern, (m) => maskToken(m));
79 }
80
81 // Key=Value assignments
82 result = result.replace(
83 /\b([A-Z0-9_]{1,50}(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z0-9_]{0,50})\s*=\s*(\S+)/gi,
84 (_, name, value) => `${name}=${maskToken(value)}`
85 );
86
87 // JSON key-values
88 result = result.replace(
89 /\b("(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|key_material)")\s*:\s*"([^"]+)"/gi,
90 (_, key, value) => `${key}:"${maskToken(value)}"`
91 );
92
93 // Authorization Bearer
94 result = result.replace(
95 /(Authorization:\s*Bearer\s+)(\S+)/gi,
96 (_, prefix, token) => `${prefix}${maskToken(token)}`
97 );
98
99 // Private keys
100 result = result.replace(
101 /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g,
102 "[REDACTED PRIVATE KEY]"
103 );
104
105 // Database URLs with passwords
106 result = result.replace(
107 /((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:]+:)([^@]+)(@)/gi,
108 (_, prefix, _pass, suffix) => `${prefix}***${suffix}`
109 );
110
111 // JWTs
112 result = result.replace(
113 /(eyJ[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_=-]{4,}){0,2})/g,
114 (m) => maskToken(m)
115 );
116
117 // Generic Basic Auth URLs
118 result = result.replace(
119 /(https?|wss?|ftp):\/\/([^/\s:@]+):([^/\s@]+)@/gi,
120 (_, scheme, user) => `${scheme}://${user}:***@`
121 );
122
123 return result;
124}
125
126/* -------------------------------------------------------------------------- */
127/* OUTPUT TRUNCATION */
128/* -------------------------------------------------------------------------- */
129
130const TEST_RUNNER_PATTERN = /(PASS|FAIL|passed|failed|test.*suites?|Tests:|pytest|ok \d|--- FAIL|--- PASS)/i;
131
132export function truncateOutput(
133 text: string,
134 options: TruncateOptions = {}
135): { text: string; truncated: boolean; linesRemoved: number } {
136 if (!text) return { text: "", truncated: false, linesRemoved: 0 };
137
138 const maxLines = options.maxLines ?? 150;
139 const keepHead = options.keepHead ?? 50;
140 const keepTail = options.keepTail ?? 50;
141 const preserveTests = options.preserveTests ?? true;
142
143 const lines = text.split("\n");
144 if (lines.length <= maxLines) {
145 return { text, truncated: false, linesRemoved: 0 };
146 }
147
148 // Preserve test outputs unless they are truly astronomical (> 300 lines)
149 if (preserveTests && TEST_RUNNER_PATTERN.test(text) && lines.length <= 300) {
150 return { text, truncated: false, linesRemoved: 0 };
151 }
152
153 const head = lines.slice(0, keepHead);
154 const tail = lines.slice(-keepTail);
155 const linesRemoved = lines.length - keepHead - keepTail;
156
157 const truncatedText = [
158 ...head,
159 "",
160 `... [${linesRemoved} lines truncated by AI Harness IO-Sanitizer to save tokens] ...`,
161 "",
162 ...tail,
163 ].join("\n");
164
165 return { text: truncatedText, truncated: true, linesRemoved };
166}
167
168export function sanitizeOutput(text: string, options?: TruncateOptions): string {
169 const redacted = redactSecrets(text);
170 const { text: truncated } = truncateOutput(redacted, options);
171 return truncated;
172}
173
174/* -------------------------------------------------------------------------- */
175/* WRITING STYLE & COMMAND GUARD */
176/* -------------------------------------------------------------------------- */
177
178const EMOJI_REGEX = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{1F1E6}-\u{1F1FF}️]/u;
179const DASHES_REGEX = /[–—―]/;
180const CURLY_QUOTES_REGEX = /[‘’“”]/;
181const MOJIBAKE_REGEX = /(?:â€|Ã[\x80-\xFF]|Â[\x80-\xFF])/;
182
183const OUTBOUND_COMMAND_PATTERN = /_apis\/wit\/[^\s'"]*\/(comments|pullrequests)|\/pullrequests\b|\bgh\s+(pr|issue|release)\b|\baz\s+boards\b|\baz\s+repos\s+pr\b|\baz\s+devops\s+wiki\b|hooks\.slack\.com|webhook\.office\.com|discord(app)?\.com\/api\/webhooks|\bgit\s+commit\b/i;
184
185export function validateWriting(text: string): WritingValidationResult {
186 const violations: string[] = [];
187 if (EMOJI_REGEX.test(text)) violations.push("emoji");
188 if (DASHES_REGEX.test(text)) violations.push("travessao/en-dash");
189 if (CURLY_QUOTES_REGEX.test(text)) violations.push("aspas curvas tipograficas");
190 if (MOJIBAKE_REGEX.test(text)) violations.push("codificacao corrompida (mojibake/UTF-8)");
191
192 return {
193 valid: violations.length === 0,
194 violations,
195 };
196}
197
198export function cleanWriting(text: string): string {
199 if (!text) return text;
200 return text
201 .replace(/\s*[—―]\s*/g, " - ")
202 .replace(/\s*–\s*/g, "-")
203 .replace(/[“”]/g, '"')
204 .replace(/[‘’]/g, "'")
205 .replace(EMOJI_REGEX, "");
206}
207
208export function guardCommand(command: string, targetsYamlContent?: string): GuardResult {
209 if (!command) return { allowed: true };
210
211 // Check writing style on outbound commands (git commit, PR, devops comments)
212 if (OUTBOUND_COMMAND_PATTERN.test(command)) {
213 const val = validateWriting(command);
214 if (!val.valid) {
215 return {
216 allowed: false,
217 reason: `Regra 875 violada no comando: contem ${val.violations.join(", ")}. Reescreva sem travessão (—), sem emoji e com aspas retas.`,
218 violations: val.violations,
219 };
220 }
221 }
222
223 // Branch Target Protection
224 if (/\bgit\s+push\b/.test(command)) {
225 // Block accidental force-push to main/master/develop unconditionally
226 if (/\s+(-f|--force|--force-with-lease)\s+.*(main|master|develop)/i.test(command)) {
227 return {
228 allowed: false,
229 reason: "Force-push em branch protegida (main/master/develop) bloqueado pelo Target Guard.",
230 };
231 }
232
233 if (targetsYamlContent) {
234 if (/protected_branches:\s*\[.*\]/i.test(targetsYamlContent) || /allow_direct_push:\s*false/i.test(targetsYamlContent)) {
235 if (/\b(origin\s+main|origin\s+develop|origin\s+release|origin\s+master)\b/i.test(command)) {
236 return {
237 allowed: false,
238 reason: "Push direto para branch declarada protegida em targets.yaml bloqueado. Crie uma branch de feature e abra PR.",
239 };
240 }
241 }
242 }
243 }
244
245 return { allowed: true };
246}
247