SLOPSHOPPER

io-guard

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

newguardcommandtoast
v0.1.0no licenseupdated 2026-10-07flaviomartil/io-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · io-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cleanwriting ⎿ io-guard: Uso: /cleanwriting <texto a higienizar> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

io-guard

In-process IO Sanitizer and Guard for Claude Code and AI Harness.

O que faz

  1. Truncamento inteligente em memoria: trunca saidas de ferramentas acima de 150 linhas preservando cabecalho e rodape (50 linhas iniciais e 50 finais). Preserva relatorios de testes automatizados ate 300 linhas.
  2. Redacao de segredos em memoria: mascara chaves de API (OpenAI, Anthropic, GitHub PATs, AWS, Google AI), JWTs, strings de conexao com senha e chaves privadas antes de chegarem ao contexto do modelo.
  3. Guardrail de escrita (Regra 875): intercepta execucao de comandos no terminal (git commit, git push) e bloqueia violacoes de travessao, emojis e aspas tipograficas.
  4. Protecao de branches protegidas: consulta targets.yaml e bloqueia comandos git push --force ou pushes direcionados para branches de protecao (main, master, develop).
  5. Comando slash /cleanwriting: higieniza rapidamente qualquer texto fornecido como argumento.

Estrutura

  • .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).

Validacao

bun test
claude plugin validate .
Source 2 files
hooks/register.ts 59 lines
1import 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};
59
hooks/io-sanitizer.ts 247 lines
1/**
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