pi-longhaul 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,70 @@ entries and log event types. Rendered state text, prompt wording and section lay
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [v1.0.4] - 2026-10-22
12
+
13
+ ### Fixed
14
+
15
+ - The summarizer copied credentials into the state it wrote: a password from a script's output into Resume, an API
16
+ key or Bearer value into Now, decisions and the narrative. The state is sent with every later request, so the
17
+ value went out again at every turn, and states on real sessions held live ones. What the summarizer writes is now
18
+ scrubbed before it is recorded: each credential value becomes `<redacted>` and the rest of the line stays. A value
19
+ is one when the span showed it as a credential (a credential key's value, symbols and all, a command's password
20
+ argument, a value a secret store printed, `the password is …`), wherever the summarizer put it and however it
21
+ wrapped it; when the text names it
22
+ (`password=…`, `"api_key": "…"`, `Bearer …`, a `user:pass@` login, or a random-looking value just after a
23
+ credential word, so `password (64-hex): <hex>` and `the gateway key (<uuid>)`); or when its shape is one wherever
24
+ it stands (a JWT, a Vault token, AWS, GitHub, OpenAI-style, Slack and age keys). The span's credentials include
25
+ what the previous summary and a switchover's earlier messages showed. A digest (`sha256:…`) is never one, and
26
+ neither is a path, a count, a version or a date the text gives a key, nor, among the span's credentials, a name
27
+ (`app-token`) under a token or secret key, an id or hash its label names (`Evaluation ID:`, `plan_hash`,
28
+ `commit`), the rest of a command after a key whose value it computes (`TOKEN=$(vault kv get … | python3 …)`), a test
29
+ name after `--- PASS:`, or a value after a credential word that is part of a file name (`~/.vault-token`): on real
30
+ sessions the scrub took out the credentials and left versions, hashes, ids and names alone. A state written before this shows redacted from its session's next compaction
31
+ on (the summary, the summarizer's prompt, `longhaul_recall`, `npm run state`), by every rule but the span's own
32
+ credentials, which only the pass that wrote it had. Until then pi keeps sending the summary it stored, and a
33
+ transcript keeps every summary and record it already holds, so a credential found in a state must be rotated. The compaction log records how many values a pass redacted
34
+ (`redacted`), never the values. Both summarizer prompts also tell the model never to copy a credential's value
35
+ and to say where it lives instead, and `longhaul_remember` refuses a decision or dead end that carries one.
36
+ - An error line naming a credential before its value (`ops-control password (64-hex): <value>`) was recorded as an
37
+ open error and shown in every state; no command-style shape matched it. Such a line is now recorded with the value
38
+ redacted, so the failure stays open, and one already carried is redacted at the next compaction.
39
+ - The credential rules found too little: a password key glued to its prefix (`PGPASSWORD=`, `MYSQL_PWD=`), short
40
+ keys (`pw=`, `pass=`), `Authorization: Basic`, a private key's body past its first line, and GitLab, Google API,
41
+ Stripe, npm and Hugging Face tokens, now in both the scrub and the command ledger's credential shapes.
42
+
43
+ ## [v1.0.3] - 2026-10-22
44
+
45
+ ### Changed
46
+
47
+ - Tested with pi 1.3.0, now recorded in `pi-compat.json`. No code changes to the extension for it.
48
+
49
+ ### Fixed
50
+
51
+ - A span's record was lost whenever another extension wrote an entry between pi's compaction and Longhaul's span
52
+ record. pi-goal does this at every compaction (its `goal-state`, sometimes a `goal-contract` message), active
53
+ goal or not, so in a session with pi-goal installed most spans folded as narrative-less: the next state kept
54
+ only the span just closed, and every earlier span's work-log line, Now, decisions, dead ends, objective moves and
55
+ labelled rules were gone from it. The fold now passes over other extensions' entries until our record arrives; a
56
+ message, a compaction or one of Longhaul's own event entries arriving first still means the record never
57
+ landed. The records were always written, so an affected transcript gets its history back at its next
58
+ compaction. While its records were lost, each pass was shown earlier messages again and recorded their rules
59
+ again under new ids; with the records back, a rule recorded from the same message in near-duplicate words is
60
+ one rule, under its newest id, and a lift of any copy lifts it. A rule the user withdrew while its record was
61
+ lost could not be lifted then (the summarizer did not see it) and comes back now, so look over the
62
+ constraints the first state after the upgrade shows.
63
+ - Spans whose records were written while earlier records were being lost can carry the same event seqs as an
64
+ earlier span (each pass numbered from a fold that had lost events). The fold now treats a seq as unique within
65
+ its span, so those spans fold too; a seq repeated inside one span is still refused as a duplicate. A correction
66
+ `longhaul_recall` shows as lifted is now the one the lift landed on, never another span's event with the same
67
+ seq.
68
+ - A replayed event whose span or seq is not a whole number from 1 is refused as malformed. A seq of `1e999`
69
+ (Infinity) used to be accepted and broke the numbering of every event after it: a `longhaul_remember` write
70
+ was then stored without a seq and dropped, with no anomaly, when the transcript was read back.
71
+ - The load smoke (`npm run load-smoke`, a development check that is not part of the package) resolved a
72
+ wildcard `exports` target by replacing only its first `*`; it now replaces every one, as Node does (CodeQL
73
+ `js/incomplete-sanitization`). No pi package uses a target with two, so the smoke's results are unchanged.
74
+
11
75
  ## [v1.0.2] - 2026-10-13
12
76
 
13
77
  ### Changed
package/CONTEXT.md CHANGED
@@ -76,6 +76,12 @@ Values a later turn may need again, verbatim, with where each came from: those t
76
76
  that the session used again (a hash, a version, a long number).
77
77
  _Avoid_: facts (the scorer's measure of a related but different set)
78
78
 
79
+ **Credential scrub**:
80
+ Taking each credential value out of the text the summarizer writes into the state (the narrative, Now,
81
+ decisions, dead ends), leaving `<redacted>` and the words around it. The value is gone; what it was and where it
82
+ lives can stay.
83
+ _Avoid_: sanitize (one-line.ts: control characters and newlines), redact (the ledger's word for dropping a whole command)
84
+
79
85
  ## Configuration
80
86
 
81
87
  **Agent dir**:
package/README.md CHANGED
@@ -349,7 +349,16 @@ compaction than one that can.
349
349
  never from a line that looks like it holds a credential. A value the summarizer names is kept only when it
350
350
  stands in the turn it cites and no part of it stood on a credential line anywhere in the span; its name is
351
351
  shown as the summarizer's label.
352
- That filter works on patterns: a bare token with no key or secret marker near it can still be learned and
352
+ - What the summarizer writes into the state (the narrative, Now, decisions and dead ends) has each credential
353
+ value replaced by `<redacted>` before it is recorded: a whole value the span showed as a credential (a credential
354
+ key's value, a command's password argument, a secret store's output, `the password is …`), however it is wrapped, a value after a credential key,
355
+ `Bearer` or a credential word, a `user:pass@` password, and token shapes (JWTs, Vault, AWS, GitHub, OpenAI-style,
356
+ Slack, age). States written before this are shown the same way from their next compaction on, by everything but
357
+ the span's own credentials; a stored summary and the transcript keep what they hold, so rotate a credential found
358
+ in a state.
359
+ The prompt also tells the summarizer never to copy one, and `longhaul_remember` refuses a decision or dead end
360
+ that carries one. Error lines are recorded with the value redacted.
361
+ These filters work on patterns: a bare token with no key or secret marker near it can still be learned and
353
362
  carried. Treat a session's transcript as you would its tool output.
354
363
 
355
364
  The threat model is in [docs/security.md](docs/security.md).
@@ -361,9 +370,9 @@ listed in [pi-compat.json](pi-compat.json). On a listed line it runs silently. O
361
370
  per session and runs; a session that opted out of it (`PI_COMPACTOR=pi`) is not warned. If a compaction fails, pi's own compaction takes over. A nightly job tests against the
362
371
  newest pi and opens an issue when it breaks.
363
372
 
364
- Tested with pi 0.87.1, 0.99.0, 0.99.1, 0.99.2, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.1.0 and 1.2.0. Upgrading or
365
- downgrading pi mid-project is covered too: CI hands one transcript through every listed pi, oldest to newest and
366
- back, with a Longhaul compaction by each, and each must continue what the others wrote.
373
+ Tested with pi 0.87.1, 0.99.0, 0.99.1, 0.99.2, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.1.0, 1.2.0 and 1.3.0.
374
+ Upgrading or downgrading pi mid-project is covered too: CI hands one transcript through every listed pi, oldest to
375
+ newest and back, with a Longhaul compaction by each, and each must continue what the others wrote.
367
376
 
368
377
  ## Known issues
369
378
 
package/docs/security.md CHANGED
@@ -39,6 +39,11 @@ extension.
39
39
  reading it into memory.
40
40
  - A timestamp a `Date` cannot hold is dropped rather than thrown on.
41
41
  - An id held by two entries (an old transcript can carry a re-minted id) resolves to the newest holder.
42
+ - A rule recorded again from the message that gave it (near-duplicate words) folds into that rule; a copy of a
43
+ lifted rule stays lifted, so restored span records cannot bring back a rule the user withdrew from a copy. A lift
44
+ lands on the rule holding its id now, and on one that held it before only when none does: the store judges the
45
+ lift against that same rule, and `longhaul_recall` marks lifted what the state lifted.
46
+ - A span or seq that is not a whole number from 1 (`1e999` parses as Infinity) makes the event malformed.
42
47
  - An entry from a newer state format is refused by name, so a downgrade starts clean rather than misreading.
43
48
  - Learned values read back from a transcript are checked again (length, printable ASCII, count). A name is
44
49
  checked as when it was given and dropped if it fails, and a named value must still have a value's shape.
@@ -212,6 +217,64 @@ the session (a web page, a README, another agent's message) could try to plant a
212
217
  replacement. A credential token anywhere in those turns refuses a value of the span as well, and so does one in
213
218
  the original words of an edited message, in a message another extension injected, or on a credential line of the
214
219
  summary, which is never a source itself.
220
+ - What the summarizer writes into the state (the narrative, every Now item and Resume, decisions, dead ends) is
221
+ scrubbed before the span record is written (`credential-scrub.ts`): each credential value is replaced by
222
+ `<redacted>` and the words around it stay. A value counts when its shape is one wherever it stands (a JWT, a Vault
223
+ token, a private key's whole body, AWS, GitHub, GitLab, Google API, Stripe, npm, Hugging Face, OpenAI-style, Slack
224
+ and age keys); when the text names it, as a credential key's value (`DB_PASSWORD=`, `PGPASSWORD=`, `MYSQL_PWD=`,
225
+ `"api_key":`, escaped quotes too, symbols kept in the value up to a space or a quote; a password word may be glued
226
+ to its prefix, any other only after `_` or `-`, so `compass:` is no key; a value-like or opaque one of twelve
227
+ characters or more, never a path, and a number, count, version or date only as six digits and more under a key
228
+ that says password in full, so `token: missing`, `tests pass: 120/120` and `e2e_pass: 1234567` stay), a value-like
229
+ `Bearer` or `Basic` value, the password of a `user:pass@` URL (to the word's last `@`), or a random-looking value
230
+ (a UUID, hex from 20 characters, a long mixed-case run with digits) starting within 40 characters after a
231
+ credential word; or when it is one of the span's credentials (`span-credentials.ts`): a whole value the span
232
+ itself showed as a credential, found however the summarizer wrapped it (quoted, bold, between dashes or curly
233
+ quotes, after `=`, glued to a flag, in a login with or without a scheme: `app/x@db`, `app:x@db:5432`). The span's
234
+ credentials come from everything the summarizer was shown: the span, the previous summary, a switchover's earlier
235
+ user messages and, in warm mode, the session's last request. They are built from strong evidence only, because each
236
+ one is taken out everywhere: what the text rules above name; the value of a key named for one credential,
237
+ ending in its name or a short part after it (singular, so `tokens: 1200` and a list of `secrets` are not one, and
238
+ no key about one: `password_policy`, `token_env`), any shape under a password key
239
+ (`Summer2024.x`, `unmade-tremor-oxidize`, `48291375`), not a name under a token, secret or key key (`app-token`,
240
+ `APP_TOKEN`, a file or host name); a command's password argument (`mysql -pX`, `sshpass -p X`, `redis-cli -a
241
+ X`, `curl -u user:X`, `htpasswd -b FILE USER X`, `--db-password X`, `openssl -pass pass:X`, `keytool -storepass
242
+ X`); a random-looking word on another credential line; a value a secret store printed alone (or a random-looking
243
+ one among its other output); and `the password is …`, `the db pass is …`. Never a number (but a password's),
244
+ version, date or path, nor one plain lowercase word. A key holds its quoted string or its first word (after Go's
245
+ `:=` or a type, `db_password: str = "…"`; a YAML password's plain value runs to the line's end), never the rest of
246
+ a command line, and nothing when its value is computed (`TOKEN=$(vault kv get … | python3 -c "import sys,json")`
247
+ once made `python3` a credential); a random-looking word the word before it on its line labels as an id or a hash
248
+ (`Evaluation ID:`, `plan_hash`, `commit`) is that, not a secret, unless the label names a credential
249
+ (`secret_id`); and an upper-case `--- PASS: TestName` is a test runner's status, while `pass: 'x'` and `pass=x`
250
+ are keys. A credential word inside a path or file name (`/tmp/admin-bearer.txt`,
251
+ `~/.vault-token`) says where one lives, so the value after it is not taken for one. The broader set that refuses a named value
252
+ (narrative/named-values.ts, with the words after "pin", "card" or "login" and a secret store's whole output)
253
+ redacted versions, commit hashes and names in about a third of real fields, so the scrub does not use it. A
254
+ `sha256:` digest is never one, and a variable or placeholder (`$KEY`, `<from vault>`) stays. The projection applies
255
+ the text rules only, since only the pass that wrote a record had its span, so a record written before the scrub
256
+ shows redacted wherever the state is read from its session's next compaction on: the summary, the summarizer's
257
+ prompt (the open span's remembered items too), `longhaul_recall` (every hit, error lines and the span's narrative
258
+ included) and the state CLI. Nothing rewrites what is stored: pi re-sends the latest compaction's summary string
259
+ until the next compaction (on an older branch, whenever someone returns to it), and the transcript keeps the old
260
+ summaries, `details.state` checkpoints, span records, remember entries and the turns trace's model-written
261
+ reasons. A credential found in a state was sent with every request since; rotate it. `longhaul_remember` refuses
262
+ a decision or dead end that the scrub would change, with the credentials of every message still in context (the
263
+ kept window included). Measured on 24 real transcripts: the text rules redact 22 values over 6612 model-written
264
+ fields, at least one of them not a secret (an Ed25519 public key after "key"); the span's credentials, each span
265
+ against its own messages and the summary before it, redact 7 more over 2981 fields in 224 spans, every one a
266
+ credential or the visible part of one (a Wi-Fi passphrase, two admin passwords, a token's printed prefix, a rotated
267
+ secret's prefix). The summarizer prompts say never to
268
+ copy a credential's value; the scrub is what holds when a model does. An error signature with a command-style
269
+ shape is never recorded; any other is recorded as the scrub leaves it (`password (64-hex): <redacted>`), carried
270
+ ones too, so an ordinary failure that mentions a secret by id stays open. Every pattern is linear on hostile text
271
+ (T4); a private key without an END line is cut 16 KiB on. Not found: a credential the span never showed as one,
272
+ written in prose with no credential word near it; a token or secret key's value shaped as a name; without the
273
+ span, a value on the line after its key (`password:` then the value), a key in smart quotes or before a full-width
274
+ colon; a password given to `docker login -p` or piped to a `--password-stdin` command; a passphrase with spaces
275
+ (`correct horse battery staple`); a key about a password rather than holding one (`password_policy:`) gives no
276
+ value, by design; and a symbol at a value's edge (`Summer2024!` leaves `<redacted>!`). The user's own words (objective,
277
+ done-when and correction quotes) are verbatim by design and not scrubbed.
215
278
  - The ledger redacts commands that carry a secret, by the same patterns (a one-time-password seed passed to
216
279
  `oathtool -b` (or `-bv`, `--base32`, or a hex or base32 key of 16 or more characters), `ykman oath accounts add`,
217
280
  `ykman ... --key`, `sshpass -p`, `htpasswd -b`, `openssl -pass pass:`, `keytool -storepass`, `redis-cli -a`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-longhaul",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Keep pi sessions working all night. A compactor that carries your session forward instead of rewriting it: your rules kept in your own words, the state written by code, old reasoning left behind, and about 5x faster than pi's own compaction on a local model.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "extensions": [
33
33
  "./src/index.ts"
34
34
  ],
35
- "image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.0.2/.github/assets/infographic.png"
35
+ "image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.0.4/.github/assets/infographic.png"
36
36
  },
37
37
  "files": [
38
38
  "src/**/*.ts",
@@ -75,10 +75,10 @@
75
75
  "typebox": "*"
76
76
  },
77
77
  "devDependencies": {
78
- "@earendil-works/pi-coding-agent": "1.2.0",
79
- "@types/node": "^24.19.1",
78
+ "@earendil-works/pi-coding-agent": "1.3.0",
79
+ "@types/node": "^24.19.2",
80
80
  "typebox": "1.3.7",
81
- "typescript": "5.9.3"
81
+ "typescript": "7.0.2"
82
82
  },
83
83
  "author": "Justin Barlow"
84
84
  }
package/pi-compat.json CHANGED
@@ -93,6 +93,15 @@
93
93
  "npm run smoke and npm run cross-version -- --extra 1.2.0: every listed pi continues 1.2.0's transcripts and it continues theirs",
94
94
  "one real compaction through pi --mode rpc on the setup-check fixture with a local model: a compaction record, no fallback"
95
95
  ]
96
+ },
97
+ {
98
+ "pi": "1.3.0",
99
+ "date": "2026-10-16",
100
+ "how": [
101
+ "npm test on the vendored pi (the version gate skipped): typecheck, unit suite, load smoke and the extension-API contract checks",
102
+ "npm run smoke and npm run cross-version -- --extra 1.3.0: every listed pi continues 1.3.0's transcripts and it continues theirs",
103
+ "one real compaction through pi --mode rpc on the setup-check fixture with a local model: a compaction record, no fallback"
104
+ ]
96
105
  }
97
106
  ]
98
107
  }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Credential scrub (CONTEXT.md *Credential scrub*; security.md T3): text a model wrote into the state (the narrative,
3
+ * Now, decisions, dead ends) and the open-error lines, with each credential value replaced by REDACTED_VALUE. The
4
+ * state is rendered into every later request, so a password the summarizer copied out of a tool's output was sent
5
+ * again at every turn. Only the value goes: the words around it stay, so a Resume line still says what to do next.
6
+ *
7
+ * A value is a credential when
8
+ * - its shape is one wherever it stands: a JWT, a Vault token, an AWS, GitHub, OpenAI-style, Slack or age key;
9
+ * - the text names it: the value of a credential key (`password=x`, `"api_key": "x"`), a Bearer value, the password
10
+ * of a `user:pass@` URL, or a random-looking value within CONTEXT_REACH characters after a credential word
11
+ * (`password (64-hex): <hex>`, `the gateway key (<uuid>)`);
12
+ * - or it is one of the span's credentials (span-credentials.ts): a whole value the span itself showed as one
13
+ * (`DB_PASSWORD=Ab3$x;9Kq2`, symbols and all), wherever the model put it and however it wrapped it (quoted, bold,
14
+ * between dashes, glued to a flag or a login).
15
+ * A digest (`sha256:<hex>`) is never one. Every pattern is linear: a summarizer's answer is untrusted (security.md T4).
16
+ */
17
+
18
+ import type { StateEvent } from "./store/events.ts";
19
+
20
+ /** What a redacted value is replaced with. Holds no token character, so a second scrub leaves it alone. */
21
+ export const REDACTED_VALUE = "<redacted>";
22
+
23
+ /** The summarizer prompts' rule (narrative/prompt.ts, narrative/turns/conversation.ts); the scrub is what holds when a model ignores it. */
24
+ export const CREDENTIAL_RULE = 'Never copy a password, key, token or other credential\'s value into the state, in any field: say what it is and where it lives ("the gateway key from its Vault secret"), never the value itself.';
25
+
26
+ /** The span's credentials: whole values, each one judged a credential where the span showed it (span-credentials.ts). */
27
+ export type SpanCredentials = ReadonlySet<string>;
28
+
29
+ export interface Scrubbed {
30
+ text: string;
31
+ /** How many values were replaced; logged as a count, never the values. */
32
+ redacted: number;
33
+ }
34
+
35
+ /** A token: what a value is cut on, as named-values.ts TOKEN_SPLIT cuts a credential line. */
36
+ const TOKEN = /[^\s=:"'`,;{}[\]()<>]+/g;
37
+ /** A word: what a whole value is cut on. Space, and the dashes and quotes prose puts around a value (`—x—`, `“x”`). */
38
+ export const WORD = /[^\s\u2013\u2014\u2018\u2019\u201c\u201d\u00ab\u00bb]+/g;
39
+ /** How far after a credential word a random-looking value counts as its value. */
40
+ const CONTEXT_REACH = 40;
41
+
42
+ /** Shapes that are credentials wherever they stand (ledger/handoff-commands.ts SECRET_PATTERNS, and learned-secrets.ts TOKEN_SHAPES). */
43
+ const CREDENTIAL_SHAPES = [
44
+ /(?<![\w-])eyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]*/g,
45
+ /(?<![\w-])hv[sbr]\.[\w-]{8,}/g,
46
+ /\bAGE-SECRET-KEY-1[0-9A-Z]{20,}/g,
47
+ /\bAKIA[0-9A-Z]{16}\b/g,
48
+ /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,})/g,
49
+ // GitLab, Google API, Stripe, npm and Hugging Face tokens; as ledger/handoff-commands.ts SECRET_PATTERNS, kept in step.
50
+ /\b(?:glpat-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}|[sr]k_(?:live|test)_[A-Za-z0-9]{16,}|npm_[A-Za-z0-9]{36}|hf_[A-Za-z]{30,})/g,
51
+ ];
52
+ const PEM_BEGIN = /-----BEGIN [A-Z ]{0,64}PRIVATE KEY(?: BLOCK)?-----/g;
53
+ const PEM_END = "-----END ";
54
+ /** A private key's body without an END line is cut here. */
55
+ const PEM_CHARS = 16_384;
56
+
57
+ /**
58
+ * The key part names a credential. A password word may be glued to its prefix (`PGPASSWORD`, `MYSQL_PWD`: a lazy
59
+ * bounded prefix from a word start, at most 48 tries per start, linear, security.md T4); any other word only after
60
+ * `_` or `-` (`DB_PASS`, `client-secret`), so `compass` and `bypass` are no key. No plural `tokens` (a count). A bare
61
+ * `pass` is one only before `=` or a quoted value (`pass=`, `pass: 'x'`): `--- PASS: TestName` is a test runner's status.
62
+ */
63
+ const CREDENTIAL_KEY = String.raw`(?<![A-Za-z0-9])(?:[A-Za-z0-9_-]{0,48}?(?:passw(?:or)?ds?|passwd|pwd)|(?:[A-Za-z0-9]{1,32}[_-]){1,3}pass|pass(?=(?:\\{0,3}["'])?[ \t]{0,8}(?:=|:[ \t]{0,8}\\{0,3}["']))|(?:[A-Za-z0-9]{1,32}[_-]){0,3}(?:pw|passphrases?|passcodes?|secrets?|token|api[_-]?keys?|access[_-]?keys?|private[_-]?keys?|credentials?))`;
64
+ /**
65
+ * A key that ends in a password word in full, whose value may be all digits (`ROOT_PASSWORD=48291375`); not `pass`
66
+ * or `pw` (`e2e_pass: 1234567`), nor a key about passwords (`password_min_length: 123456`).
67
+ */
68
+ const NUMERIC_PASSWORD_KEY = /(?:passw(?:or)?d|passwd|pwd|passphrase|passcode)s?$/i;
69
+ /** Whether `key` (a key part, its quotes and separator included or not) says password in full at its end. */
70
+ export const isNumericPasswordKey = (key: string) => NUMERIC_PASSWORD_KEY.test(withoutTrailing(key, "\"' \t=:\\"));
71
+ /** `key = value`, `key: value`, quoted as JSON or not, escaped quotes included; the value is group 2, up to a space or a quote, so symbols in it stay in it. */
72
+ const KEYED_VALUE = new RegExp(String.raw`(${CREDENTIAL_KEY}(?:\\{0,3}["'])?[ \t]{0,8}[=:][ \t]{0,8}(?:\\{0,3}["'])?)([^\s"'\x60\\]{3,})`, "gi");
73
+ const BEARER_VALUE = /(\b(?:bearer|basic)\s+)([^\s"'`,;{}[\]()<>]{8,})/gi;
74
+ /** A URL login's prefix, `scheme://user:` (the user may be empty); its password runs to the word's last `@` (urlPasswordSpans). */
75
+ const URL_LOGIN = /[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/:@]{0,512}:/gi;
76
+ const URL_PASSWORD_CHARS = 512;
77
+ /** A credential named in prose: a random-looking value after it is its value. */
78
+ const CREDENTIAL_WORD = /(?<![A-Za-z])(?:passw(?:or)?ds?|passwd|pwd|passphrases?|passcodes?|secrets?|tokens?|api[_ -]?keys?|keys?|bearer|credentials?|auth)(?![A-Za-z])/gi;
79
+
80
+ /** A value the text points at rather than holds: a variable, a placeholder, a mask. */
81
+ const REFERENCE = /^(?:\$|%|\*|\{|<|\.\.\.)/;
82
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
83
+ /** As named-values.ts RANDOM_LOOKING: long hex with a digit and a letter, a long mixed-case run with digits, 13+ digits. */
84
+ const RANDOM_LOOKING = [/^\d{13,}$/, /^(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{13,}$/i, /^(?=[A-Za-z0-9_+/-]*[a-z])(?=[A-Za-z0-9_+/-]*[A-Z])(?=[A-Za-z0-9_+/-]*\d)[A-Za-z0-9_+/-]{16,}={0,2}$/];
85
+ const DIGEST_PREFIX = /sha(?:1|224|256|384|512):$/i;
86
+
87
+ export const isRandomLooking = (token: string) => UUID.test(token) || RANDOM_LOOKING.some((shape) => shape.test(token));
88
+ /** A token a credential could be: random-looking, or six characters or more with a digit. */
89
+ export const isValueLike = (token: string) => isRandomLooking(token) || (token.length >= 6 && /\d/.test(token));
90
+ /** A value a credential key holds: value-like, or opaque (twelve characters or more), never a path (`PWD=/home/app`); a word is a status ("token: missing"). A Bearer value must be value-like: "Bearer authentication" is prose. */
91
+ const isHeldValue = (token: string) => (isValueLike(token) || token.length >= 12) && !/^(?:\/|~\/|\.\.?\/)/.test(token);
92
+ /** A number, a count (`120/120`), a version, a time or a date: no credential, unless a password key holds it. */
93
+ const NUMBERISH = /^(?:v?\d+(?:[.:_/-]\d+)*|\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+Z?)?)$/i;
94
+ export const isNumberish = (token: string) => NUMBERISH.test(token);
95
+ /** A credential key's value (isHeldValue); a number only under a password key, so `tests pass: 120/120` stays. */
96
+ const isKeyedHeld = (token: string, key: string) => {
97
+ const bare = withoutTrailing(token, ",.;:!?)]}");
98
+ return isHeldValue(token) && (!isNumberish(bare) || (isNumericPasswordKey(key) && /^\d{6,}$/.test(bare)));
99
+ };
100
+ /**
101
+ * A value prose names only by a nearby credential word: random-looking, hex only from LONG_HEX characters, since a
102
+ * plan or object id near "secret" is 16 (measured on real states: plan hashes, short ids).
103
+ */
104
+ const LONG_HEX = 20;
105
+ const isNamedByContext = (token: string) => isRandomLooking(token) && !(/^[0-9a-f]+$/i.test(token) && token.length < LONG_HEX);
106
+
107
+ /** `token` without the trailing `marks`. A loop, not `/[.!?]+$/`: that regex retries from every mark of a long run (quadratic, security.md T4). */
108
+ function withoutTrailing(token: string, marks: string): string {
109
+ let end = token.length;
110
+ while (end > 0 && marks.includes(token[end - 1])) end--;
111
+ return token.slice(0, end);
112
+ }
113
+ /** `token` without the sentence marks that end it. */
114
+ const withoutClosingMarks = (token: string) => withoutTrailing(token, ".!?");
115
+
116
+ interface Span { start: number; end: number }
117
+
118
+ function shapeSpans(text: string): Span[] {
119
+ const spans: Span[] = [];
120
+ for (const shape of CREDENTIAL_SHAPES) for (const match of text.matchAll(shape)) spans.push({ start: match.index, end: match.index + match[0].length });
121
+ return [...spans, ...pemSpans(text)];
122
+ }
123
+
124
+ /** A private key from its BEGIN line through its END line, or PEM_CHARS on when it has none. One scan: each key starts after the last. */
125
+ function pemSpans(text: string): Span[] {
126
+ const spans: Span[] = [];
127
+ let from = 0;
128
+ for (const begin of text.matchAll(PEM_BEGIN)) {
129
+ if (begin.index < from) continue;
130
+ // END is looked for inside the key's window only: the rest of the text, once per key, is quadratic (security.md T4).
131
+ const window = text.slice(begin.index, begin.index + PEM_CHARS);
132
+ const end = window.indexOf(PEM_END, begin[0].length);
133
+ const close = end < 0 ? -1 : window.indexOf("-----", end + PEM_END.length);
134
+ const stop = begin.index + (close < 0 ? window.length : close + 5);
135
+ spans.push({ start: begin.index, end: stop });
136
+ from = stop;
137
+ }
138
+ return spans;
139
+ }
140
+
141
+ /** The value group (2) of each match of `pattern`, unless it only points at a value or `holds` refuses it (given the key part, group 1). */
142
+ function groupSpans(text: string, pattern: RegExp, holds: (value: string, key: string) => boolean = () => true): Span[] {
143
+ const spans: Span[] = [];
144
+ for (const match of text.matchAll(pattern)) {
145
+ if (REFERENCE.test(match[2]) || !holds(match[2], match[1])) continue;
146
+ const start = match.index + match[1].length;
147
+ spans.push({ start, end: start + match[2].length });
148
+ }
149
+ return spans;
150
+ }
151
+
152
+ /**
153
+ * Whether the credential word at [start, end) is part of a path or a file name (`/tmp/admin-bearer.txt`,
154
+ * `token.json`), which says where a credential lives, not that a value follows. Looks at most FILE_NAME_REACH back.
155
+ */
156
+ const FILE_NAME_REACH = 256;
157
+ function inFileName(text: string, start: number, end: number): boolean {
158
+ const before = text.slice(Math.max(0, start - FILE_NAME_REACH), start);
159
+ return /\/[^\s/]*$/.test(before) || /^[\w-]*\.[A-Za-z][A-Za-z0-9]{0,4}(?![\w-])/.test(text.slice(end, end + FILE_NAME_REACH));
160
+ }
161
+
162
+ /** Tokens named by context (isNamedByContext) that start within CONTEXT_REACH characters after a credential word, digests excepted. */
163
+ function contextSpans(text: string, tokens: ReadonlyArray<{ token: string; start: number }>): Span[] {
164
+ const spans: Span[] = [];
165
+ let index = 0;
166
+ for (const word of text.matchAll(CREDENTIAL_WORD)) {
167
+ const after = word.index + word[0].length;
168
+ if (inFileName(text, word.index, after)) continue;
169
+ while (index < tokens.length && tokens[index].start < after) index++;
170
+ for (let next = index; next < tokens.length && tokens[next].start <= after + CONTEXT_REACH; next++) {
171
+ const { token, start } = tokens[next];
172
+ const bare = withoutClosingMarks(token);
173
+ if (isNamedByContext(bare) && !DIGEST_PREFIX.test(text.slice(Math.max(0, start - 7), start))) spans.push({ start, end: start + bare.length });
174
+ }
175
+ }
176
+ return spans;
177
+ }
178
+
179
+ /**
180
+ * The password of each `scheme://user:pass@host`: up to the last `@` of the word, so a password holding one is taken
181
+ * whole (`app:p@ss1234@db`). A port followed by a path is no login. Each login looks at most URL_PASSWORD_CHARS on.
182
+ */
183
+ function urlPasswordSpans(text: string): Span[] {
184
+ const spans: Span[] = [];
185
+ for (const match of text.matchAll(URL_LOGIN)) {
186
+ const start = match.index + match[0].length;
187
+ const window = text.slice(start, start + URL_PASSWORD_CHARS + 1);
188
+ const space = window.search(/\s/);
189
+ const word = space < 0 ? window : window.slice(0, space);
190
+ const at = word.lastIndexOf("@");
191
+ if (at <= 0 || /^\d{1,5}\//.test(word) || REFERENCE.test(word)) continue;
192
+ spans.push({ start, end: start + at });
193
+ }
194
+ return spans;
195
+ }
196
+
197
+ const isWordChar = (char: string) => /[\p{L}\p{N}]/u.test(char);
198
+
199
+ /** `word` without what wraps it (quotes, `**`, brackets, a closing mark), as offsets into `word`; empty when nothing is left. */
200
+ export function credentialCore(word: string): { start: number; end: number } {
201
+ let start = 0;
202
+ let end = word.length;
203
+ while (start < end && !isWordChar(word[start])) start++;
204
+ while (end > start && !isWordChar(word[end - 1])) end--;
205
+ return { start, end };
206
+ }
207
+
208
+ /**
209
+ * Where a value may stand in a word, widest first: the word, after its first or last `=` or `:` (`DB_PASSWORD='x'`),
210
+ * after a short flag (`-px`), and each part between `/` and `@` (`app/x@db`), also after that part's first `:`
211
+ * (`app:x@db:5432`). Linear in the word.
212
+ */
213
+ function placesIn(word: string): Array<[start: number, end: number]> {
214
+ const places: Array<[number, number]> = [[0, word.length]];
215
+ const first = word.search(/[=:]/);
216
+ const last = Math.max(word.lastIndexOf("="), word.lastIndexOf(":"));
217
+ if (first >= 0) places.push([first + 1, word.length]);
218
+ if (last > first) places.push([last + 1, word.length]);
219
+ const flag = /^-{1,2}[A-Za-z]/.exec(word);
220
+ if (flag) places.push([flag[0].length, word.length]);
221
+ // The next `:` at or after a part's start; the pointer only moves forward, so the scan stays linear.
222
+ let colon = -1;
223
+ const part = (from: number, to: number) => {
224
+ places.push([from, to]);
225
+ if (colon < from) {
226
+ const next = word.indexOf(":", from);
227
+ colon = next < 0 ? Infinity : next;
228
+ }
229
+ if (colon < to) places.push([colon + 1, to]);
230
+ };
231
+ let from = 0;
232
+ for (const separator of word.matchAll(/[/@]/g)) {
233
+ part(from, separator.index);
234
+ from = separator.index + 1;
235
+ }
236
+ if (from > 0) part(from, word.length);
237
+ return places;
238
+ }
239
+
240
+ /** Each span value in `text`, wrapping trimmed (credentialCore), at the widest place in its word that holds it. */
241
+ function valueSpans(text: string, values: SpanCredentials): Span[] {
242
+ const spans: Span[] = [];
243
+ for (const match of text.matchAll(WORD)) {
244
+ const word = match[0];
245
+ for (const [from, to] of placesIn(word)) {
246
+ const core = credentialCore(word.slice(from, to));
247
+ if (core.end > core.start && values.has(word.slice(from + core.start, from + core.end))) {
248
+ spans.push({ start: match.index + from + core.start, end: match.index + from + core.end });
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ return spans;
254
+ }
255
+
256
+ /** `text` with every span replaced, overlapping spans as one. */
257
+ function replaceSpans(text: string, spans: Span[]): Scrubbed {
258
+ if (!spans.length) return { text, redacted: 0 };
259
+ spans.sort((a, b) => a.start - b.start);
260
+ let out = "";
261
+ let cursor = 0;
262
+ let redacted = 0;
263
+ for (const span of spans) {
264
+ if (span.end <= cursor) continue;
265
+ if (span.start >= cursor) {
266
+ out += text.slice(cursor, span.start) + REDACTED_VALUE;
267
+ redacted++;
268
+ }
269
+ cursor = span.end;
270
+ }
271
+ return { text: out + text.slice(cursor), redacted };
272
+ }
273
+
274
+ /** What the text itself names as a credential: a shape, a credential key's value, a Bearer value, a URL password, a value after a credential word. */
275
+ function namedSpans(text: string): Span[] {
276
+ const tokens = [...text.matchAll(TOKEN)].map((match) => ({ token: match[0], start: match.index }));
277
+ return [
278
+ ...shapeSpans(text),
279
+ ...groupSpans(text, KEYED_VALUE, isKeyedHeld),
280
+ ...groupSpans(text, BEARER_VALUE, isValueLike),
281
+ ...urlPasswordSpans(text),
282
+ ...contextSpans(text, tokens),
283
+ ];
284
+ }
285
+
286
+ /** The values `text` names as credentials, wrapping trimmed: what scrubCredentials takes out of it on its own. */
287
+ export function namedCredentialValues(text: string): string[] {
288
+ return namedSpans(text).map((span) => {
289
+ const value = text.slice(span.start, span.end);
290
+ const core = credentialCore(value);
291
+ return value.slice(core.start, core.end);
292
+ }).filter(Boolean);
293
+ }
294
+
295
+ /**
296
+ * `text` with its credential values replaced. `span` is the span's credentials; without them (text read back from a
297
+ * span record already written) only what the text itself names is found.
298
+ */
299
+ export function scrubCredentials(text: string, span?: SpanCredentials): Scrubbed {
300
+ return replaceSpans(text, [...namedSpans(text), ...(span?.size ? valueSpans(text, span) : [])]);
301
+ }
302
+
303
+ /** What a pass wrote, scrubbed: the events whose text the summarizer chose, and the span's narrative. */
304
+ export function scrubModelWritten(events: readonly StateEvent[], narrative: string | undefined, span: SpanCredentials): { events: StateEvent[]; narrative?: string; redacted: number } {
305
+ let redacted = 0;
306
+ const clean = (text: string) => {
307
+ const scrubbed = scrubCredentials(text, span);
308
+ redacted += scrubbed.redacted;
309
+ return scrubbed.text;
310
+ };
311
+ const scrubbedEvents = events.map((event): StateEvent => {
312
+ switch (event.kind) {
313
+ case "decision.add": case "deadEnd.add": return { ...event, text: clean(event.text) };
314
+ case "now.set": return { ...event, now: { inProgress: event.now.inProgress.map(clean), next: event.now.next.map(clean), blocked: event.now.blocked.map(clean), resume: clean(event.now.resume) } };
315
+ default: return event;
316
+ }
317
+ });
318
+ return { events: scrubbedEvents, ...(narrative === undefined ? {} : { narrative: clean(narrative) }), redacted };
319
+ }
@@ -27,7 +27,7 @@ const KEY_CHARS = 64;
27
27
  const BLOCK_OPENER = /^(?:[|>](?:[-+]?\d?|\d[-+])|[{[])?\s*(?:#.*)?$/;
28
28
 
29
29
  /** A line read as `key = value`, `key: value` or `key value` (aligned table), or undefined when it is not one. */
30
- function keyedLine(line: string): { key: string; value: string } | undefined {
30
+ export function keyedLine(line: string): { key: string; value: string } | undefined {
31
31
  const text = line.trim();
32
32
  const separator = text.slice(0, KEY_CHARS + 1).search(/[=:]|\s{2,}|\t/);
33
33
  if (separator <= 0) return undefined;
@@ -74,6 +74,8 @@ const SECRET_PATTERNS: RegExp[] = [
74
74
  /\bAGE-SECRET-KEY-1[0-9A-Z]{20,}/,
75
75
  /\bAKIA[0-9A-Z]{16}\b/,
76
76
  /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,})/,
77
+ // As credential-scrub.ts CREDENTIAL_SHAPES: GitLab, Google API, Stripe, npm and Hugging Face tokens.
78
+ /\b(?:glpat-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}|[sr]k_(?:live|test)_[A-Za-z0-9]{16,}|npm_[A-Za-z0-9]{36}|hf_[A-Za-z]{30,})/,
77
79
  ];
78
80
 
79
81
  /** Whether `text` (a command, or a tool result's failure signature) carries a credential shape. */
@@ -7,6 +7,7 @@
7
7
  import type { ErrorRecord } from "./handoff.ts";
8
8
  import { containsSecret, normalizeCommand, redactCommand } from "./handoff-commands.ts";
9
9
  import type { ObservedError, ObservedRun } from "./handoff-observe.ts";
10
+ import { scrubCredentials } from "../credential-scrub.ts";
10
11
 
11
12
  const VOLATILE_RES: RegExp[] = [
12
13
  /\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g,
@@ -24,6 +25,14 @@ export function errorKey(signature: string): string {
24
25
  return line.replace(/\s+/g, " ").trim();
25
26
  }
26
27
 
28
+ /**
29
+ * The signature a failure is recorded under: none when it has a command-style credential shape (dropped, as ever),
30
+ * else with each value the credential scrub finds redacted (`password (64-hex): <redacted>`). Redacted, not dropped:
31
+ * an ordinary failure ("secret db-creds version <uuid> not found") must stay open. The scrub is deterministic, so the
32
+ * same failure keys the same way pass after pass.
33
+ */
34
+ const recordedSignature = (signature: string) => containsSecret(signature) ? undefined : scrubCredentials(signature).text;
35
+
27
36
  function idNumber(id: string): number {
28
37
  const match = /^E(\d+)$/.exec(id);
29
38
  return match ? Number(match[1]) : 0;
@@ -40,23 +49,28 @@ function succeededAfter(runs: ObservedRun[], command: string, after: number): bo
40
49
  * unresolved again and last seen now; a new one gets the next id. Then the mechanical rule: a
41
50
  * record whose source command exits 0 later in this pass is resolved. Records are copied. A
42
51
  * signature that carries a credential shape (a failing push or curl echoes its URL or header) is
43
- * never recorded, carried or new, because the exact half outlives the messages it came from; a
52
+ * never recorded, carried or new, because the exact half outlives the messages it came from; any
53
+ * other is recorded with the values the credential scrub finds redacted (recordedSignature); a
44
54
  * source command with one is stored as `<redacted>`, like its command record. Ids are never reused.
45
55
  */
46
56
  export function mergeErrors(previous: ErrorRecord[], observed: ObservedError[], runs: ObservedRun[], pass: number): ErrorRecord[] {
47
57
  let nextId = previous.reduce((max, record) => Math.max(max, idNumber(record.id)), 0) + 1;
48
- const records = previous.filter((record) => !containsSecret(record.signature)).map((record) => ({ ...record, sourceCommand: redactCommand(record.sourceCommand) }));
58
+ const records = previous.flatMap((record) => {
59
+ const signature = recordedSignature(record.signature);
60
+ return signature === undefined ? [] : [{ ...record, signature, sourceCommand: redactCommand(record.sourceCommand) }];
61
+ });
49
62
  const lastSeenAt = new Map<string, number>();
50
63
  for (const error of observed) {
51
- const key = errorKey(error.signature);
52
- if (key === "" || containsSecret(error.signature)) continue;
64
+ const signature = recordedSignature(error.signature);
65
+ const key = signature === undefined ? "" : errorKey(signature);
66
+ if (signature === undefined || key === "") continue;
53
67
  let record = records.find((candidate) => errorKey(candidate.signature) === key);
54
68
  if (record) {
55
69
  record.resolved = false;
56
70
  record.lastSeenPass = pass;
57
71
  if (record.sourceCommand === undefined && error.sourceCommand !== undefined) record.sourceCommand = redactCommand(error.sourceCommand);
58
72
  } else {
59
- record = { id: `E${nextId++}`, signature: error.signature, sourceCommand: redactCommand(error.sourceCommand), resolved: false, lastSeenPass: pass };
73
+ record = { id: `E${nextId++}`, signature, sourceCommand: redactCommand(error.sourceCommand), resolved: false, lastSeenPass: pass };
60
74
  records.push(record);
61
75
  }
62
76
  lastSeenAt.set(record.id, error.runIndex);