Fold the CLI redaction rules onto ATIF's, and plant the secrets once

a4a392d3a91d · AtlantisPleb · · parent de4c32b7fe25

Fold the CLI redaction rules onto ATIF's, and plant the secrets once

There were two hand-written redaction rule lists -- packages/atif/src/redaction.ts
and packages/openagents-cli/src/trace-store.ts -- and they drifted twice.
`oa_pat_`, `oa_token`, `oa_agent_` and `oa-x-` were in ATIF and missing from the
CLI, so `openagents trace redact` printed "Nothing matched the redaction rules"
over a file full of live OpenAgents tokens. `smct_` was missing from both.
crates/openagents-cli/src/trace.rs restated the same patterns a third time.

The failure mode is structural. Minting a token family meant remembering two
places, and forgetting produced no error -- it produced a redaction that reported
success while leaving the secret in place, which is worse than no command,
because whoever ran it has been told the trace is safe.

There is one list now. ATIF is authoritative and exports it; trace-store.ts
consumes `atifCredentialRules` and adds only what a local session log needs that
an export does not -- a JSON field named like a secret, a broad `NAME=value`
line, and the home-path rewrite to `~`. The restated `oa_*`, `smct_`, `sk-`,
`ghp_`, `Bearer`, `jwt` and seed-phrase rules are gone from the CLI; the ones
ATIF was missing (`nsec1`, `xprv`, `github_pat_`, `glpat-`) moved INTO ATIF, so
the export path gained them too.

The guard is a chain, and each link fails in a different place, so adding a
family walks you to what still needs it:

  1. Add a rule to ATIF, forget to classify it -> compile error, because
     REDACTION_CATEGORY_CLASS is Record<RedactionCategory, ...>.
  2. Classify it, forget the planted secret -> the ATIF fixture test.
  3. Plant it, forget a CLI -> redaction-parity.test.ts and trace_test.rs.

fixtures/redaction/planted-secrets.json is the one place a family is written
down, and all three paths read it. The Rust CLI cannot import the TypeScript
list, so its coupling is that fixture rather than a fourth copy of the patterns.
Every assertion is that the secret BODY is absent: asserting a marker appeared
passes for a prefix swap that leaves the key intact, which is the original bug.

Also re-vendors packages/openagents-cli/src/memory/redaction.ts. It had been
stale since dd8d3e51fe added `machine_token` to ATIF without re-running
scripts/vendor-memory.mjs, so the coder's memory redaction was dropping `smct_`
on the floor and the vendored-memory drift guard was red on main.

Verified by deletion: removing the ATIF fold fails 27 TypeScript tests, and
removing the three new Rust rules fails 2 Rust tests naming the six survivors.

Refs #103

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/trace.rs
  • modified crates/openagents-cli/tests/trace_test.rs
  • added fixtures/redaction/planted-secrets.json
  • modified packages/atif/src/redaction.test.ts
  • modified packages/atif/src/redaction.ts
  • modified packages/openagents-cli/src/memory/redaction.ts
  • modified packages/openagents-cli/src/trace-store.ts
  • added packages/openagents-cli/test/redaction-parity.test.ts
  • modified packages/openagents-cli/test/trace-store.test.ts

Diff

9 files changed, +1409 -797

crates/openagents-cli/src/trace.rs modified +49 -2

@@ -489,6 +489,18 @@ struct RedactionRule {

489 489
/// Order is load-bearing: the specific shapes run before the broad ones so a bearer
490 490
/// token is counted as `bearer_token` rather than swallowed by `env_value`, and each
491 491
/// rule sees the previous rule's substitutions.
492
///
493
/// These patterns are a third statement of the ones in
494
/// `packages/atif/src/redaction.ts`, which is authoritative, and a third statement is
495
/// what let `oa_pat_` and `smct_` leak in the first place. This crate cannot import
496
/// the TypeScript list, so the coupling is a TEST instead: `tests/trace_test.rs` reads
497
/// `fixtures/redaction/planted-secrets.json` — the same file both TypeScript paths
498
/// assert against — and fails when a planted credential body survives redaction here.
499
/// A token family added to ATIF gets a planted secret, and the planted secret fails
500
/// this crate until the rule below exists.
501
///
502
/// The CATEGORY names are this CLI's own and do not have to match ATIF's; only the
503
/// removal is a shared contract.
492 504
fn redaction_rules(home: &str) -> Vec<RedactionRule> {
493 505
    let mut rules = vec![
494 506
        RedactionRule {

@@ -508,6 +520,41 @@ fn redaction_rules(home: &str) -> Vec<RedactionRule> {

508 520
            resolve: None,
509 521
            declines_redacted_capture: false,
510 522
        },
523
        // The PEM block form. It spans lines, so it needs `(?s)`, and it runs
524
        // before every line-oriented rule below so nothing chops it in half.
525
        RedactionRule {
526
            category: "private_key",
527
            pattern: Regex::new(
528
                r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
529
            )
530
            .unwrap(),
531
            replacement: "[REDACTED:private_key]",
532
            resolve: None,
533
            declines_redacted_capture: false,
534
        },
535
        // Payment and wallet material: an invoice or offer someone can pay, and
536
        // an extended PUBLIC key, which is not a spending secret but reveals
537
        // every address a wallet will ever use.
538
        RedactionRule {
539
            category: "wallet_or_payment",
540
            pattern: Regex::new(
541
                r"(?i)\b(?:lnbc[0-9][a-z0-9]{20,}|lntb[0-9][a-z0-9]{20,}|lno1[a-z0-9]{20,}|bc1[a-z0-9]{20,}|(?:xpub|ypub|zpub|tpub)[1-9A-HJ-NP-Za-km-z]{20,})\b",
542
            )
543
            .unwrap(),
544
            replacement: "[REDACTED:wallet_or_payment]",
545
            resolve: None,
546
            declines_redacted_capture: false,
547
        },
548
        // A path into a `.secrets/` directory. The file name alone says which
549
        // credential lives there, and the home-path rule below rewrites only the
550
        // `/Users/<name>` prefix, so it would leave the rest of the path standing.
551
        RedactionRule {
552
            category: "secrets_path",
553
            pattern: Regex::new(r#"(?:\.{1,2}/)?\.secrets/[^\s"'`)<>]+"#).unwrap(),
554
            replacement: "[REDACTED:secrets_path]",
555
            resolve: None,
556
            declines_redacted_capture: false,
557
        },
511 558
        RedactionRule {
512 559
            category: "bearer_token",
513 560
            pattern: Regex::new(r"\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{8,}").unwrap(),

@@ -518,7 +565,7 @@ fn redaction_rules(home: &str) -> Vec<RedactionRule> {

518 565
        RedactionRule {
519 566
            category: "api_key",
520 567
            pattern: Regex::new(
521
                r"\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|gho_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{30,})\b",
568
                r"\b(?:sk-[A-Za-z0-9_-]{16,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{20,})\b",
522 569
            )
523 570
            .unwrap(),
524 571
            replacement: "[REDACTED:api_key]",

@@ -541,7 +588,7 @@ fn redaction_rules(home: &str) -> Vec<RedactionRule> {

541 588
        RedactionRule {
542 589
            category: "oa_token",
543 590
            pattern: Regex::new(
544
                r"\b(?:oa_(?:live|test|sk|key|secret|tok|token|pat)?_?[A-Za-z0-9]{12,}|oa-x-[A-Za-z0-9_-]{4,}|smct_[A-Za-z0-9_-]{8,})\b",
591
                r"\b(?:oa_(?:live|test|sk|key|secret|tok|token|pat)?_?[A-Za-z0-9]{12,}|oa-x-[A-Za-z0-9_-]{4,}|smct_[A-Za-z0-9_-]{6,})\b",
545 592
            )
546 593
            .unwrap(),
547 594
            replacement: "[REDACTED:oa_token]",
crates/openagents-cli/tests/trace_test.rs modified +121

@@ -428,3 +428,124 @@ fn the_openagents_personal_access_token_is_redacted() {

428 428
        assert!(redacted.total > 0);
429 429
    }
430 430
}
431
432
// ---------------------------------------------------------------------------
433
// The shared planted-secret fixture
434
// ---------------------------------------------------------------------------
435
//
436
// `fixtures/redaction/planted-secrets.json` is the one place a token family is
437
// written down. `packages/atif/src/redaction.ts` asserts against it, so does
438
// `packages/openagents-cli`, and so does this crate — which is the point. The
439
// three redaction paths restate the same patterns in three languages, and twice
440
// now a family was added to one and forgotten in another, producing a redaction
441
// that reported success over live tokens rather than an error.
442
//
443
// Every assertion below is that the secret BODY is gone. Asserting that a marker
444
// appeared would pass for the original defect, which swapped `sk-` for a marker
445
// and left `liveSECRETVALUE123` sitting in the file.
446
447
#[derive(serde::Deserialize)]
448
struct PlantedSecret {
449
    label: String,
450
    category: String,
451
    credential: bool,
452
    raw: String,
453
    leak: String,
454
}
455
456
#[derive(serde::Deserialize)]
457
struct PlantedSecrets {
458
    secrets: Vec<PlantedSecret>,
459
}
460
461
/// Read the fixture from the repository root. A missing or unreadable fixture is a
462
/// hard failure: a suite that silently checks nothing is the defect, not the guard.
463
fn planted_from_fixture() -> Vec<PlantedSecret> {
464
    let path = Path::new(env!("CARGO_MANIFEST_DIR"))
465
        .join("../../fixtures/redaction/planted-secrets.json");
466
    let text = fs::read_to_string(&path).unwrap_or_else(|error| {
467
        panic!(
468
            "the shared redaction fixture is unreadable at {}: {error}. \
469
             Every language's redaction guard reads this file; without it nothing is asserted.",
470
            path.display()
471
        )
472
    });
473
    let parsed: PlantedSecrets = serde_json::from_str(&text).expect("fixture is not valid JSON");
474
    parsed.secrets
475
}
476
477
/// Only the credential entries are a floor for this CLI. The rest of the fixture is
478
/// PII and location shapes that the ATIF export path removes and a local trace
479
/// deliberately handles differently — a home path becomes `~` here, not a tag.
480
fn planted_credentials() -> Vec<PlantedSecret> {
481
    planted_from_fixture()
482
        .into_iter()
483
        .filter(|entry| entry.credential)
484
        .collect()
485
}
486
487
#[test]
488
fn every_credential_in_the_shared_fixture_is_removed() {
489
    let home = "/Users/octavia";
490
    let credentials = planted_credentials();
491
    assert!(
492
        credentials.len() >= 16,
493
        "the fixture yielded only {} credentials, which is too few to be the real file",
494
        credentials.len()
495
    );
496
497
    let mut survivors: Vec<String> = Vec::new();
498
    for entry in &credentials {
499
        let redacted = redact_text(&entry.raw, home);
500
        if redacted.text.contains(&entry.leak) {
501
            survivors.push(format!("{} ({})", entry.label, entry.category));
502
        }
503
        assert!(
504
            redacted.total >= 1,
505
            "{} produced no redaction at all, so `oa trace redact` would report \
506
             'Nothing matched the redaction rules' over a live credential",
507
            entry.label
508
        );
509
    }
510
511
    assert!(
512
        survivors.is_empty(),
513
        "these planted credentials survived `redact_text`, which means \
514
         fixtures/redaction/planted-secrets.json covers a family that \
515
         crates/openagents-cli/src/trace.rs does not: {survivors:?}"
516
    );
517
}
518
519
#[test]
520
fn every_credential_survives_nothing_when_planted_in_one_document() {
521
    // Rules run in sequence over one growing string, so an earlier rule can eat the
522
    // text a later rule was going to match. Line-at-a-time checks miss that.
523
    let home = "/Users/octavia";
524
    let credentials = planted_credentials();
525
    let document = credentials
526
        .iter()
527
        .map(|entry| entry.raw.as_str())
528
        .collect::<Vec<_>>()
529
        .join("\n");
530
531
    let redacted = redact_text(&document, home);
532
    let survivors: Vec<&str> = credentials
533
        .iter()
534
        .filter(|entry| redacted.text.contains(&entry.leak))
535
        .map(|entry| entry.label.as_str())
536
        .collect();
537
    assert!(
538
        survivors.is_empty(),
539
        "planted credentials survived a combined document: {survivors:?}"
540
    );
541
542
    // The report is what an operator reads. It carries counts, never the match.
543
    let report = serde_json::to_string(&redacted.counts).unwrap();
544
    for entry in &credentials {
545
        assert!(
546
            !report.contains(&entry.leak),
547
            "{} appeared in the redaction report",
548
            entry.label
549
        );
550
    }
551
}
fixtures/redaction/planted-secrets.json added +293

@@ -0,0 +1,293 @@

1
{
2
  "$comment": [
3
    "Planted secrets, one or more per redaction category, shared by every path that redacts.",
4
    "",
5
    "`category` names the category in packages/atif/src/redaction.ts, the authoritative rule",
6
    "list. `raw` is a line that carries the planted value. `leak` is the substring that MUST",
7
    "NOT survive redaction -- it is a fragment of the secret BODY, never its prefix, because a",
8
    "prefix check passes for the exact bug this fixture exists to catch: a redaction that swaps",
9
    "`sk-liveSECRET` for `[REDACTED]liveSECRET` and reports success.",
10
    "",
11
    "`credential` marks a category as key material or an access token. Every credential entry",
12
    "is a floor that all three redaction paths must clear:",
13
    "",
14
    "  - packages/atif/src/redaction.ts        (the ATIF export path; asserts the category too)",
15
    "  - packages/openagents-cli/src/trace-store.ts  (openagents trace redact)",
16
    "  - crates/openagents-cli/src/trace.rs          (oa trace redact)",
17
    "",
18
    "Nothing in here is a real credential. The values are shaped like the families they stand",
19
    "for and are drawn from published test vectors where one exists.",
20
    "",
21
    "Adding a token family: add its rule to packages/atif/src/redaction.ts, classify it in",
22
    "REDACTION_CATEGORY_CLASS, add an entry here, and the parity tests will tell you which of",
23
    "the three paths still needs it."
24
  ],
25
  "secrets": [
26
    {
27
      "label": "OpenAI sk- key",
28
      "category": "provider_key",
29
      "credential": true,
30
      "raw": "use sk-abcdefghijklmnop0123456789ABCD now",
31
      "leak": "abcdefghijklmnop0123456789ABCD"
32
    },
33
    {
34
      "label": "OpenRouter sk-or- key",
35
      "category": "provider_key",
36
      "credential": true,
37
      "raw": "sk-or-v1-0011223344556677889900aabbccddeeff00112233",
38
      "leak": "0011223344556677"
39
    },
40
    {
41
      "label": "Anthropic sk-ant- key",
42
      "category": "provider_key",
43
      "credential": true,
44
      "raw": "key sk-ant-api03-AbCdEf0123456789AbCdEf done",
45
      "leak": "AbCdEf0123456789"
46
    },
47
    {
48
      "label": "Stripe sk_live_ key",
49
      "category": "provider_key",
50
      "credential": true,
51
      "raw": "STRIPE=sk_live_0123456789abcdefABCDEF rest",
52
      "leak": "0123456789abcdefABCDEF"
53
    },
54
    {
55
      "label": "oa_agent_ token",
56
      "category": "oa_agent_token",
57
      "credential": true,
58
      "raw": "bearer creds oa_agent_AbCdEf123456789xyz end",
59
      "leak": "AbCdEf123456789xyz"
60
    },
61
    {
62
      "label": "generic oa_ token",
63
      "category": "oa_token",
64
      "credential": true,
65
      "raw": "auth oa_live_abcdef0123456789abcdef next",
66
      "leak": "abcdef0123456789abcdef"
67
    },
68
    {
69
      "label": "oa_pat_ personal access token",
70
      "category": "oa_token",
71
      "credential": true,
72
      "raw": "the token is oa_pat_abc123def456ghi789jkl012 here",
73
      "leak": "abc123def456ghi789jkl012"
74
    },
75
    {
76
      "label": "machine pairing token",
77
      "category": "machine_token",
78
      "credential": true,
79
      "raw": "the machine token is smct_machine-secret today",
80
      "leak": "machine-secret"
81
    },
82
    {
83
      "label": "X verification code",
84
      "category": "x_code",
85
      "credential": true,
86
      "raw": "Code: oa-x-9f2bc-defG",
87
      "leak": "9f2bc-defG"
88
    },
89
    {
90
      "label": "AWS access key",
91
      "category": "aws_key",
92
      "credential": true,
93
      "raw": "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE here",
94
      "leak": "IOSFODNN7EXAMPLE"
95
    },
96
    {
97
      "label": "Google API key",
98
      "category": "google_key",
99
      "credential": true,
100
      "raw": "gkey AIzaSyA1234567890abcdefghijklmnopqrstuv ok",
101
      "leak": "SyA1234567890abcdefghijklmnopqrstuv"
102
    },
103
    {
104
      "label": "Slack token",
105
      "category": "slack_token",
106
      "credential": true,
107
      "raw": "slack xoxb-1234567890-abcdefghijkl set",
108
      "leak": "1234567890-abcdefghijkl"
109
    },
110
    {
111
      "label": "GitHub fine-grained PAT",
112
      "category": "github_token",
113
      "credential": true,
114
      "raw": "github_pat_11ABCDEFG0abcdefghijkl_0123456789abcdefghijklmnopqrstuvwx set",
115
      "leak": "11ABCDEFG0abcdefghijkl"
116
    },
117
    {
118
      "label": "GitLab project access token",
119
      "category": "gitlab_token",
120
      "credential": true,
121
      "raw": "ci used glpat-ABCdef0123456789xyz here",
122
      "leak": "ABCdef0123456789xyz"
123
    },
124
    {
125
      "label": "GitHub token",
126
      "category": "github_token",
127
      "credential": true,
128
      "raw": "ghp_0123456789abcdefABCDEF0123456789abcd token",
129
      "leak": "0123456789abcdefABCDEF0123456789abcd"
130
    },
131
    {
132
      "label": "JWT",
133
      "category": "jwt",
134
      "credential": true,
135
      "raw": "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
136
      "leak": "SflKxwRJSMeKKF2QT4"
137
    },
138
    {
139
      "label": "Bearer credential",
140
      "category": "bearer",
141
      "credential": true,
142
      "raw": "Authorization: Bearer abcdef0123456789ABCDEFxyz",
143
      "leak": "abcdef0123456789ABCDEFxyz"
144
    },
145
    {
146
      "label": "env secret line",
147
      "category": "env_secret",
148
      "credential": true,
149
      "raw": "DATABASE_PASSWORD=hunter2supersecretvalue more",
150
      "leak": "hunter2supersecretvalue"
151
    },
152
    {
153
      "label": "lightning invoice",
154
      "category": "wallet_or_payment",
155
      "credential": true,
156
      "raw": "pay lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq more",
157
      "leak": "pvjluezpp5qqqsyqcyq5rqwzqfq"
158
    },
159
    {
160
      "label": "bolt12 offer",
161
      "category": "wallet_or_payment",
162
      "credential": true,
163
      "raw": "offer lno1pqpsgq0123456789abcdefghijklmnop here",
164
      "leak": "pqpsgq0123456789abcdefghijklmnop"
165
    },
166
    {
167
      "label": "on-chain bc1 address",
168
      "category": "wallet_or_payment",
169
      "credential": true,
170
      "raw": "send to bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq please",
171
      "leak": "qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
172
    },
173
    {
174
      "label": "xpub",
175
      "category": "wallet_or_payment",
176
      "credential": true,
177
      "raw": "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz key",
178
      "leak": "6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZ"
179
    },
180
    {
181
      "label": "nostr nsec private key",
182
      "category": "private_key",
183
      "credential": true,
184
      "raw": "signing with nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5 today",
185
      "leak": "vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"
186
    },
187
    {
188
      "label": "PEM private key",
189
      "category": "private_key",
190
      "credential": true,
191
      "raw": "k=-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEABBBBBBBB\nmoredata==\n-----END OPENSSH PRIVATE KEY-----\nend",
192
      "leak": "b3BlbnNzaC1rZXkt"
193
    },
194
    {
195
      "label": "mnemonic seed phrase",
196
      "category": "mnemonic",
197
      "credential": true,
198
      "raw": "seed legal winner thank year wave sausage worth useful legal winner thank yellow done",
199
      "leak": "legal winner thank year wave sausage worth useful legal winner thank yellow"
200
    },
201
    {
202
      "label": "BIP-39 abandon test vector",
203
      "category": "mnemonic",
204
      "credential": true,
205
      "raw": "my backup is abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about ok",
206
      "leak": "abandon abandon abandon"
207
    },
208
    {
209
      "label": ".secrets path",
210
      "category": "secrets_path",
211
      "credential": true,
212
      "raw": "read .secrets/tailnet.env carefully",
213
      "leak": "tailnet.env"
214
    },
215
    {
216
      "label": "owner identifier",
217
      "category": "owner_id",
218
      "credential": false,
219
      "raw": "owner github:12345678 claimed it",
220
      "leak": "12345678"
221
    },
222
    {
223
      "label": "email PII",
224
      "category": "email",
225
      "credential": false,
226
      "raw": "contact me at jane.doe@example.com please",
227
      "leak": "jane.doe@example.com"
228
    },
229
    {
230
      "label": "phone PII",
231
      "category": "phone",
232
      "credential": false,
233
      "raw": "call (312) 555-0198 after intake",
234
      "leak": "312) 555-0198"
235
    },
236
    {
237
      "label": "SSN PII",
238
      "category": "ssn",
239
      "credential": false,
240
      "raw": "SSN: 123-45-6789 appears in the attachment",
241
      "leak": "123-45-6789"
242
    },
243
    {
244
      "label": "date of birth PHI",
245
      "category": "date_of_birth",
246
      "credential": false,
247
      "raw": "DOB: 04/23/1978 on the health form",
248
      "leak": "04/23/1978"
249
    },
250
    {
251
      "label": "medical record PHI",
252
      "category": "medical_record_id",
253
      "credential": false,
254
      "raw": "MRN: HOSP-928374 belongs to the patient packet",
255
      "leak": "HOSP-928374"
256
    },
257
    {
258
      "label": "home path",
259
      "category": "home_path",
260
      "credential": false,
261
      "raw": "open /Users/alice/work/secret.txt then",
262
      "leak": "/Users/alice"
263
    },
264
    {
265
      "label": "linux home path",
266
      "category": "home_path",
267
      "credential": false,
268
      "raw": "cat /home/bob/.ssh/id_rsa fails",
269
      "leak": "/home/bob"
270
    },
271
    {
272
      "label": "file URL",
273
      "category": "file_url",
274
      "credential": false,
275
      "raw": "see file:///Users/carol/private/doc.md now",
276
      "leak": "carol/private/doc.md"
277
    },
278
    {
279
      "label": "private internal IP",
280
      "category": "ip",
281
      "credential": false,
282
      "raw": "host 10.0.0.42 and 100.96.1.2 internal",
283
      "leak": "10.0.0.42"
284
    },
285
    {
286
      "label": "contiguous base64 blob",
287
      "category": "long_blob",
288
      "credential": false,
289
      "raw": "token=QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5YWJjZGVm end",
290
      "leak": "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5YWJjZGVm"
291
    }
292
  ]
293
}
packages/atif/src/redaction.test.ts modified +183 -321

@@ -1,5 +1,7 @@

1
import { describe, expect, test } from "vite-plus/test"
2
import { Effect } from "effect"
1
import { readFileSync } from "node:fs";
2
3
import { describe, expect, test } from "vite-plus/test";
4
import { Effect } from "effect";
3 5
4 6
import {
5 7
  ATIF_PINNED_SCHEMA_VERSION,

@@ -7,10 +9,14 @@ import {

7 9
  AtifTrajectory,
8 10
  atifTraceTripwire,
9 11
  validateAtifTrajectory,
10
} from "./trace-schema.ts"
12
} from "./trace-schema.ts";
11 13
import {
14
  REDACTION_CATEGORY_CLASS,
12 15
  REDACTION_SERVICE_REF,
13 16
  TraceRedactor,
17
  atifRedactionRules,
18
  isCredentialCategory,
19
  type RedactionCategory,
14 20
  TraceRedactorLive,
15 21
  redactForExternalInference,
16 22
  redactString,

@@ -19,281 +25,137 @@ import {

19 25
  redactTraceValue,
20 26
  redactValue,
21 27
  type RedactionResult,
22
} from "./redaction.ts"
28
} from "./redaction.ts";
23 29
24
const red = (s: string): RedactionResult<string> => redactString(s)
30
const red = (s: string): RedactionResult<string> => redactString(s);
25 31
32
/**
33
 * The planted secrets are a SHARED fixture, not a list local to this file.
34
 *
35
 * `packages/openagents-cli` and `crates/openagents-cli` assert against the same
36
 * file, so a token family added here is asserted in all three redaction paths
37
 * rather than in whichever one the author happened to be editing. That is the
38
 * fix for the drift that let `oa_pat_` and `smct_` through.
39
 */
26 40
const SECRET_FIXTURES: ReadonlyArray<{
27
  label: string
28
  raw: string
29
  leak: string
30
  category: string
31
}> = [
32
  {
33
    label: "OpenAI sk- key",
34
    raw: "use sk-abcdefghijklmnop0123456789ABCD now",
35
    leak: "sk-abcdefghijklmnop",
36
    category: "provider_key",
37
  },
38
  {
39
    label: "OpenRouter sk-or- key",
40
    raw: "sk-or-v1-0011223344556677889900aabbccddeeff00112233",
41
    leak: "0011223344556677",
42
    category: "provider_key",
43
  },
44
  {
45
    label: "Anthropic sk-ant- key",
46
    raw: "key sk-ant-api03-AbCdEf0123456789AbCdEf done",
47
    leak: "AbCdEf0123456789",
48
    category: "provider_key",
49
  },
50
  {
51
    label: "Stripe sk_live_ key",
52
    raw: "STRIPE=sk_live_0123456789abcdefABCDEF rest",
53
    leak: "sk_live_0123456789",
54
    category: "provider_key",
55
  },
56
  {
57
    label: "oa_agent_ token",
58
    raw: "bearer creds oa_agent_AbCdEf123456789xyz end",
59
    leak: "oa_agent_AbCdEf",
60
    category: "oa_agent_token",
61
  },
62
  {
63
    label: "generic oa_ token",
64
    raw: "auth oa_live_abcdef0123456789abcdef next",
65
    leak: "oa_live_abcdef0123456789",
66
    category: "oa_token",
67
  },
68
  {
69
    label: "oa_pat_ personal access token",
70
    raw: "the token is oa_pat_abc123def456ghi789jkl012 here",
71
    leak: "oa_pat_abc123def456",
72
    category: "oa_token",
73
  },
74
  {
75
    // Minted by computer pairing. Hyphenated, which every other token rule
76
    // stops at, so it survived an export intact.
77
    label: "machine pairing token",
78
    raw: "the machine token is smct_machine-secret today",
79
    leak: "smct_machine-secret",
80
    category: "machine_token",
81
  },
82
  {
83
    label: "X verification code",
84
    raw: "Code: oa-x-9f2bc-defG",
85
    leak: "9f2bc-defG",
86
    category: "x_code",
87
  },
88
  {
89
    label: "owner identifier",
90
    raw: "owner github:12345678 claimed it",
91
    leak: "12345678",
92
    category: "owner_id",
93
  },
94
  {
95
    label: "AWS access key",
96
    raw: "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE here",
97
    leak: "AKIAIOSFODNN7EXAMPLE",
98
    category: "aws_key",
99
  },
100
  {
101
    label: "Google API key",
102
    raw: "gkey AIzaSyA1234567890abcdefghijklmnopqrstuv ok",
103
    leak: "AIzaSyA1234567890",
104
    category: "google_key",
105
  },
106
  {
107
    label: "Slack token",
108
    raw: "slack xoxb-1234567890-abcdefghijkl set",
109
    leak: "xoxb-1234567890",
110
    category: "slack_token",
111
  },
112
  {
113
    label: "GitHub token",
114
    raw: "ghp_0123456789abcdefABCDEF0123456789abcd token",
115
    leak: "ghp_0123456789",
116
    category: "github_token",
117
  },
118
  {
119
    label: "JWT",
120
    raw: "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
121
    leak: "SflKxwRJSMeKKF2QT4",
122
    category: "jwt",
123
  },
124
  {
125
    label: "Bearer credential",
126
    raw: "Authorization: Bearer abcdef0123456789ABCDEFxyz",
127
    leak: "abcdef0123456789ABCDEFxyz",
128
    category: "bearer",
129
  },
130
  {
131
    label: "env secret line",
132
    raw: "DATABASE_PASSWORD=hunter2supersecretvalue more",
133
    leak: "hunter2supersecretvalue",
134
    category: "env_secret",
135
  },
136
  {
137
    label: "email PII",
138
    raw: "contact me at jane.doe@example.com please",
139
    leak: "jane.doe@example.com",
140
    category: "email",
141
  },
142
  {
143
    label: "phone PII",
144
    raw: "call (312) 555-0198 after intake",
145
    leak: "312) 555-0198",
146
    category: "phone",
147
  },
148
  {
149
    label: "SSN PII",
150
    raw: "SSN: 123-45-6789 appears in the attachment",
151
    leak: "123-45-6789",
152
    category: "ssn",
153
  },
154
  {
155
    label: "date of birth PHI",
156
    raw: "DOB: 04/23/1978 on the health form",
157
    leak: "04/23/1978",
158
    category: "date_of_birth",
159
  },
160
  {
161
    label: "medical record PHI",
162
    raw: "MRN: HOSP-928374 belongs to the patient packet",
163
    leak: "HOSP-928374",
164
    category: "medical_record_id",
165
  },
166
  {
167
    label: "home path",
168
    raw: "open /Users/alice/work/secret.txt then",
169
    leak: "/Users/alice",
170
    category: "home_path",
171
  },
172
  {
173
    label: "linux home path",
174
    raw: "cat /home/bob/.ssh/id_rsa fails",
175
    leak: "/home/bob",
176
    category: "home_path",
177
  },
178
  {
179
    label: "file URL",
180
    raw: "see file:///Users/carol/private/doc.md now",
181
    leak: "carol",
182
    category: "file_url",
183
  },
184
  {
185
    label: ".secrets path",
186
    raw: "read .secrets/tailnet.env carefully",
187
    leak: ".secrets/tailnet.env",
188
    category: "secrets_path",
189
  },
190
  {
191
    label: "lightning invoice",
192
    raw: "pay lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq more",
193
    leak: "lnbc2500u1",
194
    category: "wallet_or_payment",
195
  },
196
  {
197
    label: "bolt12 offer",
198
    raw: "offer lno1pqpsgq0123456789abcdefghijklmnop here",
199
    leak: "lno1pqpsgq",
200
    category: "wallet_or_payment",
201
  },
202
  {
203
    label: "on-chain bc1 address",
204
    raw: "send to bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq please",
205
    leak: "bc1qar0srrr7xfkvy",
206
    category: "wallet_or_payment",
207
  },
208
  {
209
    label: "xpub",
210
    raw: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz key",
211
    leak: "xpub6CUGRUonZSQ4T",
212
    category: "wallet_or_payment",
213
  },
214
  {
215
    label: "private internal IP",
216
    raw: "host 10.0.0.42 and 100.96.1.2 internal",
217
    leak: "10.0.0.42",
218
    category: "ip",
219
  },
220
  {
221
    label: "PEM private key",
222
    raw: "k=-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEABBBBBBBB\nmoredata==\n-----END OPENSSH PRIVATE KEY-----\nend",
223
    leak: "b3BlbnNzaC1rZXkt",
224
    category: "private_key",
225
  },
226
  {
227
    label: "mnemonic seed phrase",
228
    raw: "seed legal winner thank year wave sausage worth useful legal winner thank yellow done",
229
    leak: "legal winner thank year wave sausage worth useful legal winner thank yellow",
230
    category: "mnemonic",
231
  },
232
]
41
  label: string;
42
  raw: string;
43
  leak: string;
44
  category: string;
45
  credential: boolean;
46
}> = JSON.parse(
47
  readFileSync(
48
    new URL("../../../fixtures/redaction/planted-secrets.json", import.meta.url),
49
    "utf8",
50
  ),
51
).secrets;
52
53
describe("the shared planted-secret fixture", () => {
54
  const ruleCategories = new Set(atifRedactionRules.map((rule) => rule.category));
55
  const fixtureCategories = new Set(SECRET_FIXTURES.map((fx) => fx.category));
56
57
  test("classifies every category, so a new one cannot arrive unclassified", () => {
58
    // `REDACTION_CATEGORY_CLASS` is `Record<RedactionCategory, ...>`, so this is
59
    // already a compile error; the runtime check catches a category that was
60
    // added to the RULES list without being added to the union at all.
61
    for (const category of ruleCategories) {
62
      expect(
63
        REDACTION_CATEGORY_CLASS[category],
64
        `${category} has a rule but no entry in REDACTION_CATEGORY_CLASS`,
65
      ).toBeDefined();
66
    }
67
  });
68
69
  test("plants a secret for every category that has a rule", () => {
70
    const uncovered = [...ruleCategories].filter((c) => !fixtureCategories.has(c));
71
    expect(
72
      uncovered,
73
      `these categories have a rule and no planted secret in ` +
74
        `fixtures/redaction/planted-secrets.json, so nothing asserts that the ` +
75
        `openagents-cli redaction paths cover them`,
76
    ).toEqual([]);
77
  });
78
79
  test("marks credential entries the same way the rule list does", () => {
80
    for (const fx of SECRET_FIXTURES) {
81
      expect(fx.credential, `${fx.label} is filed under ${fx.category}`).toBe(
82
        isCredentialCategory(fx.category as RedactionCategory),
83
      );
84
    }
85
  });
86
87
  test("leaks name a fragment of the secret body, never only its prefix", () => {
88
    // A test that asserts a MARKER appeared passes for a redaction that swapped
89
    // `sk-liveSECRET` for `[REDACTED]liveSECRET`. Every leak here has to be
90
    // something whose survival means the secret survived.
91
    for (const fx of SECRET_FIXTURES) {
92
      expect(fx.raw, `${fx.label} does not contain its own leak`).toContain(fx.leak);
93
      expect(fx.leak.length, `${fx.label} has a trivially short leak`).toBeGreaterThan(7);
94
    }
95
  });
96
});
233 97
234 98
describe("redactString", () => {
235 99
  for (const fx of SECRET_FIXTURES) {
236 100
    test(`${fx.label} is scrubbed`, () => {
237
      const r = red(fx.raw)
238
      expect(r.value).not.toContain(fx.leak)
239
      expect(r.report.counts[fx.category] ?? 0).toBeGreaterThanOrEqual(1)
240
      expect(r.report.total).toBeGreaterThanOrEqual(1)
241
    })
101
      const r = red(fx.raw);
102
      expect(r.value).not.toContain(fx.leak);
103
      expect(r.report.counts[fx.category] ?? 0).toBeGreaterThanOrEqual(1);
104
      expect(r.report.total).toBeGreaterThanOrEqual(1);
105
    });
242 106
  }
243 107
244 108
  test("slash-separated prose is not redacted as a long blob", () => {
245 109
    const prose =
246
      "states: candidate/shadow/released/active/rejected/rolled and schema/service/IPC/process/PTY/task/test/output/redaction"
247
    const r = red(prose)
248
    expect(r.value).toBe(prose)
249
    expect(r.report.counts.long_blob ?? 0).toBe(0)
250
  })
110
      "states: candidate/shadow/released/active/rejected/rolled and schema/service/IPC/process/PTY/task/test/output/redaction";
111
    const r = red(prose);
112
    expect(r.value).toBe(prose);
113
    expect(r.report.counts.long_blob ?? 0).toBe(0);
114
  });
251 115
252 116
  test("a contiguous base64 blob is still redacted", () => {
253
    const blob = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5YWJjZGVm"
254
    const r = red(`token=${blob} end`)
255
    expect(r.value).not.toContain(blob)
256
    expect(r.report.counts.long_blob ?? 0).toBeGreaterThanOrEqual(1)
257
  })
117
    const blob = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5YWJjZGVm";
118
    const r = red(`token=${blob} end`);
119
    expect(r.value).not.toContain(blob);
120
    expect(r.report.counts.long_blob ?? 0).toBeGreaterThanOrEqual(1);
121
  });
258 122
259 123
  test("prose that only matches the mnemonic SHAPE is not redacted", () => {
260 124
    // 12 short lowercase words, so it matches the candidate regex, but the words
261 125
    // are not all BIP39 words -> it is ordinary prose and must be preserved.
262
    const prose = "the team will ship this year and then start over next month"
263
    const r = red(prose)
264
    expect(r.value).toBe(prose)
265
    expect(r.report.counts.mnemonic ?? 0).toBe(0)
266
  })
126
    const prose = "the team will ship this year and then start over next month";
127
    const r = red(prose);
128
    expect(r.value).toBe(prose);
129
    expect(r.report.counts.mnemonic ?? 0).toBe(0);
130
  });
267 131
268 132
  test("a real BIP39 seed phrase inside prose is still redacted", () => {
269 133
    const r = red(
270 134
      "backup phrase legal winner thank year wave sausage worth useful legal winner thank yellow now",
271
    )
272
    expect(r.value).not.toContain("legal winner thank year wave sausage")
273
    expect(r.report.counts.mnemonic ?? 0).toBeGreaterThanOrEqual(1)
274
  })
135
    );
136
    expect(r.value).not.toContain("legal winner thank year wave sausage");
137
    expect(r.report.counts.mnemonic ?? 0).toBeGreaterThanOrEqual(1);
138
  });
275 139
276 140
  test("known public false positives are preserved", () => {
277 141
    const r = red(
278 142
      "See https://openagents.com/trace/abc-123 and https://github.com/OpenAgentsInc/openagents/issues/6219 on openagents/khala for #6219.",
279
    )
280
    expect(r.value).toContain("https://openagents.com/trace/abc-123")
281
    expect(r.value).toContain(
282
      "https://github.com/OpenAgentsInc/openagents/issues/6219",
283
    )
284
    expect(r.value).toContain("openagents/khala")
285
    expect(r.value).toContain("#6219")
286
    expect(r.report.total).toBe(0)
287
  })
143
    );
144
    expect(r.value).toContain("https://openagents.com/trace/abc-123");
145
    expect(r.value).toContain("https://github.com/OpenAgentsInc/openagents/issues/6219");
146
    expect(r.value).toContain("openagents/khala");
147
    expect(r.value).toContain("#6219");
148
    expect(r.report.total).toBe(0);
149
  });
288 150
289 151
  test("is deterministic", () => {
290
    const input = SECRET_FIXTURES.map(f => f.raw).join(" | ")
291
    const a = redactTraceString(input)
292
    const b = redactTraceString(input)
293
    expect(a.value).toBe(b.value)
294
    expect(a.report).toEqual(b.report)
295
  })
296
})
152
    const input = SECRET_FIXTURES.map((f) => f.raw).join(" | ");
153
    const a = redactTraceString(input);
154
    const b = redactTraceString(input);
155
    expect(a.value).toBe(b.value);
156
    expect(a.report).toEqual(b.report);
157
  });
158
});
297 159
298 160
describe("redactValue", () => {
299 161
  test("walks deeply, preserves numeric metrics, and redacts usernames", () => {

@@ -303,21 +165,21 @@ describe("redactValue", () => {

303 165
      listing: "drwxr-xr-x@ 3 alice staff 96 file",
304 166
      slug: "/private/tmp/-Users-alice-work/log",
305 167
      token: "oa_agent_AbCdEf123456789xyz",
306
    }
307
    const r = redactValue(value)
308
    const json = JSON.stringify(r.value)
309
310
    expect(r.value.metrics).toEqual(value.metrics)
311
    expect(json).not.toContain("/Users/alice")
312
    expect(json).not.toContain(" alice ")
313
    expect(json).not.toContain("oa_agent_AbCdEf")
314
    expect(r.report.counts.home_path).toBeGreaterThanOrEqual(1)
315
    expect(r.report.counts.username).toBeGreaterThanOrEqual(1)
316
  })
317
})
168
    };
169
    const r = redactValue(value);
170
    const json = JSON.stringify(r.value);
171
172
    expect(r.value.metrics).toEqual(value.metrics);
173
    expect(json).not.toContain("/Users/alice");
174
    expect(json).not.toContain(" alice ");
175
    expect(json).not.toContain("oa_agent_AbCdEf");
176
    expect(r.report.counts.home_path).toBeGreaterThanOrEqual(1);
177
    expect(r.report.counts.username).toBeGreaterThanOrEqual(1);
178
  });
179
});
318 180
319 181
describe("redact-before-tripwire safety bar", () => {
320
  const leakBlob = SECRET_FIXTURES.map(f => f.raw).join("\n")
182
  const leakBlob = SECRET_FIXTURES.map((f) => f.raw).join("\n");
321 183
322 184
  const buildTrajectory = (userMessage: string): AtifTrajectory =>
323 185
    new AtifTrajectory({

@@ -340,19 +202,19 @@ describe("redact-before-tripwire safety bar", () => {

340 202
          metrics: { prompt_tokens: 10, completion_tokens: 5 },
341 203
        }),
342 204
      ],
343
    })
205
    });
344 206
345 207
  test("the leaky control trips the backstop", () => {
346
    expect(atifTraceTripwire(buildTrajectory(leakBlob)).length).toBeGreaterThan(0)
347
  })
208
    expect(atifTraceTripwire(buildTrajectory(leakBlob)).length).toBeGreaterThan(0);
209
  });
348 210
349 211
  test("the scrubbed trajectory passes validation and tripwire", () => {
350
    const { value } = redactTraceValue(buildTrajectory(leakBlob))
351
    expect(validateAtifTrajectory(value as AtifTrajectory)).toEqual([])
352
    expect(atifTraceTripwire(value as AtifTrajectory)).toEqual([])
353
    expect(JSON.stringify(value)).toContain("openagents/khala")
354
  })
355
})
212
    const { value } = redactTraceValue(buildTrajectory(leakBlob));
213
    expect(validateAtifTrajectory(value as AtifTrajectory)).toEqual([]);
214
    expect(atifTraceTripwire(value as AtifTrajectory)).toEqual([]);
215
    expect(JSON.stringify(value)).toContain("openagents/khala");
216
  });
217
});
356 218
357 219
describe("redactForExternalInference shared service", () => {
358 220
  const adversarialRegulatedDocument = [

@@ -362,33 +224,33 @@ describe("redactForExternalInference shared service", () => {

362 224
    "Health packet says DOB: 04/23/1978 and MRN: HOSP-928374.",
363 225
    "Local export path /Users/alice/Clients/private-case.md.",
364 226
    "Payment material lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq.",
365
  ].join("\n")
227
  ].join("\n");
366 228
367 229
  test("regulated corpus ingestion is redacted before external inference", () => {
368 230
    const result = redactStringForExternalInference(adversarialRegulatedDocument, {
369 231
      regulatedVertical: "health",
370 232
      surface: "corpus_ingestion",
371
    })
233
    });
372 234
373 235
    expect(result.policy).toEqual({
374 236
      appliedBeforeExternalInference: true,
375 237
      regulatedVertical: "health",
376 238
      serviceRef: REDACTION_SERVICE_REF,
377 239
      surface: "corpus_ingestion",
378
    })
379
    expect(result.safeForExternalInference).toBe(true)
380
    expect(result.value).not.toContain("jane.doe@example.com")
381
    expect(result.value).not.toContain("312) 555-0198")
382
    expect(result.value).not.toContain("123-45-6789")
383
    expect(result.value).not.toContain("04/23/1978")
384
    expect(result.value).not.toContain("HOSP-928374")
385
    expect(result.value).not.toContain("/Users/alice")
386
    expect(result.report.counts.email).toBeGreaterThanOrEqual(1)
387
    expect(result.report.counts.phone).toBeGreaterThanOrEqual(1)
388
    expect(result.report.counts.ssn).toBeGreaterThanOrEqual(1)
389
    expect(result.report.counts.date_of_birth).toBeGreaterThanOrEqual(1)
390
    expect(result.report.counts.medical_record_id).toBeGreaterThanOrEqual(1)
391
  })
240
    });
241
    expect(result.safeForExternalInference).toBe(true);
242
    expect(result.value).not.toContain("jane.doe@example.com");
243
    expect(result.value).not.toContain("312) 555-0198");
244
    expect(result.value).not.toContain("123-45-6789");
245
    expect(result.value).not.toContain("04/23/1978");
246
    expect(result.value).not.toContain("HOSP-928374");
247
    expect(result.value).not.toContain("/Users/alice");
248
    expect(result.report.counts.email).toBeGreaterThanOrEqual(1);
249
    expect(result.report.counts.phone).toBeGreaterThanOrEqual(1);
250
    expect(result.report.counts.ssn).toBeGreaterThanOrEqual(1);
251
    expect(result.report.counts.date_of_birth).toBeGreaterThanOrEqual(1);
252
    expect(result.report.counts.medical_record_id).toBeGreaterThanOrEqual(1);
253
  });
392 254
393 255
  test("trace capture uses the same service before the tripwire", () => {
394 256
    const trajectory = new AtifTrajectory({

@@ -415,65 +277,65 @@ describe("redactForExternalInference shared service", () => {

415 277
          metrics: { prompt_tokens: 32, completion_tokens: 12 },
416 278
        }),
417 279
      ],
418
    })
280
    });
419 281
420
    expect(atifTraceTripwire(trajectory).length).toBeGreaterThan(0)
282
    expect(atifTraceTripwire(trajectory).length).toBeGreaterThan(0);
421 283
422 284
    const result = redactForExternalInference(trajectory, {
423 285
      regulatedVertical: "legal",
424 286
      surface: "trace_capture",
425
    })
287
    });
426 288
427
    expect(result.policy.serviceRef).toBe(REDACTION_SERVICE_REF)
428
    expect(result.policy.surface).toBe("trace_capture")
429
    expect(validateAtifTrajectory(result.value as AtifTrajectory)).toEqual([])
430
    expect(atifTraceTripwire(result.value as AtifTrajectory)).toEqual([])
431
    expect(JSON.stringify(result.value)).not.toContain("jane.doe@example.com")
432
    expect(JSON.stringify(result.value)).not.toContain("HOSP-928374")
433
  })
434
})
289
    expect(result.policy.serviceRef).toBe(REDACTION_SERVICE_REF);
290
    expect(result.policy.surface).toBe("trace_capture");
291
    expect(validateAtifTrajectory(result.value as AtifTrajectory)).toEqual([]);
292
    expect(atifTraceTripwire(result.value as AtifTrajectory)).toEqual([]);
293
    expect(JSON.stringify(result.value)).not.toContain("jane.doe@example.com");
294
    expect(JSON.stringify(result.value)).not.toContain("HOSP-928374");
295
  });
296
});
435 297
436 298
describe("TraceRedactor Effect service", () => {
437 299
  test("redacts through the Default layer", async () => {
438 300
    const result = await Effect.runPromise(
439 301
      Effect.gen(function* () {
440
        const redactor = yield* TraceRedactor
302
        const redactor = yield* TraceRedactor;
441 303
        return yield* redactor.redact({
442 304
          message: "Bearer abcdef0123456789ABCDEF in /Users/carol/x",
443
        })
305
        });
444 306
      }).pipe(Effect.provide(TraceRedactor.Default)),
445
    )
307
    );
446 308
447
    expect(JSON.stringify(result.value)).not.toContain("abcdef0123456789ABCDEF")
448
    expect(JSON.stringify(result.value)).not.toContain("/Users/carol")
449
    expect(result.report.counts.bearer).toBe(1)
450
    expect(result.report.counts.home_path).toBe(1)
451
  })
309
    expect(JSON.stringify(result.value)).not.toContain("abcdef0123456789ABCDEF");
310
    expect(JSON.stringify(result.value)).not.toContain("/Users/carol");
311
    expect(result.report.counts.bearer).toBe(1);
312
    expect(result.report.counts.home_path).toBe(1);
313
  });
452 314
453 315
  test("redacts through the legacy live layer alias", async () => {
454 316
    const result = await Effect.runPromise(
455 317
      Effect.gen(function* () {
456
        const redactor = yield* TraceRedactor
457
        return yield* redactor.redactText("email d@example.com")
318
        const redactor = yield* TraceRedactor;
319
        return yield* redactor.redactText("email d@example.com");
458 320
      }).pipe(Effect.provide(TraceRedactorLive)),
459
    )
321
    );
460 322
461
    expect(result.value).toContain("[REDACTED:email]")
462
  })
323
    expect(result.value).toContain("[REDACTED:email]");
324
  });
463 325
464 326
  test("redacts external-inference text through the shared service", async () => {
465 327
    const result = await Effect.runPromise(
466 328
      Effect.gen(function* () {
467
        const redactor = yield* TraceRedactor
329
        const redactor = yield* TraceRedactor;
468 330
        return yield* redactor.redactTextForExternalInference(
469 331
          "MRN: HOSP-928374 for jane.doe@example.com",
470 332
          { regulatedVertical: "health", surface: "corpus_ingestion" },
471
        )
333
        );
472 334
      }).pipe(Effect.provide(TraceRedactorLive)),
473
    )
335
    );
474 336
475
    expect(result.policy.serviceRef).toBe(REDACTION_SERVICE_REF)
476
    expect(result.value).not.toContain("HOSP-928374")
477
    expect(result.value).not.toContain("jane.doe@example.com")
478
  })
479
})
337
    expect(result.policy.serviceRef).toBe(REDACTION_SERVICE_REF);
338
    expect(result.value).not.toContain("HOSP-928374");
339
    expect(result.value).not.toContain("jane.doe@example.com");
340
  });
341
});
packages/atif/src/redaction.ts modified +263 -183

@@ -1,6 +1,6 @@

1
import { Context, Effect, Layer } from "effect"
1
import { Context, Effect, Layer } from "effect";
2 2
3
import { BIP39_ENGLISH_WORDS } from "./bip39-wordlist.ts"
3
import { BIP39_ENGLISH_WORDS } from "./bip39-wordlist.ts";
4 4
5 5
export type RedactionCategory =
6 6
  | "private_key"

@@ -16,6 +16,7 @@ export type RedactionCategory =

16 16
  | "google_key"
17 17
  | "slack_token"
18 18
  | "github_token"
19
  | "gitlab_token"
19 20
  | "owner_id"
20 21
  | "env_secret"
21 22
  | "wallet_or_payment"

@@ -29,139 +30,134 @@ export type RedactionCategory =

29 30
  | "medical_record_id"
30 31
  | "ip"
31 32
  | "long_blob"
32
  | "username"
33
  | "username";
33 34
34 35
export type RedactOptions = Readonly<{
35
  usernames?: ReadonlyArray<string>
36
}>
36
  usernames?: ReadonlyArray<string>;
37
}>;
37 38
38
export type RedactionSurface = "corpus_ingestion" | "trace_capture"
39
export type RedactionSurface = "corpus_ingestion" | "trace_capture";
39 40
40
export type RegulatedVertical = "legal" | "health" | "other_regulated"
41
export type RegulatedVertical = "legal" | "health" | "other_regulated";
41 42
42 43
export type ExternalInferenceRedactionOptions = RedactOptions &
43 44
  Readonly<{
44
    surface: RedactionSurface
45
    regulatedVertical?: RegulatedVertical
46
  }>
45
    surface: RedactionSurface;
46
    regulatedVertical?: RegulatedVertical;
47
  }>;
47 48
48 49
export type RedactionReport = Readonly<{
49
  counts: Readonly<Record<string, number>>
50
  total: number
51
}>
50
  counts: Readonly<Record<string, number>>;
51
  total: number;
52
}>;
52 53
53 54
export type RedactionResult<T> = Readonly<{
54
  value: T
55
  report: RedactionReport
56
}>
55
  value: T;
56
  report: RedactionReport;
57
}>;
57 58
58 59
export type ExternalInferenceRedactionResult<T> = RedactionResult<T> &
59 60
  Readonly<{
60 61
    policy: Readonly<{
61
      serviceRef: typeof REDACTION_SERVICE_REF
62
      surface: RedactionSurface
63
      regulatedVertical?: RegulatedVertical
64
      appliedBeforeExternalInference: true
65
    }>
66
    safeForExternalInference: true
67
  }>
68
69
export type TraceRedactionCategory = RedactionCategory
70
export type TraceRedactionReport = RedactionReport
71
export type TraceRedactionResult<T> = RedactionResult<T>
62
      serviceRef: typeof REDACTION_SERVICE_REF;
63
      surface: RedactionSurface;
64
      regulatedVertical?: RegulatedVertical;
65
      appliedBeforeExternalInference: true;
66
    }>;
67
    safeForExternalInference: true;
68
  }>;
69
70
export type TraceRedactionCategory = RedactionCategory;
71
export type TraceRedactionReport = RedactionReport;
72
export type TraceRedactionResult<T> = RedactionResult<T>;
72 73
73 74
export type TraceRedactorShape = Readonly<{
74
  redact: <T>(
75
    value: T,
76
    options?: RedactOptions,
77
  ) => Effect.Effect<RedactionResult<T>>
78
  redactString: (
79
    text: string,
80
    options?: RedactOptions,
81
  ) => Effect.Effect<RedactionResult<string>>
82
  redactText: (
83
    text: string,
84
    options?: RedactOptions,
85
  ) => Effect.Effect<RedactionResult<string>>
75
  redact: <T>(value: T, options?: RedactOptions) => Effect.Effect<RedactionResult<T>>;
76
  redactString: (text: string, options?: RedactOptions) => Effect.Effect<RedactionResult<string>>;
77
  redactText: (text: string, options?: RedactOptions) => Effect.Effect<RedactionResult<string>>;
86 78
  redactForExternalInference: <T>(
87 79
    value: T,
88 80
    options: ExternalInferenceRedactionOptions,
89
  ) => Effect.Effect<ExternalInferenceRedactionResult<T>>
81
  ) => Effect.Effect<ExternalInferenceRedactionResult<T>>;
90 82
  redactTextForExternalInference: (
91 83
    text: string,
92 84
    options: ExternalInferenceRedactionOptions,
93
  ) => Effect.Effect<ExternalInferenceRedactionResult<string>>
85
  ) => Effect.Effect<ExternalInferenceRedactionResult<string>>;
94 86
  redactTrajectory: <T>(
95 87
    trajectory: T,
96 88
    options?: RedactOptions,
97
  ) => Effect.Effect<RedactionResult<T>>
98
}>
89
  ) => Effect.Effect<RedactionResult<T>>;
90
}>;
99 91
100
export const REDACTION_SERVICE_REF = "@openagentsinc/atif/redaction"
92
export const REDACTION_SERVICE_REF = "@openagentsinc/atif/redaction";
101 93
102
const ALLOWLIST_EXACT: ReadonlyArray<string> = ["openagents/khala"]
94
const ALLOWLIST_EXACT: ReadonlyArray<string> = ["openagents/khala"];
103 95
104 96
const ALLOWLIST_PATTERNS: ReadonlyArray<RegExp> = [
105 97
  /https?:\/\/openagents\.com\/[^\s"'`)<>]*/g,
106 98
  /https?:\/\/(?:www\.)?github\.com\/OpenAgentsInc\/[^\s"'`)<>]*/g,
107 99
  /#\d{1,6}\b/g,
108
]
100
];
109 101
110
const SENT_OPEN = "\uE000"
111
const SENT_CLOSE = "\uE001"
102
const SENT_OPEN = "\uE000";
103
const SENT_CLOSE = "\uE001";
112 104
113
const escapeRegExp = (s: string): string =>
114
  s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
105
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115 106
116
const tag = (cat: RedactionCategory): string => `[REDACTED:${cat}]`
107
const tag = (cat: RedactionCategory): string => `[REDACTED:${cat}]`;
117 108
118
const maskAllowlist = (
119
  text: string,
120
): { masked: string; originals: Array<string> } => {
121
  const originals: Array<string> = []
122
  let masked = text
109
const maskAllowlist = (text: string): { masked: string; originals: Array<string> } => {
110
  const originals: Array<string> = [];
111
  let masked = text;
123 112
  const stash = (m: string): string => {
124
    const idx = originals.length
125
    originals.push(m)
126
    return `${SENT_OPEN}${idx}${SENT_CLOSE}`
127
  }
113
    const idx = originals.length;
114
    originals.push(m);
115
    return `${SENT_OPEN}${idx}${SENT_CLOSE}`;
116
  };
128 117
129 118
  for (const exact of ALLOWLIST_EXACT) {
130
    masked = masked.replace(new RegExp(escapeRegExp(exact), "g"), m => stash(m))
119
    masked = masked.replace(new RegExp(escapeRegExp(exact), "g"), (m) => stash(m));
131 120
  }
132 121
  for (const re of ALLOWLIST_PATTERNS) {
133
    re.lastIndex = 0
134
    masked = masked.replace(re, m => stash(m))
122
    re.lastIndex = 0;
123
    masked = masked.replace(re, (m) => stash(m));
135 124
  }
136 125
137
  return { masked, originals }
138
}
126
  return { masked, originals };
127
};
139 128
140
const unmaskAllowlist = (
141
  masked: string,
142
  originals: ReadonlyArray<string>,
143
): string =>
129
const unmaskAllowlist = (masked: string, originals: ReadonlyArray<string>): string =>
144 130
  masked.replace(
145 131
    new RegExp(`${SENT_OPEN}(\\d+)${SENT_CLOSE}`, "g"),
146 132
    (_m, idx: string) => originals[Number(idx)] ?? "",
147
  )
133
  );
148 134
149
type Rule = Readonly<{
150
  category: RedactionCategory
151
  pattern: RegExp
152
  replace: (match: string, ...groups: Array<string>) => string
153
}>
135
/**
136
 * One redaction rule. `replace` returns the substitution, or the match unchanged
137
 * to decline — a decline is not counted, which is what lets the mnemonic rule
138
 * gate a shape match against the BIP39 wordlist.
139
 *
140
 * Exported because this list is the authoritative one. Every other redaction
141
 * path in the repo consumes it rather than restating the patterns; restating
142
 * them is what let `oa_pat_` and `smct_` leak past a redaction that reported
143
 * success.
144
 */
145
export type Rule = Readonly<{
146
  category: RedactionCategory;
147
  pattern: RegExp;
148
  replace: (match: string, ...groups: Array<string>) => string;
149
}>;
154 150
155 151
// A candidate BIP39 seed phrase is a run of 12/15/18/21/24 lowercase words.
156 152
// This regex only FINDS candidates cheaply; `mnemonicReplace` then confirms
157 153
// every word is an actual BIP39 word before redacting, so ordinary English
158 154
// prose (which is full of non-wordlist words like "the", "roadmap", "ide") is
159 155
// left intact. Real seed phrases are all-wordlist by definition and still redact.
160
const MNEMONIC = /\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b/g
156
const MNEMONIC = /\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b/g;
161 157
162 158
// Shortest real BIP39 mnemonic. Runs of consecutive wordlist words below this
163 159
// length are treated as coincidental prose, not a seed phrase.
164
const MIN_MNEMONIC_WORDS = 12
160
const MIN_MNEMONIC_WORDS = 12;
165 161
166 162
/**
167 163
 * Redact only the ACTUAL seed phrase inside a shape-matched candidate: the

@@ -173,41 +169,49 @@ const MIN_MNEMONIC_WORDS = 12

173 169
 * to be a run of short lowercase words.
174 170
 */
175 171
const mnemonicReplace = (match: string): string => {
176
  const words = match.split(" ")
177
  let bestStart = -1
178
  let bestLen = 0
179
  let curStart = 0
180
  let curLen = 0
172
  const words = match.split(" ");
173
  let bestStart = -1;
174
  let bestLen = 0;
175
  let curStart = 0;
176
  let curLen = 0;
181 177
  for (let i = 0; i < words.length; i += 1) {
182 178
    if (BIP39_ENGLISH_WORDS.has(words[i] as string)) {
183
      if (curLen === 0) curStart = i
184
      curLen += 1
179
      if (curLen === 0) curStart = i;
180
      curLen += 1;
185 181
      if (curLen > bestLen) {
186
        bestLen = curLen
187
        bestStart = curStart
182
        bestLen = curLen;
183
        bestStart = curStart;
188 184
      }
189 185
    } else {
190
      curLen = 0
186
      curLen = 0;
191 187
    }
192 188
  }
193
  if (bestLen < MIN_MNEMONIC_WORDS) return match
194
  const before = words.slice(0, bestStart).join(" ")
195
  const after = words.slice(bestStart + bestLen).join(" ")
196
  return [before, tag("mnemonic"), after].filter(part => part !== "").join(" ")
197
}
189
  if (bestLen < MIN_MNEMONIC_WORDS) return match;
190
  const before = words.slice(0, bestStart).join(" ");
191
  const after = words.slice(bestStart + bestLen).join(" ");
192
  return [before, tag("mnemonic"), after].filter((part) => part !== "").join(" ");
193
};
198 194
199 195
const RULES: ReadonlyArray<Rule> = [
200 196
  {
197
    category: "private_key",
198
    pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
199
    replace: () => tag("private_key"),
200
  },
201
  {
202
    // Bech32 and base58 SECRET keys. `wallet_or_payment` below carries the
203
    // matching PUBLIC forms (`xpub`, `bc1`, an invoice); these are the spend
204
    // and signing halves, so they run first and are their own category.
205
    // `npub` is deliberately absent: it is the public name.
201 206
    category: "private_key",
202 207
    pattern:
203
      /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
208
      /\b(?:nsec1[02-9ac-hj-np-z]{50,}|(?:xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{50,})\b/g,
204 209
    replace: () => tag("private_key"),
205 210
  },
206 211
  { category: "mnemonic", pattern: MNEMONIC, replace: mnemonicReplace },
207 212
  {
208 213
    category: "jwt",
209
    pattern:
210
      /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\b/g,
214
    pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\b/g,
211 215
    replace: () => tag("jwt"),
212 216
  },
213 217
  {

@@ -231,11 +235,23 @@ const RULES: ReadonlyArray<Rule> = [

231 235
    pattern: /\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g,
232 236
    replace: () => tag("slack_token"),
233 237
  },
238
  {
239
    // `github_pat_` first: the general `gh[pousr]_` shape does not reach it,
240
    // because a fine-grained PAT spells the prefix out in full.
241
    category: "github_token",
242
    pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
243
    replace: () => tag("github_token"),
244
  },
234 245
  {
235 246
    category: "github_token",
236 247
    pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
237 248
    replace: () => tag("github_token"),
238 249
  },
250
  {
251
    category: "gitlab_token",
252
    pattern: /\bglpat-[A-Za-z0-9_-]{16,}\b/g,
253
    replace: () => tag("gitlab_token"),
254
  },
239 255
  {
240 256
    category: "bearer",
241 257
    pattern: /\b([Bb]earer)\s+[A-Za-z0-9._~+/=-]{8,}/g,

@@ -243,8 +259,7 @@ const RULES: ReadonlyArray<Rule> = [

243 259
  },
244 260
  {
245 261
    category: "bearer",
246
    pattern:
247
      /\b(authorization)\s*[:=]\s*["']?(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}["']?/gi,
262
    pattern: /\b(authorization)\s*[:=]\s*["']?(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}["']?/gi,
248 263
    replace: () => `authorization: ${tag("bearer")}`,
249 264
  },
250 265
  {

@@ -329,8 +344,7 @@ const RULES: ReadonlyArray<Rule> = [

329 344
  },
330 345
  {
331 346
    category: "medical_record_id",
332
    pattern:
333
      /\b(?:MRN|medical record(?: number)?|patient id)\s*[:#=]?\s*[A-Za-z0-9-]{6,}\b/gi,
347
    pattern: /\b(?:MRN|medical record(?: number)?|patient id)\s*[:#=]?\s*[A-Za-z0-9-]{6,}\b/gi,
334 348
    replace: () => `MRN ${tag("medical_record_id")}`,
335 349
  },
336 350
  {

@@ -360,142 +374,212 @@ const RULES: ReadonlyArray<Rule> = [

360 374
    pattern: /\b[A-Za-z0-9+]{48,}={0,2}\b/g,
361 375
    replace: () => tag("long_blob"),
362 376
  },
363
]
377
];
378
379
/**
380
 * How each category is classified for the purposes of the CLI redaction floor.
381
 *
382
 * `"credential"` means the match is key material or an access token: something
383
 * that grants access if it escapes. Every credential category is a floor that
384
 * the `openagents trace redact` path must also cover, and
385
 * `test/redaction-parity.test.ts` fails when one of them has no coverage there.
386
 *
387
 * `"other"` means the match is a location, an identifier, or a PII/PHI shape.
388
 * Those matter for a corpus export but are not access-granting, and the trace
389
 * path deliberately treats some of them differently -- it rewrites a home path
390
 * to `~` rather than to a tag, and it keeps `long_blob` off so a public `npub`
391
 * survives a redaction.
392
 *
393
 * This map is exhaustive by construction: `Record<RedactionCategory, ...>` will
394
 * not compile once a new category is added and left unclassified, which is the
395
 * first link in the chain that stops the two rule lists from drifting again.
396
 */
397
export const REDACTION_CATEGORY_CLASS: Record<RedactionCategory, "credential" | "other"> = {
398
  private_key: "credential",
399
  mnemonic: "credential",
400
  jwt: "credential",
401
  bearer: "credential",
402
  provider_key: "credential",
403
  oa_agent_token: "credential",
404
  x_code: "credential",
405
  oa_token: "credential",
406
  machine_token: "credential",
407
  aws_key: "credential",
408
  google_key: "credential",
409
  slack_token: "credential",
410
  github_token: "credential",
411
  gitlab_token: "credential",
412
  env_secret: "credential",
413
  wallet_or_payment: "credential",
414
  secrets_path: "credential",
415
  owner_id: "other",
416
  home_path: "other",
417
  file_url: "other",
418
  email: "other",
419
  phone: "other",
420
  ssn: "other",
421
  date_of_birth: "other",
422
  medical_record_id: "other",
423
  ip: "other",
424
  long_blob: "other",
425
  username: "other",
426
};
427
428
/** True when a category's match is key material or an access token. */
429
export const isCredentialCategory = (category: RedactionCategory): boolean =>
430
  REDACTION_CATEGORY_CLASS[category] === "credential";
431
432
/**
433
 * Every rule this module applies, in the order it applies them.
434
 *
435
 * This is the authoritative list. Consumers import it instead of restating the
436
 * patterns: `oa_pat_` and `smct_` both leaked because a second hand-written
437
 * list forgot them, and forgetting produced no error -- it produced a redaction
438
 * that reported success over a file full of live tokens.
439
 */
440
export const atifRedactionRules: ReadonlyArray<Rule> = RULES;
441
442
/**
443
 * The subset of {@link atifRedactionRules} whose matches are credentials.
444
 *
445
 * This is what the `openagents trace redact` path folds in on top of its own
446
 * trace-specific rules, so a token family added here is removed there too.
447
 */
448
export const atifCredentialRules: ReadonlyArray<Rule> = RULES.filter((rule) =>
449
  isCredentialCategory(rule.category),
450
);
364 451
365 452
const collectUsernames = (text: string): Set<string> => {
366
  const names = new Set<string>()
453
  const names = new Set<string>();
367 454
  for (const m of text.matchAll(/\/Users\/([A-Za-z0-9._-]+)/g)) {
368 455
    if (m[1] && m[1] !== "Shared") {
369
      names.add(m[1])
456
      names.add(m[1]);
370 457
    }
371 458
  }
372 459
  for (const m of text.matchAll(/\/home\/([A-Za-z0-9._-]+)/g)) {
373 460
    if (m[1]) {
374
      names.add(m[1])
461
      names.add(m[1]);
375 462
    }
376 463
  }
377 464
  for (const m of text.matchAll(/-Users-([A-Za-z0-9._]+?)-/g)) {
378 465
    if (m[1] && m[1] !== "Shared") {
379
      names.add(m[1])
466
      names.add(m[1]);
380 467
    }
381 468
  }
382
  return names
383
}
469
  return names;
470
};
384 471
385
const mergeReports = (
386
  into: Record<string, number>,
387
  from: RedactionReport,
388
): void => {
472
const mergeReports = (into: Record<string, number>, from: RedactionReport): void => {
389 473
  for (const [cat, n] of Object.entries(from.counts)) {
390
    into[cat] = (into[cat] ?? 0) + n
474
    into[cat] = (into[cat] ?? 0) + n;
391 475
  }
392
}
476
};
393 477
394 478
export const redactString = (
395 479
  input: string,
396 480
  options: RedactOptions = {},
397 481
): RedactionResult<string> => {
398
  const counts: Record<string, number> = {}
482
  const counts: Record<string, number> = {};
399 483
  const bump = (cat: RedactionCategory): void => {
400
    counts[cat] = (counts[cat] ?? 0) + 1
401
  }
484
    counts[cat] = (counts[cat] ?? 0) + 1;
485
  };
402 486
403
  const { masked, originals } = maskAllowlist(input)
404
  let working = masked
487
  const { masked, originals } = maskAllowlist(input);
488
  let working = masked;
405 489
406 490
  for (const rule of RULES) {
407
    rule.pattern.lastIndex = 0
491
    rule.pattern.lastIndex = 0;
408 492
    working = working.replace(rule.pattern, (...args: Array<unknown>) => {
409
      const match = args[0] as string
493
      const match = args[0] as string;
410 494
      if (match.includes(SENT_OPEN)) {
411
        return match
495
        return match;
412 496
      }
413
      const groups = args.slice(1, -2) as Array<string>
414
      const replaced = rule.replace(match, ...groups)
497
      const groups = args.slice(1, -2) as Array<string>;
498
      const replaced = rule.replace(match, ...groups);
415 499
      // Only count a real redaction. A rule whose `replace` returns the match
416 500
      // unchanged (e.g. the mnemonic wordlist gate rejecting prose) is a no-op
417 501
      // and must not inflate the report.
418 502
      if (replaced !== match) {
419
        bump(rule.category)
503
        bump(rule.category);
420 504
      }
421
      return replaced
422
    })
505
      return replaced;
506
    });
423 507
  }
424 508
425 509
  for (const name of options.usernames ?? []) {
426 510
    if (name === "") {
427
      continue
511
      continue;
428 512
    }
429
    const re = new RegExp(escapeRegExp(name), "g")
430
    working = working.replace(re, m => {
513
    const re = new RegExp(escapeRegExp(name), "g");
514
    working = working.replace(re, (m) => {
431 515
      if (m.includes(SENT_OPEN)) {
432
        return m
516
        return m;
433 517
      }
434
      bump("username")
435
      return "[REDACTED:home]"
436
    })
518
      bump("username");
519
      return "[REDACTED:home]";
520
    });
437 521
  }
438 522
439
  const value = unmaskAllowlist(working, originals)
440
  const total = Object.values(counts).reduce((a, b) => a + b, 0)
441
  return { value, report: { counts, total } }
442
}
523
  const value = unmaskAllowlist(working, originals);
524
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
525
  return { value, report: { counts, total } };
526
};
443 527
444
export const redactTraceString = redactString
528
export const redactTraceString = redactString;
445 529
446
type Json = unknown
530
type Json = unknown;
447 531
448 532
export const redactValue = <T extends Json>(
449 533
  value: T,
450 534
  options: RedactOptions = {},
451 535
): RedactionResult<T> => {
452
  const counts: Record<string, number> = {}
453
  const usernames = new Set<string>(options.usernames ?? [])
536
  const counts: Record<string, number> = {};
537
  const usernames = new Set<string>(options.usernames ?? []);
454 538
455 539
  const scan = (v: Json): void => {
456 540
    if (typeof v === "string") {
457 541
      for (const name of collectUsernames(v)) {
458
        usernames.add(name)
542
        usernames.add(name);
459 543
      }
460
      return
544
      return;
461 545
    }
462 546
    if (Array.isArray(v)) {
463
      v.forEach(scan)
464
      return
547
      v.forEach(scan);
548
      return;
465 549
    }
466 550
    if (v !== null && typeof v === "object") {
467
      Object.values(v as Record<string, Json>).forEach(scan)
551
      Object.values(v as Record<string, Json>).forEach(scan);
468 552
    }
469
  }
553
  };
470 554
471
  scan(value)
472
  const opts: RedactOptions = { usernames: Array.from(usernames) }
555
  scan(value);
556
  const opts: RedactOptions = { usernames: Array.from(usernames) };
473 557
474 558
  const walk = (v: Json): Json => {
475 559
    if (typeof v === "string") {
476
      const r = redactString(v, opts)
477
      mergeReports(counts, r.report)
478
      return r.value
560
      const r = redactString(v, opts);
561
      mergeReports(counts, r.report);
562
      return r.value;
479 563
    }
480 564
    if (Array.isArray(v)) {
481
      return v.map(walk)
565
      return v.map(walk);
482 566
    }
483 567
    if (v !== null && typeof v === "object") {
484
      const out: Record<string, Json> = {}
568
      const out: Record<string, Json> = {};
485 569
      for (const [k, child] of Object.entries(v as Record<string, Json>)) {
486
        out[k] = walk(child)
570
        out[k] = walk(child);
487 571
      }
488
      return out
572
      return out;
489 573
    }
490
    return v
491
  }
574
    return v;
575
  };
492 576
493
  const redacted = walk(value) as T
494
  const total = Object.values(counts).reduce((a, b) => a + b, 0)
495
  return { value: redacted, report: { counts, total } }
496
}
577
  const redacted = walk(value) as T;
578
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
579
  return { value: redacted, report: { counts, total } };
580
};
497 581
498
export const redactTraceValue = redactValue
582
export const redactTraceValue = redactValue;
499 583
500 584
const externalInferencePolicy = (
501 585
  options: ExternalInferenceRedactionOptions,

@@ -506,35 +590,33 @@ const externalInferencePolicy = (

506 590
    ? {}
507 591
    : { regulatedVertical: options.regulatedVertical }),
508 592
  appliedBeforeExternalInference: true,
509
})
593
});
510 594
511 595
export const redactForExternalInference = <T extends Json>(
512 596
  value: T,
513 597
  options: ExternalInferenceRedactionOptions,
514 598
): ExternalInferenceRedactionResult<T> => {
515
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } =
516
    options
517
  const redacted = redactValue(value, redactOptions)
599
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } = options;
600
  const redacted = redactValue(value, redactOptions);
518 601
  return {
519 602
    ...redacted,
520 603
    policy: externalInferencePolicy(options),
521 604
    safeForExternalInference: true,
522
  }
523
}
605
  };
606
};
524 607
525 608
export const redactStringForExternalInference = (
526 609
  text: string,
527 610
  options: ExternalInferenceRedactionOptions,
528 611
): ExternalInferenceRedactionResult<string> => {
529
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } =
530
    options
531
  const redacted = redactString(text, redactOptions)
612
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } = options;
613
  const redacted = redactString(text, redactOptions);
532 614
  return {
533 615
    ...redacted,
534 616
    policy: externalInferencePolicy(options),
535 617
    safeForExternalInference: true,
536
  }
537
}
618
  };
619
};
538 620
539 621
export const makeTraceRedactor = (): TraceRedactorShape => ({
540 622
  redact: (value, options) => Effect.sync(() => redactValue(value, options)),

@@ -544,15 +626,13 @@ export const makeTraceRedactor = (): TraceRedactorShape => ({

544 626
    Effect.sync(() => redactForExternalInference(value, options)),
545 627
  redactTextForExternalInference: (text, options) =>
546 628
    Effect.sync(() => redactStringForExternalInference(text, options)),
547
  redactTrajectory: (trajectory, options) =>
548
    Effect.sync(() => redactValue(trajectory, options)),
549
})
550
551
export class TraceRedactor extends Context.Service<
552
  TraceRedactor,
553
  TraceRedactorShape
554
>()("@openagentsinc/atif/TraceRedactor") {
555
  static readonly Default = Layer.succeed(TraceRedactor, makeTraceRedactor())
629
  redactTrajectory: (trajectory, options) => Effect.sync(() => redactValue(trajectory, options)),
630
});
631
632
export class TraceRedactor extends Context.Service<TraceRedactor, TraceRedactorShape>()(
633
  "@openagentsinc/atif/TraceRedactor",
634
) {
635
  static readonly Default = Layer.succeed(TraceRedactor, makeTraceRedactor());
556 636
}
557 637
558
export const TraceRedactorLive = TraceRedactor.Default
638
export const TraceRedactorLive = TraceRedactor.Default;
packages/openagents-cli/src/memory/redaction.ts modified +271 -183

@@ -1,9 +1,9 @@

1 1
// Vendored from packages/atif/src/redaction.ts by scripts/vendor-memory.mjs — do not edit here.
2 2
// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy
3 3
// no longer matches the canonical source.
4
import { Context, Effect, Layer } from "effect"
4
import { Context, Effect, Layer } from "effect";
5 5
6
import { BIP39_ENGLISH_WORDS } from "./bip39-wordlist.js"
6
import { BIP39_ENGLISH_WORDS } from "./bip39-wordlist.js";
7 7
8 8
export type RedactionCategory =
9 9
  | "private_key"

@@ -14,10 +14,12 @@ export type RedactionCategory =

14 14
  | "oa_agent_token"
15 15
  | "x_code"
16 16
  | "oa_token"
17
  | "machine_token"
17 18
  | "aws_key"
18 19
  | "google_key"
19 20
  | "slack_token"
20 21
  | "github_token"
22
  | "gitlab_token"
21 23
  | "owner_id"
22 24
  | "env_secret"
23 25
  | "wallet_or_payment"

@@ -31,139 +33,134 @@ export type RedactionCategory =

31 33
  | "medical_record_id"
32 34
  | "ip"
33 35
  | "long_blob"
34
  | "username"
36
  | "username";
35 37
36 38
export type RedactOptions = Readonly<{
37
  usernames?: ReadonlyArray<string>
38
}>
39
  usernames?: ReadonlyArray<string>;
40
}>;
39 41
40
export type RedactionSurface = "corpus_ingestion" | "trace_capture"
42
export type RedactionSurface = "corpus_ingestion" | "trace_capture";
41 43
42
export type RegulatedVertical = "legal" | "health" | "other_regulated"
44
export type RegulatedVertical = "legal" | "health" | "other_regulated";
43 45
44 46
export type ExternalInferenceRedactionOptions = RedactOptions &
45 47
  Readonly<{
46
    surface: RedactionSurface
47
    regulatedVertical?: RegulatedVertical
48
  }>
48
    surface: RedactionSurface;
49
    regulatedVertical?: RegulatedVertical;
50
  }>;
49 51
50 52
export type RedactionReport = Readonly<{
51
  counts: Readonly<Record<string, number>>
52
  total: number
53
}>
53
  counts: Readonly<Record<string, number>>;
54
  total: number;
55
}>;
54 56
55 57
export type RedactionResult<T> = Readonly<{
56
  value: T
57
  report: RedactionReport
58
}>
58
  value: T;
59
  report: RedactionReport;
60
}>;
59 61
60 62
export type ExternalInferenceRedactionResult<T> = RedactionResult<T> &
61 63
  Readonly<{
62 64
    policy: Readonly<{
63
      serviceRef: typeof REDACTION_SERVICE_REF
64
      surface: RedactionSurface
65
      regulatedVertical?: RegulatedVertical
66
      appliedBeforeExternalInference: true
67
    }>
68
    safeForExternalInference: true
69
  }>
70
71
export type TraceRedactionCategory = RedactionCategory
72
export type TraceRedactionReport = RedactionReport
73
export type TraceRedactionResult<T> = RedactionResult<T>
65
      serviceRef: typeof REDACTION_SERVICE_REF;
66
      surface: RedactionSurface;
67
      regulatedVertical?: RegulatedVertical;
68
      appliedBeforeExternalInference: true;
69
    }>;
70
    safeForExternalInference: true;
71
  }>;
72
73
export type TraceRedactionCategory = RedactionCategory;
74
export type TraceRedactionReport = RedactionReport;
75
export type TraceRedactionResult<T> = RedactionResult<T>;
74 76
75 77
export type TraceRedactorShape = Readonly<{
76
  redact: <T>(
77
    value: T,
78
    options?: RedactOptions,
79
  ) => Effect.Effect<RedactionResult<T>>
80
  redactString: (
81
    text: string,
82
    options?: RedactOptions,
83
  ) => Effect.Effect<RedactionResult<string>>
84
  redactText: (
85
    text: string,
86
    options?: RedactOptions,
87
  ) => Effect.Effect<RedactionResult<string>>
78
  redact: <T>(value: T, options?: RedactOptions) => Effect.Effect<RedactionResult<T>>;
79
  redactString: (text: string, options?: RedactOptions) => Effect.Effect<RedactionResult<string>>;
80
  redactText: (text: string, options?: RedactOptions) => Effect.Effect<RedactionResult<string>>;
88 81
  redactForExternalInference: <T>(
89 82
    value: T,
90 83
    options: ExternalInferenceRedactionOptions,
91
  ) => Effect.Effect<ExternalInferenceRedactionResult<T>>
84
  ) => Effect.Effect<ExternalInferenceRedactionResult<T>>;
92 85
  redactTextForExternalInference: (
93 86
    text: string,
94 87
    options: ExternalInferenceRedactionOptions,
95
  ) => Effect.Effect<ExternalInferenceRedactionResult<string>>
88
  ) => Effect.Effect<ExternalInferenceRedactionResult<string>>;
96 89
  redactTrajectory: <T>(
97 90
    trajectory: T,
98 91
    options?: RedactOptions,
99
  ) => Effect.Effect<RedactionResult<T>>
100
}>
92
  ) => Effect.Effect<RedactionResult<T>>;
93
}>;
101 94
102
export const REDACTION_SERVICE_REF = "@openagentsinc/atif/redaction"
95
export const REDACTION_SERVICE_REF = "@openagentsinc/atif/redaction";
103 96
104
const ALLOWLIST_EXACT: ReadonlyArray<string> = ["openagents/khala"]
97
const ALLOWLIST_EXACT: ReadonlyArray<string> = ["openagents/khala"];
105 98
106 99
const ALLOWLIST_PATTERNS: ReadonlyArray<RegExp> = [
107 100
  /https?:\/\/openagents\.com\/[^\s"'`)<>]*/g,
108 101
  /https?:\/\/(?:www\.)?github\.com\/OpenAgentsInc\/[^\s"'`)<>]*/g,
109 102
  /#\d{1,6}\b/g,
110
]
103
];
111 104
112
const SENT_OPEN = "\uE000"
113
const SENT_CLOSE = "\uE001"
105
const SENT_OPEN = "\uE000";
106
const SENT_CLOSE = "\uE001";
114 107
115
const escapeRegExp = (s: string): string =>
116
  s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
108
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
117 109
118
const tag = (cat: RedactionCategory): string => `[REDACTED:${cat}]`
110
const tag = (cat: RedactionCategory): string => `[REDACTED:${cat}]`;
119 111
120
const maskAllowlist = (
121
  text: string,
122
): { masked: string; originals: Array<string> } => {
123
  const originals: Array<string> = []
124
  let masked = text
112
const maskAllowlist = (text: string): { masked: string; originals: Array<string> } => {
113
  const originals: Array<string> = [];
114
  let masked = text;
125 115
  const stash = (m: string): string => {
126
    const idx = originals.length
127
    originals.push(m)
128
    return `${SENT_OPEN}${idx}${SENT_CLOSE}`
129
  }
116
    const idx = originals.length;
117
    originals.push(m);
118
    return `${SENT_OPEN}${idx}${SENT_CLOSE}`;
119
  };
130 120
131 121
  for (const exact of ALLOWLIST_EXACT) {
132
    masked = masked.replace(new RegExp(escapeRegExp(exact), "g"), m => stash(m))
122
    masked = masked.replace(new RegExp(escapeRegExp(exact), "g"), (m) => stash(m));
133 123
  }
134 124
  for (const re of ALLOWLIST_PATTERNS) {
135
    re.lastIndex = 0
136
    masked = masked.replace(re, m => stash(m))
125
    re.lastIndex = 0;
126
    masked = masked.replace(re, (m) => stash(m));
137 127
  }
138 128
139
  return { masked, originals }
140
}
129
  return { masked, originals };
130
};
141 131
142
const unmaskAllowlist = (
143
  masked: string,
144
  originals: ReadonlyArray<string>,
145
): string =>
132
const unmaskAllowlist = (masked: string, originals: ReadonlyArray<string>): string =>
146 133
  masked.replace(
147 134
    new RegExp(`${SENT_OPEN}(\\d+)${SENT_CLOSE}`, "g"),
148 135
    (_m, idx: string) => originals[Number(idx)] ?? "",
149
  )
136
  );
150 137
151
type Rule = Readonly<{
152
  category: RedactionCategory
153
  pattern: RegExp
154
  replace: (match: string, ...groups: Array<string>) => string
155
}>
138
/**
139
 * One redaction rule. `replace` returns the substitution, or the match unchanged
140
 * to decline — a decline is not counted, which is what lets the mnemonic rule
141
 * gate a shape match against the BIP39 wordlist.
142
 *
143
 * Exported because this list is the authoritative one. Every other redaction
144
 * path in the repo consumes it rather than restating the patterns; restating
145
 * them is what let `oa_pat_` and `smct_` leak past a redaction that reported
146
 * success.
147
 */
148
export type Rule = Readonly<{
149
  category: RedactionCategory;
150
  pattern: RegExp;
151
  replace: (match: string, ...groups: Array<string>) => string;
152
}>;
156 153
157 154
// A candidate BIP39 seed phrase is a run of 12/15/18/21/24 lowercase words.
158 155
// This regex only FINDS candidates cheaply; `mnemonicReplace` then confirms
159 156
// every word is an actual BIP39 word before redacting, so ordinary English
160 157
// prose (which is full of non-wordlist words like "the", "roadmap", "ide") is
161 158
// left intact. Real seed phrases are all-wordlist by definition and still redact.
162
const MNEMONIC = /\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b/g
159
const MNEMONIC = /\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b/g;
163 160
164 161
// Shortest real BIP39 mnemonic. Runs of consecutive wordlist words below this
165 162
// length are treated as coincidental prose, not a seed phrase.
166
const MIN_MNEMONIC_WORDS = 12
163
const MIN_MNEMONIC_WORDS = 12;
167 164
168 165
/**
169 166
 * Redact only the ACTUAL seed phrase inside a shape-matched candidate: the

@@ -175,41 +172,49 @@ const MIN_MNEMONIC_WORDS = 12

175 172
 * to be a run of short lowercase words.
176 173
 */
177 174
const mnemonicReplace = (match: string): string => {
178
  const words = match.split(" ")
179
  let bestStart = -1
180
  let bestLen = 0
181
  let curStart = 0
182
  let curLen = 0
175
  const words = match.split(" ");
176
  let bestStart = -1;
177
  let bestLen = 0;
178
  let curStart = 0;
179
  let curLen = 0;
183 180
  for (let i = 0; i < words.length; i += 1) {
184 181
    if (BIP39_ENGLISH_WORDS.has(words[i] as string)) {
185
      if (curLen === 0) curStart = i
186
      curLen += 1
182
      if (curLen === 0) curStart = i;
183
      curLen += 1;
187 184
      if (curLen > bestLen) {
188
        bestLen = curLen
189
        bestStart = curStart
185
        bestLen = curLen;
186
        bestStart = curStart;
190 187
      }
191 188
    } else {
192
      curLen = 0
189
      curLen = 0;
193 190
    }
194 191
  }
195
  if (bestLen < MIN_MNEMONIC_WORDS) return match
196
  const before = words.slice(0, bestStart).join(" ")
197
  const after = words.slice(bestStart + bestLen).join(" ")
198
  return [before, tag("mnemonic"), after].filter(part => part !== "").join(" ")
199
}
192
  if (bestLen < MIN_MNEMONIC_WORDS) return match;
193
  const before = words.slice(0, bestStart).join(" ");
194
  const after = words.slice(bestStart + bestLen).join(" ");
195
  return [before, tag("mnemonic"), after].filter((part) => part !== "").join(" ");
196
};
200 197
201 198
const RULES: ReadonlyArray<Rule> = [
202 199
  {
200
    category: "private_key",
201
    pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
202
    replace: () => tag("private_key"),
203
  },
204
  {
205
    // Bech32 and base58 SECRET keys. `wallet_or_payment` below carries the
206
    // matching PUBLIC forms (`xpub`, `bc1`, an invoice); these are the spend
207
    // and signing halves, so they run first and are their own category.
208
    // `npub` is deliberately absent: it is the public name.
203 209
    category: "private_key",
204 210
    pattern:
205
      /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
211
      /\b(?:nsec1[02-9ac-hj-np-z]{50,}|(?:xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{50,})\b/g,
206 212
    replace: () => tag("private_key"),
207 213
  },
208 214
  { category: "mnemonic", pattern: MNEMONIC, replace: mnemonicReplace },
209 215
  {
210 216
    category: "jwt",
211
    pattern:
212
      /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\b/g,
217
    pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\b/g,
213 218
    replace: () => tag("jwt"),
214 219
  },
215 220
  {

@@ -233,11 +238,23 @@ const RULES: ReadonlyArray<Rule> = [

233 238
    pattern: /\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g,
234 239
    replace: () => tag("slack_token"),
235 240
  },
241
  {
242
    // `github_pat_` first: the general `gh[pousr]_` shape does not reach it,
243
    // because a fine-grained PAT spells the prefix out in full.
244
    category: "github_token",
245
    pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
246
    replace: () => tag("github_token"),
247
  },
236 248
  {
237 249
    category: "github_token",
238 250
    pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
239 251
    replace: () => tag("github_token"),
240 252
  },
253
  {
254
    category: "gitlab_token",
255
    pattern: /\bglpat-[A-Za-z0-9_-]{16,}\b/g,
256
    replace: () => tag("gitlab_token"),
257
  },
241 258
  {
242 259
    category: "bearer",
243 260
    pattern: /\b([Bb]earer)\s+[A-Za-z0-9._~+/=-]{8,}/g,

@@ -245,8 +262,7 @@ const RULES: ReadonlyArray<Rule> = [

245 262
  },
246 263
  {
247 264
    category: "bearer",
248
    pattern:
249
      /\b(authorization)\s*[:=]\s*["']?(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}["']?/gi,
265
    pattern: /\b(authorization)\s*[:=]\s*["']?(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}["']?/gi,
250 266
    replace: () => `authorization: ${tag("bearer")}`,
251 267
  },
252 268
  {

@@ -270,6 +286,13 @@ const RULES: ReadonlyArray<Rule> = [

270 286
    pattern: /\boa_(?:live|test|sk|key|secret|tok|token|pat)?_?[A-Za-z0-9]{12,}\b/g,
271 287
    replace: () => tag("oa_token"),
272 288
  },
289
  {
290
    // Machine tokens minted by computer pairing. They carry a hyphen, which
291
    // every rule above stops at, so they survived an ATIF export intact.
292
    category: "machine_token",
293
    pattern: /\bsmct_[A-Za-z0-9_-]{6,}\b/g,
294
    replace: () => tag("machine_token"),
295
  },
273 296
  {
274 297
    category: "owner_id",
275 298
    pattern: /\b(github|gh|x|twitter|discord|telegram|nostr):\d{3,}\b/gi,

@@ -324,8 +347,7 @@ const RULES: ReadonlyArray<Rule> = [

324 347
  },
325 348
  {
326 349
    category: "medical_record_id",
327
    pattern:
328
      /\b(?:MRN|medical record(?: number)?|patient id)\s*[:#=]?\s*[A-Za-z0-9-]{6,}\b/gi,
350
    pattern: /\b(?:MRN|medical record(?: number)?|patient id)\s*[:#=]?\s*[A-Za-z0-9-]{6,}\b/gi,
329 351
    replace: () => `MRN ${tag("medical_record_id")}`,
330 352
  },
331 353
  {

@@ -355,142 +377,212 @@ const RULES: ReadonlyArray<Rule> = [

355 377
    pattern: /\b[A-Za-z0-9+]{48,}={0,2}\b/g,
356 378
    replace: () => tag("long_blob"),
357 379
  },
358
]
380
];
381
382
/**
383
 * How each category is classified for the purposes of the CLI redaction floor.
384
 *
385
 * `"credential"` means the match is key material or an access token: something
386
 * that grants access if it escapes. Every credential category is a floor that
387
 * the `openagents trace redact` path must also cover, and
388
 * `test/redaction-parity.test.ts` fails when one of them has no coverage there.
389
 *
390
 * `"other"` means the match is a location, an identifier, or a PII/PHI shape.
391
 * Those matter for a corpus export but are not access-granting, and the trace
392
 * path deliberately treats some of them differently -- it rewrites a home path
393
 * to `~` rather than to a tag, and it keeps `long_blob` off so a public `npub`
394
 * survives a redaction.
395
 *
396
 * This map is exhaustive by construction: `Record<RedactionCategory, ...>` will
397
 * not compile once a new category is added and left unclassified, which is the
398
 * first link in the chain that stops the two rule lists from drifting again.
399
 */
400
export const REDACTION_CATEGORY_CLASS: Record<RedactionCategory, "credential" | "other"> = {
401
  private_key: "credential",
402
  mnemonic: "credential",
403
  jwt: "credential",
404
  bearer: "credential",
405
  provider_key: "credential",
406
  oa_agent_token: "credential",
407
  x_code: "credential",
408
  oa_token: "credential",
409
  machine_token: "credential",
410
  aws_key: "credential",
411
  google_key: "credential",
412
  slack_token: "credential",
413
  github_token: "credential",
414
  gitlab_token: "credential",
415
  env_secret: "credential",
416
  wallet_or_payment: "credential",
417
  secrets_path: "credential",
418
  owner_id: "other",
419
  home_path: "other",
420
  file_url: "other",
421
  email: "other",
422
  phone: "other",
423
  ssn: "other",
424
  date_of_birth: "other",
425
  medical_record_id: "other",
426
  ip: "other",
427
  long_blob: "other",
428
  username: "other",
429
};
430
431
/** True when a category's match is key material or an access token. */
432
export const isCredentialCategory = (category: RedactionCategory): boolean =>
433
  REDACTION_CATEGORY_CLASS[category] === "credential";
434
435
/**
436
 * Every rule this module applies, in the order it applies them.
437
 *
438
 * This is the authoritative list. Consumers import it instead of restating the
439
 * patterns: `oa_pat_` and `smct_` both leaked because a second hand-written
440
 * list forgot them, and forgetting produced no error -- it produced a redaction
441
 * that reported success over a file full of live tokens.
442
 */
443
export const atifRedactionRules: ReadonlyArray<Rule> = RULES;
444
445
/**
446
 * The subset of {@link atifRedactionRules} whose matches are credentials.
447
 *
448
 * This is what the `openagents trace redact` path folds in on top of its own
449
 * trace-specific rules, so a token family added here is removed there too.
450
 */
451
export const atifCredentialRules: ReadonlyArray<Rule> = RULES.filter((rule) =>
452
  isCredentialCategory(rule.category),
453
);
359 454
360 455
const collectUsernames = (text: string): Set<string> => {
361
  const names = new Set<string>()
456
  const names = new Set<string>();
362 457
  for (const m of text.matchAll(/\/Users\/([A-Za-z0-9._-]+)/g)) {
363 458
    if (m[1] && m[1] !== "Shared") {
364
      names.add(m[1])
459
      names.add(m[1]);
365 460
    }
366 461
  }
367 462
  for (const m of text.matchAll(/\/home\/([A-Za-z0-9._-]+)/g)) {
368 463
    if (m[1]) {
369
      names.add(m[1])
464
      names.add(m[1]);
370 465
    }
371 466
  }
372 467
  for (const m of text.matchAll(/-Users-([A-Za-z0-9._]+?)-/g)) {
373 468
    if (m[1] && m[1] !== "Shared") {
374
      names.add(m[1])
469
      names.add(m[1]);
375 470
    }
376 471
  }
377
  return names
378
}
472
  return names;
473
};
379 474
380
const mergeReports = (
381
  into: Record<string, number>,
382
  from: RedactionReport,
383
): void => {
475
const mergeReports = (into: Record<string, number>, from: RedactionReport): void => {
384 476
  for (const [cat, n] of Object.entries(from.counts)) {
385
    into[cat] = (into[cat] ?? 0) + n
477
    into[cat] = (into[cat] ?? 0) + n;
386 478
  }
387
}
479
};
388 480
389 481
export const redactString = (
390 482
  input: string,
391 483
  options: RedactOptions = {},
392 484
): RedactionResult<string> => {
393
  const counts: Record<string, number> = {}
485
  const counts: Record<string, number> = {};
394 486
  const bump = (cat: RedactionCategory): void => {
395
    counts[cat] = (counts[cat] ?? 0) + 1
396
  }
487
    counts[cat] = (counts[cat] ?? 0) + 1;
488
  };
397 489
398
  const { masked, originals } = maskAllowlist(input)
399
  let working = masked
490
  const { masked, originals } = maskAllowlist(input);
491
  let working = masked;
400 492
401 493
  for (const rule of RULES) {
402
    rule.pattern.lastIndex = 0
494
    rule.pattern.lastIndex = 0;
403 495
    working = working.replace(rule.pattern, (...args: Array<unknown>) => {
404
      const match = args[0] as string
496
      const match = args[0] as string;
405 497
      if (match.includes(SENT_OPEN)) {
406
        return match
498
        return match;
407 499
      }
408
      const groups = args.slice(1, -2) as Array<string>
409
      const replaced = rule.replace(match, ...groups)
500
      const groups = args.slice(1, -2) as Array<string>;
501
      const replaced = rule.replace(match, ...groups);
410 502
      // Only count a real redaction. A rule whose `replace` returns the match
411 503
      // unchanged (e.g. the mnemonic wordlist gate rejecting prose) is a no-op
412 504
      // and must not inflate the report.
413 505
      if (replaced !== match) {
414
        bump(rule.category)
506
        bump(rule.category);
415 507
      }
416
      return replaced
417
    })
508
      return replaced;
509
    });
418 510
  }
419 511
420 512
  for (const name of options.usernames ?? []) {
421 513
    if (name === "") {
422
      continue
514
      continue;
423 515
    }
424
    const re = new RegExp(escapeRegExp(name), "g")
425
    working = working.replace(re, m => {
516
    const re = new RegExp(escapeRegExp(name), "g");
517
    working = working.replace(re, (m) => {
426 518
      if (m.includes(SENT_OPEN)) {
427
        return m
519
        return m;
428 520
      }
429
      bump("username")
430
      return "[REDACTED:home]"
431
    })
521
      bump("username");
522
      return "[REDACTED:home]";
523
    });
432 524
  }
433 525
434
  const value = unmaskAllowlist(working, originals)
435
  const total = Object.values(counts).reduce((a, b) => a + b, 0)
436
  return { value, report: { counts, total } }
437
}
526
  const value = unmaskAllowlist(working, originals);
527
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
528
  return { value, report: { counts, total } };
529
};
438 530
439
export const redactTraceString = redactString
531
export const redactTraceString = redactString;
440 532
441
type Json = unknown
533
type Json = unknown;
442 534
443 535
export const redactValue = <T extends Json>(
444 536
  value: T,
445 537
  options: RedactOptions = {},
446 538
): RedactionResult<T> => {
447
  const counts: Record<string, number> = {}
448
  const usernames = new Set<string>(options.usernames ?? [])
539
  const counts: Record<string, number> = {};
540
  const usernames = new Set<string>(options.usernames ?? []);
449 541
450 542
  const scan = (v: Json): void => {
451 543
    if (typeof v === "string") {
452 544
      for (const name of collectUsernames(v)) {
453
        usernames.add(name)
545
        usernames.add(name);
454 546
      }
455
      return
547
      return;
456 548
    }
457 549
    if (Array.isArray(v)) {
458
      v.forEach(scan)
459
      return
550
      v.forEach(scan);
551
      return;
460 552
    }
461 553
    if (v !== null && typeof v === "object") {
462
      Object.values(v as Record<string, Json>).forEach(scan)
554
      Object.values(v as Record<string, Json>).forEach(scan);
463 555
    }
464
  }
556
  };
465 557
466
  scan(value)
467
  const opts: RedactOptions = { usernames: Array.from(usernames) }
558
  scan(value);
559
  const opts: RedactOptions = { usernames: Array.from(usernames) };
468 560
469 561
  const walk = (v: Json): Json => {
470 562
    if (typeof v === "string") {
471
      const r = redactString(v, opts)
472
      mergeReports(counts, r.report)
473
      return r.value
563
      const r = redactString(v, opts);
564
      mergeReports(counts, r.report);
565
      return r.value;
474 566
    }
475 567
    if (Array.isArray(v)) {
476
      return v.map(walk)
568
      return v.map(walk);
477 569
    }
478 570
    if (v !== null && typeof v === "object") {
479
      const out: Record<string, Json> = {}
571
      const out: Record<string, Json> = {};
480 572
      for (const [k, child] of Object.entries(v as Record<string, Json>)) {
481
        out[k] = walk(child)
573
        out[k] = walk(child);
482 574
      }
483
      return out
575
      return out;
484 576
    }
485
    return v
486
  }
577
    return v;
578
  };
487 579
488
  const redacted = walk(value) as T
489
  const total = Object.values(counts).reduce((a, b) => a + b, 0)
490
  return { value: redacted, report: { counts, total } }
491
}
580
  const redacted = walk(value) as T;
581
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
582
  return { value: redacted, report: { counts, total } };
583
};
492 584
493
export const redactTraceValue = redactValue
585
export const redactTraceValue = redactValue;
494 586
495 587
const externalInferencePolicy = (
496 588
  options: ExternalInferenceRedactionOptions,

@@ -501,35 +593,33 @@ const externalInferencePolicy = (

501 593
    ? {}
502 594
    : { regulatedVertical: options.regulatedVertical }),
503 595
  appliedBeforeExternalInference: true,
504
})
596
});
505 597
506 598
export const redactForExternalInference = <T extends Json>(
507 599
  value: T,
508 600
  options: ExternalInferenceRedactionOptions,
509 601
): ExternalInferenceRedactionResult<T> => {
510
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } =
511
    options
512
  const redacted = redactValue(value, redactOptions)
602
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } = options;
603
  const redacted = redactValue(value, redactOptions);
513 604
  return {
514 605
    ...redacted,
515 606
    policy: externalInferencePolicy(options),
516 607
    safeForExternalInference: true,
517
  }
518
}
608
  };
609
};
519 610
520 611
export const redactStringForExternalInference = (
521 612
  text: string,
522 613
  options: ExternalInferenceRedactionOptions,
523 614
): ExternalInferenceRedactionResult<string> => {
524
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } =
525
    options
526
  const redacted = redactString(text, redactOptions)
615
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } = options;
616
  const redacted = redactString(text, redactOptions);
527 617
  return {
528 618
    ...redacted,
529 619
    policy: externalInferencePolicy(options),
530 620
    safeForExternalInference: true,
531
  }
532
}
621
  };
622
};
533 623
534 624
export const makeTraceRedactor = (): TraceRedactorShape => ({
535 625
  redact: (value, options) => Effect.sync(() => redactValue(value, options)),

@@ -539,15 +629,13 @@ export const makeTraceRedactor = (): TraceRedactorShape => ({

539 629
    Effect.sync(() => redactForExternalInference(value, options)),
540 630
  redactTextForExternalInference: (text, options) =>
541 631
    Effect.sync(() => redactStringForExternalInference(text, options)),
542
  redactTrajectory: (trajectory, options) =>
543
    Effect.sync(() => redactValue(trajectory, options)),
544
})
545
546
export class TraceRedactor extends Context.Service<
547
  TraceRedactor,
548
  TraceRedactorShape
549
>()("@openagentsinc/atif/TraceRedactor") {
550
  static readonly Default = Layer.succeed(TraceRedactor, makeTraceRedactor())
632
  redactTrajectory: (trajectory, options) => Effect.sync(() => redactValue(trajectory, options)),
633
});
634
635
export class TraceRedactor extends Context.Service<TraceRedactor, TraceRedactorShape>()(
636
  "@openagentsinc/atif/TraceRedactor",
637
) {
638
  static readonly Default = Layer.succeed(TraceRedactor, makeTraceRedactor());
551 639
}
552 640
553
export const TraceRedactorLive = TraceRedactor.Default
641
export const TraceRedactorLive = TraceRedactor.Default;
packages/openagents-cli/src/trace-store.ts modified +59 -103

@@ -16,7 +16,7 @@

16 16
import { lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17 17
import { join } from "node:path";
18 18
19
import { BIP39_ENGLISH_WORDS } from "./memory/bip39-wordlist.js";
19
import { atifCredentialRules, type Rule as AtifRedactionRule } from "./memory/redaction.js";
20 20
21 21
/** Where a candidate trace came from. */
22 22
export type TraceSourceKind =

@@ -326,118 +326,52 @@ export interface RedactionRule {

326 326
  readonly replacement: string;
327 327
  /**
328 328
   * A rule that has to look at what it matched before deciding. It returns the
329
   * text to substitute, or the match unchanged to decline. Only the seed-phrase
330
   * rule needs this: a 12-word run is a cheap shape to find and an expensive one
331
   * to guess at, so the wordlist decides rather than the regex.
329
   * text to substitute, or the match unchanged to DECLINE, and a decline is not
330
   * counted -- which is what keeps the report honest when the seed-phrase rule
331
   * rejects a 12-word run of ordinary prose.
332
   *
333
   * Every rule adapted from ATIF carries one, because an ATIF rule computes its
334
   * own substitution from the match and its capture groups.
332 335
   */
333
  readonly resolve?: (match: string) => string;
336
  readonly resolve?: (match: string, ...groups: Array<string>) => string;
334 337
}
335 338
336 339
const escapeForRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
337 340
338
/** Word counts a BIP-39 mnemonic can have; below twelve is prose, not a seed. */
339
const MIN_SEED_WORDS = 12;
340
341 341
/**
342
 * A run of short lowercase words the length of a mnemonic. This finds candidates
343
 * cheaply; {@link seedPhraseResolve} confirms against the word list before
344
 * anything is removed, so ordinary English is left alone.
342
 * One ATIF rule, adapted to this module's shape.
343
 *
344
 * An ATIF rule decides its own substitution, so `resolve` carries it and
345
 * `replacement` is never consulted; it is filled in with the tag the rule
346
 * writes so the rule list still reads honestly.
347
 *
348
 * The wrapper declines on a match that already holds a redaction marker. The
349
 * trace-specific rules run first and leave `[REDACTED:...]` behind them; an
350
 * ATIF rule re-matching one of those markers would rewrite a redaction as a
351
 * different redaction and count it twice, which inflates the report without
352
 * removing anything.
345 353
 */
346
const SEED_PHRASE_SHAPE = /\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b/g;
354
const fromAtifRule = (rule: AtifRedactionRule): RedactionRule => ({
355
  category: rule.category,
356
  pattern: rule.pattern,
357
  replacement: `[REDACTED:${rule.category}]`,
358
  resolve: (match, ...groups) =>
359
    match.includes("[REDACTED:") ? match : rule.replace(match, ...groups),
360
});
347 361
348 362
/**
349
 * Redact the longest run of consecutive BIP-39 words inside a shape match, and
350
 * only when that run is a whole mnemonic. Surrounding prose survives, which is
351
 * what keeps this rule usable on a real session log.
363
 * The rules that exist because this is a TRACE and not an ATIF export.
364
 *
365
 * A trace is a local session log, so it carries this machine's home path and
366
 * whatever got pasted into a prompt. These rules rewrite a home path to `~`
367
 * rather than to a tag, keep the seed phrase the CLI now hands out, and cover
368
 * the `NAME=value` and `"secret": "..."` shapes a session log is full of.
369
 *
370
 * They run BEFORE the ATIF rules so their category names -- and the `~`
371
 * rewrite -- win on the shapes both lists know about. The ATIF rules then
372
 * cover everything these do not.
352 373
 */
353
const seedPhraseResolve = (match: string): string => {
354
  const words = match.split(" ");
355
  let bestStart = -1;
356
  let bestLength = 0;
357
  let runStart = 0;
358
  let runLength = 0;
359
  for (let index = 0; index < words.length; index += 1) {
360
    if (BIP39_ENGLISH_WORDS.has(words[index] as string)) {
361
      if (runLength === 0) runStart = index;
362
      runLength += 1;
363
      if (runLength > bestLength) {
364
        bestLength = runLength;
365
        bestStart = runStart;
366
      }
367
    } else {
368
      runLength = 0;
369
    }
370
  }
371
  if (bestLength < MIN_SEED_WORDS) return match;
372
  return [
373
    words.slice(0, bestStart).join(" "),
374
    "[REDACTED:seed_phrase]",
375
    words.slice(bestStart + bestLength).join(" "),
376
  ]
377
    .filter((part) => part !== "")
378
    .join(" ");
379
};
380
381
/** The conservative rule set. `home` scopes the path rules to this machine. */
382
export const redactionRules = (home: string): ReadonlyArray<RedactionRule> => [
383
  {
384
    // The CLI now keeps one seed phrase per machine and tells people to write
385
    // it down, so a phrase pasted into a session is a shape this promise has to
386
    // cover. `npub` is deliberately not here: it is the public name.
387
    category: "seed_phrase",
388
    pattern: SEED_PHRASE_SHAPE,
389
    replacement: "[REDACTED:seed_phrase]",
390
    resolve: seedPhraseResolve,
391
  },
392
  {
393
    category: "private_key",
394
    pattern:
395
      /\b(?:nsec1[02-9ac-hj-np-z]{50,}|(?:xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{50,})\b/g,
396
    replacement: "[REDACTED:private_key]",
397
  },
398
  {
399
    category: "bearer_token",
400
    pattern: /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{8,}/g,
401
    replacement: "Bearer [REDACTED:bearer_token]",
402
  },
403
  {
404
    category: "api_key",
405
    pattern:
406
      /\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|gho_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{30,})\b/g,
407
    replacement: "[REDACTED:api_key]",
408
  },
409
  // The `api_key` rule above covers other people's credentials and stopped
410
  // there, so this command redacted a Stripe key and left an OpenAgents one.
411
  // These are our own token families, and they are the ones most likely to be
412
  // in an OpenAgents trace. Ordered narrowest first so `oa_agent_` is not
413
  // consumed by the general rule.
414
  {
415
    category: "oa_agent_token",
416
    pattern: /\boa_agent_[A-Za-z0-9_-]{6,}\b/g,
417
    replacement: "[REDACTED:oa_agent_token]",
418
  },
419
  {
420
    category: "x_code",
421
    pattern: /\boa-x-[A-Za-z0-9_-]{4,}\b/g,
422
    replacement: "[REDACTED:x_code]",
423
  },
424
  {
425
    category: "oa_token",
426
    pattern: /\boa_(?:live|test|sk|key|secret|tok|token|pat)?_?[A-Za-z0-9]{12,}\b/g,
427
    replacement: "[REDACTED:oa_token]",
428
  },
429
  {
430
    // Machine tokens minted by computer pairing. They carry a hyphen, which
431
    // the token rules above stop at.
432
    category: "machine_token",
433
    pattern: /\bsmct_[A-Za-z0-9_-]{6,}\b/g,
434
    replacement: "[REDACTED:machine_token]",
435
  },
436
  {
437
    category: "jwt",
438
    pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
439
    replacement: "[REDACTED:jwt]",
440
  },
374
const traceSpecificRules = (home: string): ReadonlyArray<RedactionRule> => [
441 375
  {
442 376
    category: "secret_field",
443 377
    pattern:

@@ -466,6 +400,28 @@ export const redactionRules = (home: string): ReadonlyArray<RedactionRule> => [

466 400
  },
467 401
];
468 402
403
/**
404
 * The rule set `openagents trace redact` runs, in order. `home` scopes the path
405
 * rules to this machine.
406
 *
407
 * This list used to be written out by hand alongside the one in
408
 * `packages/atif/src/redaction.ts`, and the two drifted twice. `oa_pat_`,
409
 * `oa_token`, `oa_agent_` and `oa-x-` were in ATIF and missing here, so this
410
 * command reported "Nothing matched the redaction rules" over a file full of
411
 * live OpenAgents tokens; `smct_` was missing from both. Minting a token family
412
 * meant remembering two places, and forgetting produced no error -- it produced
413
 * a redaction that quietly reported success.
414
 *
415
 * So there is one list now. Every credential family comes from ATIF, which is
416
 * authoritative; this module adds only what is specific to a local session log
417
 * and nothing that ATIF already covers. `test/redaction-parity.test.ts` fails
418
 * when a credential category exists in ATIF and has no coverage here.
419
 */
420
export const redactionRules = (home: string): ReadonlyArray<RedactionRule> => [
421
  ...traceSpecificRules(home),
422
  ...atifCredentialRules.map(fromAtifRule),
423
];
424
469 425
export interface RedactionResult {
470 426
  readonly text: string;
471 427
  /** Matches per category. Counts only; the matched text is never returned. */
packages/openagents-cli/test/redaction-parity.test.ts added +160

@@ -0,0 +1,160 @@

1
/**
2
 * The guard that keeps the CLI redaction path from drifting away from ATIF.
3
 *
4
 * There used to be two hand-written rule lists -- one in
5
 * `packages/atif/src/redaction.ts`, one in `src/trace-store.ts` -- and they
6
 * drifted twice. `oa_pat_`, `oa_token`, `oa_agent_` and `oa-x-` were in ATIF
7
 * and missing from the CLI, so `openagents trace redact` printed "Nothing
8
 * matched the redaction rules" over a file full of live OpenAgents tokens.
9
 * `smct_` was missing from both. Neither gap produced an error; both produced a
10
 * redaction that reported success.
11
 *
12
 * There is one list now, and this file is what holds it to that. Each test
13
 * fails at a different link in the chain, so adding a token family walks the
14
 * author to the place that still needs it:
15
 *
16
 *   1. Add a rule to ATIF, forget to classify it   -> compile error.
17
 *   2. Classify it, forget the planted secret      -> the ATIF fixture test.
18
 *   3. Plant it, forget the CLI                    -> the tests below.
19
 *
20
 * Assertions here are ALWAYS that the secret BODY is absent. Asserting that a
21
 * marker appeared would pass for a redaction that swapped a prefix and left the
22
 * key in place, which is the original bug.
23
 */
24
import { readFileSync } from "node:fs";
25
import { describe, expect, it } from "vitest";
26
27
import {
28
  REDACTION_CATEGORY_CLASS,
29
  atifCredentialRules,
30
  atifRedactionRules,
31
  isCredentialCategory,
32
} from "../src/memory/redaction.js";
33
import { redactText, redactionRules } from "../src/trace-store.js";
34
35
interface PlantedSecret {
36
  readonly label: string;
37
  readonly category: string;
38
  readonly credential: boolean;
39
  readonly raw: string;
40
  readonly leak: string;
41
}
42
43
const planted: ReadonlyArray<PlantedSecret> = (
44
  JSON.parse(
45
    readFileSync(
46
      new URL("../../../fixtures/redaction/planted-secrets.json", import.meta.url),
47
      "utf8",
48
    ),
49
  ) as { secrets: ReadonlyArray<PlantedSecret> }
50
).secrets;
51
52
const credentials = planted.filter((entry) => entry.credential);
53
54
const home = "/Users/octavia";
55
const rules = redactionRules(home);
56
57
describe("the CLI redaction path against the shared planted secrets", () => {
58
  it("has at least one planted credential to check", () => {
59
    // A fixture that failed to load would make every loop below vacuous, and a
60
    // suite that asserts nothing is the same failure this file exists to stop.
61
    expect(credentials.length).toBeGreaterThanOrEqual(16);
62
  });
63
64
  it.each(credentials)("removes the body of the planted $label", ({ leak, raw }) => {
65
    const result = redactText(raw, rules);
66
    expect(result.text).not.toContain(leak);
67
    expect(result.total).toBeGreaterThanOrEqual(1);
68
  });
69
70
  it("removes every planted credential from one document at once", () => {
71
    // Rules run in order over one growing string, so a rule can consume the
72
    // text a later rule was going to match. Per-line checks miss that; this
73
    // plants the whole set in a single document and checks the same floor.
74
    const document = credentials.map((entry) => entry.raw).join("\n");
75
    const result = redactText(document, rules);
76
    const survivors = credentials
77
      .filter((entry) => result.text.includes(entry.leak))
78
      .map((entry) => entry.label);
79
    expect(survivors).toEqual([]);
80
  });
81
82
  it("never echoes a secret back in the report it prints", () => {
83
    // The report is what the command shows the operator. It carries counts.
84
    const document = credentials.map((entry) => entry.raw).join("\n");
85
    const { counts, total } = redactText(document, rules);
86
    const report = JSON.stringify({ counts, total });
87
    for (const entry of credentials) {
88
      expect(report, `${entry.label} appeared in the report`).not.toContain(entry.leak);
89
    }
90
  });
91
});
92
93
describe("the CLI rule list against the ATIF rule list", () => {
94
  const cliCategories = new Set(rules.map((rule) => rule.category));
95
96
  it("carries every ATIF credential rule, by pattern, not by retyping it", () => {
97
    // Identity, not equality: the CLI list holds the SAME RegExp objects the
98
    // ATIF list holds. A rule that was copied rather than imported fails here
99
    // even when the copy is currently byte-identical, because a copy is what
100
    // drifts on the next edit.
101
    const cliPatterns = new Set(rules.map((rule) => rule.pattern));
102
    const restated = atifCredentialRules
103
      .filter((rule) => !cliPatterns.has(rule.pattern))
104
      .map((rule) => rule.category);
105
    expect(restated).toEqual([]);
106
  });
107
108
  it("covers every ATIF credential category", () => {
109
    const uncovered = atifCredentialRules
110
      .map((rule) => rule.category)
111
      .filter((category) => !cliCategories.has(category));
112
    expect(
113
      uncovered,
114
      "these credential categories exist in packages/atif/src/redaction.ts and " +
115
        "have no coverage in the openagents trace redact path",
116
    ).toEqual([]);
117
  });
118
119
  it("adds only trace-specific rules of its own", () => {
120
    // The rules the CLI still owns are the ones ATIF does not have a category
121
    // for: a JSON field named like a secret, a broad `NAME=value` line, and the
122
    // home-path rewrite to `~` that a trace wants and an export does not. If
123
    // this list grows, the new rule probably belongs in ATIF instead, where all
124
    // three redaction paths would get it.
125
    const own = rules
126
      .filter((rule) => !atifRedactionRules.some((atif) => atif.pattern === rule.pattern))
127
      .map((rule) => rule.category);
128
    expect(own).toEqual(["secret_field", "env_value", "env_value", "home_path", "home_path"]);
129
  });
130
131
  it("keeps the ATIF categories the trace path deliberately declines", () => {
132
    // Not every ATIF category belongs in a trace redaction. `long_blob` would
133
    // eat a public `npub`, and `home_path` is handled here as a `~` rewrite
134
    // rather than a tag. Naming them keeps the omission a decision rather than
135
    // an oversight -- and the compile-time `Record<RedactionCategory, ...>` in
136
    // ATIF means a NEW category cannot land in this set by default.
137
    const declined = atifRedactionRules
138
      .map((rule) => rule.category)
139
      .filter((category) => !isCredentialCategory(category));
140
    // Compared as a set, so this asserts membership without depending on the
141
    // order the rules happen to be written in.
142
    expect(new Set(declined)).toEqual(
143
      new Set([
144
        "date_of_birth",
145
        "email",
146
        "file_url",
147
        "home_path",
148
        "ip",
149
        "long_blob",
150
        "medical_record_id",
151
        "owner_id",
152
        "phone",
153
        "ssn",
154
      ]),
155
    );
156
    for (const category of declined) {
157
      expect(REDACTION_CATEGORY_CLASS[category]).toBe("other");
158
    }
159
  });
160
});
packages/openagents-cli/test/trace-store.test.ts modified +10 -5

@@ -205,19 +205,24 @@ describe("trace redaction", () => {

205 205
  const home = "/Users/octavia";
206 206
  const rules = redactionRules(home);
207 207
208
  // These name the CATEGORY each shape is counted under, which the shared
209
  // fixture deliberately does not: the three redaction paths use different
210
  // category vocabularies, so the shared floor in `redaction-parity.test.ts`
211
  // asserts only that the secret body is gone. This list is the local check
212
  // that the report an operator reads names the right thing.
208 213
  const plantedSecrets: ReadonlyArray<{ category: string; text: string; secret: string }> = [
209 214
    {
210
      category: "bearer_token",
215
      category: "bearer",
211 216
      text: "authorization: Bearer sec.ret-token.value-12345",
212 217
      secret: "sec.ret-token.value-12345",
213 218
    },
214 219
    {
215
      category: "api_key",
220
      category: "provider_key",
216 221
      text: "used sk-abcdefghijklmnop1234 to call",
217 222
      secret: "sk-abcdefghijklmnop1234",
218 223
    },
219 224
    {
220
      category: "api_key",
225
      category: "github_token",
221 226
      text: "pushed with ghp_abcdefghijklmnopqrst123456",
222 227
      secret: "ghp_abcdefghijklmnopqrst123456",
223 228
    },

@@ -240,7 +245,7 @@ describe("trace redaction", () => {

240 245
      // The published BIP-39 test phrase, not anyone's seed. `openagents
241 246
      // identity` gives every machine one of these to keep, so a phrase pasted
242 247
      // into a session is now a shape a redacted export has to remove.
243
      category: "seed_phrase",
248
      category: "mnemonic",
244 249
      text: "my backup is abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about ok",
245 250
      secret: "abandon abandon",
246 251
    },

@@ -313,7 +318,7 @@ describe("trace redaction", () => {

313 318
    const result = redactText(`${prose} for ${npub}`, rules);
314 319
    expect(result.text).toContain(prose);
315 320
    expect(result.text).toContain(npub);
316
    expect(result.counts["seed_phrase"]).toBeUndefined();
321
    expect(result.counts["mnemonic"]).toBeUndefined();
317 322
    expect(result.counts["private_key"]).toBeUndefined();
318 323
  });
319 324

This page updates live while a promote is in flight · changelog