opencrabs 0.5.10

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/opencrabs/opencrabs/releases
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Tests for `redact_secrets()` — the function that scrubs sensitive data
//! from tool output before it's displayed in the TUI and channels.
//!
//! Covers: env var assignments, piped secrets, IPv4 addresses,
//! known key prefixes, hex tokens, and mixed alphanumeric tokens.

use crate::utils::redact_tool_input;
use crate::utils::sanitize::redact_secrets;

// ── Environment variable assignments ───────────────────────────────

#[test]
fn redacts_env_var_pass_assignment() {
    let input = r#"ADMIN_PASS="fuNZEIYc2isz0txisiWTKg8A""#;
    let out = redact_secrets(input);
    assert!(
        !out.contains("fuNZEIYc2isz0txisiWTKg8A"),
        "password leaked: {out}"
    );
    assert!(
        out.contains("ADMIN_PASS="),
        "variable name should be preserved: {out}"
    );
}

#[test]
fn redacts_env_var_secret_assignment() {
    let input = "NEW_SECRET=mgd4EjM8oTrmvWPEbqKys7q2c5H6N7";
    let out = redact_secrets(input);
    assert!(
        !out.contains("mgd4EjM8oTrmvWPEbqKys7q2c5H6N7"),
        "secret leaked: {out}"
    );
}

#[test]
fn redacts_env_var_token_assignment() {
    let input = "API_TOKEN=abc123def456ghi789jkl012mno345";
    let out = redact_secrets(input);
    assert!(
        !out.contains("abc123def456ghi789jkl012mno345"),
        "token leaked: {out}"
    );
}

#[test]
fn redacts_env_var_apikey_assignment() {
    let input = "MY_APIKEY=sk_live_abcdef1234567890abcdef";
    let out = redact_secrets(input);
    assert!(
        !out.contains("abcdef1234567890abcdef"),
        "api key leaked: {out}"
    );
}

#[test]
fn redacts_env_var_credential_assignment() {
    let input = "DB_CREDENTIAL=super_secret_password_12345";
    let out = redact_secrets(input);
    assert!(
        !out.contains("super_secret_password_12345"),
        "credential leaked: {out}"
    );
}

#[test]
fn redacts_env_var_auth_assignment() {
    let input = "SERVICE_AUTH=bearer_token_abcdefghijklmnop";
    let out = redact_secrets(input);
    assert!(
        !out.contains("bearer_token_abcdefghijklmnop"),
        "auth value leaked: {out}"
    );
}

// ── Piped secrets ──────────────────────────────────────────────────

#[test]
fn redacts_piped_secret_double_quotes() {
    let input =
        r#"echo "mgd4EjM8oTrmvWPEbqKys7q2c5H6N7" | docker login -u robot$harbor --password-stdin"#;
    let out = redact_secrets(input);
    assert!(
        !out.contains("mgd4EjM8oTrmvWPEbqKys7q2c5H6N7"),
        "piped secret leaked: {out}"
    );
    assert!(out.contains("echo"), "echo command preserved: {out}");
    assert!(
        out.contains("docker login"),
        "docker command preserved: {out}"
    );
}

#[test]
fn redacts_piped_secret_single_quotes() {
    let input = "echo 'superSecretToken1234567890ab' | kubectl apply -f -";
    let out = redact_secrets(input);
    assert!(
        !out.contains("superSecretToken1234567890ab"),
        "piped secret leaked: {out}"
    );
}

#[test]
fn does_not_redact_short_echo_values() {
    let input = r#"echo "hello" | cat"#;
    let out = redact_secrets(input);
    assert!(
        out.contains("hello"),
        "short non-secret should not be redacted: {out}"
    );
}

// ── IPv4 addresses ─────────────────────────────────────────────────

#[test]
fn redacts_server_ip() {
    let input = "Connected to 198.51.100.23 on port 443";
    let out = redact_secrets(input);
    assert!(!out.contains("198.51.100.23"), "server IP leaked: {out}");
    assert!(
        out.contains("[IP_REDACTED]"),
        "IP should be replaced with [IP_REDACTED]: {out}"
    );
}

#[test]
fn redacts_multiple_ips() {
    let input = "Primary: 10.0.1.5, Secondary: 192.168.1.100";
    let out = redact_secrets(input);
    assert!(!out.contains("10.0.1.5"), "first IP leaked: {out}");
    assert!(!out.contains("192.168.1.100"), "second IP leaked: {out}");
}

#[test]
fn preserves_localhost() {
    let input = "Listening on 127.0.0.1:8080";
    let out = redact_secrets(input);
    assert!(
        out.contains("127.0.0.1"),
        "localhost should be preserved: {out}"
    );
}

#[test]
fn preserves_zero_address() {
    let input = "Binding to 0.0.0.0:3000";
    let out = redact_secrets(input);
    assert!(
        out.contains("0.0.0.0"),
        "0.0.0.0 should be preserved: {out}"
    );
}

// ── Existing patterns (regression) ─────────────────────────────────

#[test]
fn redacts_bearer_token() {
    let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test";
    let out = redact_secrets(input);
    assert!(
        !out.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"),
        "JWT leaked: {out}"
    );
}

#[test]
fn redacts_api_key_assignment() {
    let input = "api_key=sk-proj-abcdef1234567890abcdef1234567890";
    let out = redact_secrets(input);
    assert!(
        !out.contains("abcdef1234567890abcdef1234567890"),
        "API key leaked: {out}"
    );
}

#[test]
fn redacts_github_pat() {
    let input = "Token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef12";
    let out = redact_secrets(input);
    assert!(
        !out.contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef12"),
        "GitHub PAT leaked: {out}"
    );
}

#[test]
fn redacts_long_hex_token() {
    let input = "secret: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
    let out = redact_secrets(input);
    assert!(
        !out.contains("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"),
        "hex token leaked: {out}"
    );
}

#[test]
fn redacts_64_char_hex_install_token() {
    // Shape of a token the inference proxy issues: 32 random bytes, hex encoded.
    let token = "0123456789abcdef".repeat(4);
    assert_eq!(token.len(), 64);
    let input = format!("Authorization: Bearer {token}");
    let out = redact_secrets(&input);
    assert!(!out.contains(&token), "64-char install token leaked: {out}");
}

#[test]
fn redacts_ocp_prefixed_install_token() {
    // Shape of a token issued now: `ocp_` plus 64 hex digits. The hex rule
    // cannot match it, so the `ocp_` prefix entry is what redacts it.
    let token = format!("ocp_{}", "0123456789abcdef".repeat(4));
    let input = format!("saw {token} in the log");
    let out = redact_secrets(&input);
    assert!(!out.contains(&token), "ocp_ install token leaked: {out}");
}

// ── Combined patterns ──────────────────────────────────────────────

#[test]
fn redacts_mixed_output_with_ips_and_secrets() {
    let input = r#"Deploying to 203.0.113.4 with ADMIN_PASS="EXAMPLE_pw_a1b2c3d4e5" and token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef12"#;
    let out = redact_secrets(input);
    assert!(!out.contains("203.0.113.4"), "IP leaked: {out}");
    assert!(
        !out.contains("EXAMPLE_pw_a1b2c3d4e5"),
        "password leaked: {out}"
    );
    assert!(
        !out.contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef12"),
        "GitHub PAT leaked: {out}"
    );
}

#[test]
fn preserves_non_sensitive_output() {
    let input = "Build completed successfully in 42 seconds with 0 errors";
    let out = redact_secrets(input);
    assert_eq!(out, input, "non-sensitive output should be unchanged");
}

#[test]
fn does_not_panic_on_multibyte_char_after_prefix_match() {
    // Repro of the Ctrl+O TUI render panic from 2026-06-01 03:22:
    //   `start byte index 1868 is not a char boundary; it is inside
    //   '→' (bytes 1866..1869) of ...`
    // Cause: when redact_secrets matched a KEY_PREFIX but the suffix
    // was too short to redact, `search_from` advanced by
    // `"[REDACTED]".len()` (10 ASCII bytes) without snapping to a
    // UTF-8 char boundary. The next iteration's slice
    // `&result[search_from..]` panicked when those 10 bytes landed
    // inside a multi-byte character like `→` (3 bytes).
    //
    // Construct an input that:
    //   1. Contains a key prefix (`sk-`) followed by a SHORT suffix
    //      that won't be redacted (forcing the bug path, not the
    //      replace path that always produces an ASCII-only "[REDACTED]"
    //      output).
    //   2. Places a multi-byte char near `after + "[REDACTED]".len()`
    //      from the prefix match position.
    //
    // Before the fix this would panic the redact_secrets call —
    // and via the channel/TUI sanitiser pipeline, panic the render
    // thread when the user expanded a thinking block (Ctrl+O).
    let input = "sk-x → next text here that follows the arrow → and more arrows →→→";
    let _out = redact_secrets(input); // must not panic
}

#[test]
fn handles_multibyte_chars_at_various_offsets_after_prefix() {
    // Property-style coverage: shift the multi-byte char to several
    // offsets past a short-suffix prefix match. Any one of these
    // could pre-fix land inside a char and panic.
    for pad_bytes in 0..=15 {
        let padding: String = "x".repeat(pad_bytes);
        let input = format!("sk-{padding}→ rest of text continues");
        let _out = redact_secrets(&input); // must not panic at any offset
    }
}

#[test]
fn cyrillic_emoji_cjk_in_post_prefix_window_do_not_panic() {
    // Three different multi-byte character widths past a short
    // suffix that didn't trigger replacement:
    //   2-byte (Cyrillic 'я'), 3-byte (CJK '中'), 4-byte (emoji 🦀)
    let inputs = ["sk-z я тест", "sk-z 中文测试", "sk-z 🦀🦀🦀 опенкрабс"];
    for input in inputs {
        let _out = redact_secrets(input); // must not panic
    }
}

// --- Query-param / lowercase key=value + URL-password coverage ---
// Added 2026-06-07: redact_secrets is the redactor for ALL channel output,
// the DB writeback, and build output, but it only caught UPPERCASE env
// vars. Provider-error URLs with `?api_key=...` query params and
// `https://user:pass@` credentials slipped through (a short, unknown-prefix
// token evaded every other pass). These pin the folded-in coverage.

#[test]
fn redact_secrets_query_param_api_key() {
    let text = "fetch failed: https://api.example.com/v1/sync?api_key=sk-shortABC123&page=2";
    let out = redact_secrets(text);
    assert!(
        !out.contains("sk-shortABC123"),
        "api_key query param leaked: {out}"
    );
    assert!(out.contains("api_key=[REDACTED]"), "got: {out}");
    // The rest of the URL stays for context.
    assert!(out.contains("api.example.com"));
    assert!(
        out.contains("page=2"),
        "non-secret param should survive: {out}"
    );
}

#[test]
fn redact_secrets_query_param_token_and_secret() {
    assert!(!redact_secrets("?token=abc123def456 done").contains("abc123def456"));
    assert!(!redact_secrets("client_secret=xyz789short more").contains("xyz789short"));
    assert!(!redact_secrets("password = hunter2plain end").contains("hunter2plain"));
}

#[test]
fn redact_secrets_url_embedded_password() {
    let text = "clone failed for https://alice:s3cr3tpw@git.example.com/repo.git";
    let out = redact_secrets(text);
    assert!(!out.contains("s3cr3tpw"), "URL password leaked: {out}");
    assert!(out.contains("alice:[REDACTED]@"), "got: {out}");
    assert!(out.contains("git.example.com"));
}

#[test]
fn redact_secrets_does_not_overredact_benign_keys() {
    // Non-secret `*_key=` / bare `key=` patterns must survive — only the
    // explicit sensitive allowlist is redacted.
    for benign in [
        "primary_key=5",
        "cache_key=user_42",
        "sort_key=name",
        "idempotency_key=order-123",
    ] {
        assert_eq!(
            redact_secrets(benign),
            benign,
            "benign key wrongly redacted: {benign}"
        );
    }
}

#[test]
fn redact_secrets_keyval_preserves_existing_placeholder() {
    // An already-redacted token placeholder must not be re-mangled by the
    // key=value pass (HEX/MIXED run first).
    let text = "auth_token=aa83802d35bb2c4471e7e96f4eaeafa6c96fe42f set";
    let out = redact_secrets(text);
    assert!(!out.contains("aa83802d"), "secret leaked: {out}");
    assert!(
        !out.contains("[REDACTED]]"),
        "placeholder got mangled: {out}"
    );
}

#[test]
fn redacts_labeled_password_with_colon() {
    let text = "The credentials are:\n- Email: ace@badireto.pt\n- Password: g3Jklf2!&bF6";
    let out = redact_secrets(text);
    assert!(!out.contains("ace@badireto.pt"), "email leaked: {out}");
    assert!(!out.contains("g3Jklf2!&bF6"), "password leaked: {out}");
    assert!(
        out.contains("Email: [REDACTED]"),
        "email not redacted: {out}"
    );
    assert!(
        out.contains("Password: [REDACTED]"),
        "password not redacted: {out}"
    );
}

#[test]
fn redacts_labeled_token_with_colon() {
    let text = "Token: abc123def456ghi789";
    let out = redact_secrets(text);
    assert!(
        out.contains("Token: [REDACTED]"),
        "token not redacted: {out}"
    );
    assert!(!out.contains("abc123def456"), "token leaked: {out}");
}

#[test]
fn redacts_labeled_api_key_with_colon() {
    let text = "API Key: sk-proj-mrRb3y9swLqHv8ZzB9lPH0_V7RPruzdbnXJf34DxU2RCdQnhCYjS99Tj";
    let out = redact_secrets(text);
    assert!(out.contains("[REDACTED]"), "api key not redacted: {out}");
    assert!(!out.contains("mrRb3y"), "api key leaked: {out}");
}

#[test]
fn redacts_email_in_sensitive_key_json() {
    let input = serde_json::json!({"email": "user@example.com", "name": "John"});
    let out = redact_tool_input(&input);
    assert_eq!(out["email"], "[REDACTED]");
    assert_eq!(out["name"], "John");
}