pi-longhaul 1.0.3 → 1.1.1

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,76 @@ entries and log event types. Rendered state text, prompt wording and section lay
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [v1.1.1] - 2026-10-23
12
+
13
+ ### Fixed
14
+
15
+ - v1.1.0 was tagged but never published: its release stopped before npm because the package's gallery image still
16
+ named the previous release. v1.1.1 is the first published release with the v1.1.0 changes below (standing rules
17
+ kept until withdrawn, the `/skill:` text no longer quotable as yours, the summarizer's tighter rule wording).
18
+
19
+ ## [v1.1.0] - 2026-10-23 [not published]
20
+
21
+ ### Changed
22
+
23
+ - Standing rules no longer retire by count. Only the newest 8 corrections used to stay, so a long session lost its
24
+ oldest rules, often its most basic ones, while requests mislabeled as rules kept their places. Now only your
25
+ withdrawal retires a rule. When the state is over its hard cap, after the work log, decisions and dead ends, the
26
+ oldest rules are shown as an 80-character excerpt that names the message holding them, instead of being dropped.
27
+ `caps.corrections` in `longhaul.json` now defaults to 0, no limit up to a safety ceiling of 64 that no real
28
+ session has come near; a number set there still retires the oldest past it. The log's `capFit` reports
29
+ `condensedCorrections`.
30
+ - The summarizer's answer is bounded at 32 rules, lifts, decisions and dead ends each, and each rule's text in its
31
+ prompt is cut to `caps.objectiveQuoteChars` with a pointer to the message, so many long rules cannot grow every
32
+ compaction's prompt.
33
+ - The summarizer is told more precisely what is not a rule: a request for the work at hand however firmly worded
34
+ ("make sure the banner is blue"), a question, a report of what you did ("I ran the script"), or an idea still
35
+ under discussion. A rule is what still binds once the work asked for now is done. `longhaul_remember` says the
36
+ same. When Longhaul takes over a session, an earlier request is not a rule either, even when it says how the
37
+ result must behave ("add an export button that saves the report as CSV"). Prompt revisions `state-19` and
38
+ `turns-11`.
39
+
40
+ ### Fixed
41
+
42
+ - A `/skill:<name>` invocation's text became standing rules: pi expands the skill into the user message, and its
43
+ rule-like lines ("Never push the tag before the commit") were recorded as the user's, six from one paste. Only the
44
+ words typed after the skill can be quoted now, whoever proposes the rule, and the summarizer sees such a message
45
+ as `[/skill:<name>] <your words>` (in turns mode too, with the skill's text behind `show`). A skill whose text
46
+ carries its own closing tag or a /goal block cannot pass that text off as yours either. A prompt template
47
+ (`/<name>`) leaves no such mark, so its text still reads as yours: docs/security.md names this limit.
48
+
49
+ ## [v1.0.4] - 2026-10-22
50
+
51
+ ### Fixed
52
+
53
+ - The summarizer copied credentials into the state it wrote: a password from a script's output into Resume, an API
54
+ key or Bearer value into Now, decisions and the narrative. The state is sent with every later request, so the
55
+ value went out again at every turn, and states on real sessions held live ones. What the summarizer writes is now
56
+ scrubbed before it is recorded: each credential value becomes `<redacted>` and the rest of the line stays. A value
57
+ is one when the span showed it as a credential (a credential key's value, symbols and all, a command's password
58
+ argument, a value a secret store printed, `the password is …`), wherever the summarizer put it and however it
59
+ wrapped it; when the text names it
60
+ (`password=…`, `"api_key": "…"`, `Bearer …`, a `user:pass@` login, or a random-looking value just after a
61
+ credential word, so `password (64-hex): <hex>` and `the gateway key (<uuid>)`); or when its shape is one wherever
62
+ it stands (a JWT, a Vault token, AWS, GitHub, OpenAI-style, Slack and age keys). The span's credentials include
63
+ what the previous summary and a switchover's earlier messages showed. A digest (`sha256:…`) is never one, and
64
+ neither is a path, a count, a version or a date the text gives a key, nor, among the span's credentials, a name
65
+ (`app-token`) under a token or secret key, an id or hash its label names (`Evaluation ID:`, `plan_hash`,
66
+ `commit`), the rest of a command after a key whose value it computes (`TOKEN=$(vault kv get … | python3 …)`), a test
67
+ name after `--- PASS:`, or a value after a credential word that is part of a file name (`~/.vault-token`): on real
68
+ 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
69
+ on (the summary, the summarizer's prompt, `longhaul_recall`, `npm run state`), by every rule but the span's own
70
+ credentials, which only the pass that wrote it had. Until then pi keeps sending the summary it stored, and a
71
+ 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
72
+ (`redacted`), never the values. Both summarizer prompts also tell the model never to copy a credential's value
73
+ and to say where it lives instead, and `longhaul_remember` refuses a decision or dead end that carries one.
74
+ - An error line naming a credential before its value (`ops-control password (64-hex): <value>`) was recorded as an
75
+ open error and shown in every state; no command-style shape matched it. Such a line is now recorded with the value
76
+ redacted, so the failure stays open, and one already carried is redacted at the next compaction.
77
+ - The credential rules found too little: a password key glued to its prefix (`PGPASSWORD=`, `MYSQL_PWD=`), short
78
+ keys (`pw=`, `pass=`), `Authorization: Basic`, a private key's body past its first line, and GitLab, Google API,
79
+ Stripe, npm and Hugging Face tokens, now in both the scrub and the command ledger's credential shapes.
80
+
11
81
  ## [v1.0.3] - 2026-10-22
12
82
 
13
83
  ### Changed
package/CONTEXT.md CHANGED
@@ -66,8 +66,10 @@ The workspace at the moment of compaction: branch, commit, whether the tree is c
66
66
  test, build or lint command, open errors, background tasks still running. Newer than anything the model wrote.
67
67
 
68
68
  **Correction**:
69
- A standing rule the user gave, kept verbatim with the message it came from. A **Lift** is the user withdrawing
70
- one; the correction stays visible as history.
69
+ A standing rule the user gave, kept verbatim with the message it came from: one that still binds once the work
70
+ asked for now is done, never a request for that work. Only a **Lift**, the user withdrawing one, retires it, never
71
+ their number (short of a safety ceiling no real session reaches); the correction stays visible as history. A **Condensed** correction is one the hard cap shows as an
72
+ excerpt naming its message. A skill's text is never a correction: it is the skill author's, not the user's.
71
73
  _Avoid_: preference, constraint (pi's own summary heading, not this concept)
72
74
 
73
75
  **Learned values**:
@@ -76,6 +78,12 @@ Values a later turn may need again, verbatim, with where each came from: those t
76
78
  that the session used again (a hash, a version, a long number).
77
79
  _Avoid_: facts (the scorer's measure of a related but different set)
78
80
 
81
+ **Credential scrub**:
82
+ Taking each credential value out of the text the summarizer writes into the state (the narrative, Now,
83
+ decisions, dead ends), leaving `<redacted>` and the words around it. The value is gone; what it was and where it
84
+ lives can stay.
85
+ _Avoid_: sanitize (one-line.ts: control characters and newlines), redact (the ledger's word for dropping a whole command)
86
+
79
87
  ## Configuration
80
88
 
81
89
  **Agent dir**:
package/README.md CHANGED
@@ -57,8 +57,9 @@ Longhaul answers each of these:
57
57
 
58
58
  - **Carried, not rewritten.** The Session state is updated with a diff, so nothing earlier is paraphrased
59
59
  again. A standing rule you gave ("never use mocks; fakes only") is kept in your own words, copied from your
60
- message by code, until you withdraw it ("the no-mocks rule no longer applies"). A one-time permission ("go
61
- ahead and use a mock here") leaves the rule standing.
60
+ message by code, until you withdraw it ("the no-mocks rule no longer applies"), however many rules you give:
61
+ by default none is dropped to make room (up to a safety ceiling of 64), and when the state is full the oldest show
62
+ their opening words and the message that holds them. A one-time permission ("go ahead and use a mock here") leaves the rule standing.
62
63
  - **Old reasoning left behind.** The kept window is handed over without the reasoning the state already
63
64
  superseded, so the next span starts from the state and recent tool input and output, as a fresh prompt would
64
65
  (*Old reasoning in the kept window*).
@@ -349,7 +350,16 @@ compaction than one that can.
349
350
  never from a line that looks like it holds a credential. A value the summarizer names is kept only when it
350
351
  stands in the turn it cites and no part of it stood on a credential line anywhere in the span; its name is
351
352
  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
353
+ - What the summarizer writes into the state (the narrative, Now, decisions and dead ends) has each credential
354
+ value replaced by `<redacted>` before it is recorded: a whole value the span showed as a credential (a credential
355
+ 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,
356
+ `Bearer` or a credential word, a `user:pass@` password, and token shapes (JWTs, Vault, AWS, GitHub, OpenAI-style,
357
+ Slack, age). States written before this are shown the same way from their next compaction on, by everything but
358
+ the span's own credentials; a stored summary and the transcript keep what they hold, so rotate a credential found
359
+ in a state.
360
+ The prompt also tells the summarizer never to copy one, and `longhaul_remember` refuses a decision or dead end
361
+ that carries one. Error lines are recorded with the value redacted.
362
+ These filters work on patterns: a bare token with no key or secret marker near it can still be learned and
353
363
  carried. Treat a session's transcript as you would its tool output.
354
364
 
355
365
  The threat model is in [docs/security.md](docs/security.md).
@@ -3,7 +3,7 @@
3
3
  "routeProviders": [],
4
4
  "localOnlyRoots": [],
5
5
  "summarizerHeaders": {},
6
- "caps": { "hardChars": 13500, "objectiveQuoteChars": 600, "corrections": 8, "correctionChars": 300, "decisions": 10, "deadEnds": 10, "itemChars": 300, "workLogLines": 12, "openErrors": 5, "worldChars": 1200, "filesChars": 2500, "learnedChars": 1500 },
6
+ "caps": { "hardChars": 13500, "objectiveQuoteChars": 600, "corrections": 0, "correctionChars": 300, "decisions": 10, "deadEnds": 10, "itemChars": 300, "workLogLines": 12, "openErrors": 5, "worldChars": 1200, "filesChars": 2500, "learnedChars": 1500 },
7
7
  "claimPatterns": [
8
8
  { "pattern": "npm test", "kind": "test" }, { "pattern": "node --test", "kind": "test" }, { "pattern": "go test", "kind": "test" },
9
9
  { "pattern": "pytest", "kind": "test" }, { "pattern": "cargo test", "kind": "test" }, { "pattern": "make test", "kind": "test" },
@@ -41,6 +41,9 @@ When pi is about to compact and Longhaul is selected (`PI_COMPACTOR` unset, empt
41
41
  - every quoted correction must be a verbatim substring of a user message (a quote cited under the wrong
42
42
  message binds to the newest message that does hold it; text no user wrote is rejected);
43
43
  - in a pi-goal `/goal` message only the objective inside the goal tags is quotable, never pi-goal's template;
44
+ - in a `/skill:<name>` message only the words typed after the skill are quotable, never the skill's text pi
45
+ expanded into the message (a pasted skill once became six rules); the message list shows it as
46
+ `[/skill:<name>] <your words>`;
44
47
  - an evidence id the ledger does not know is dropped, and the decision or dead end it backed is kept;
45
48
  - shape slips that say nothing about content (`null` for an absent optional, an omitted empty list) are
46
49
  normalized before the schema check, and every list and item is bounded by the schema.
@@ -154,7 +157,7 @@ messages in the log; `PI_COMPACT_STATE_DEBUG_STACK=1` adds stack traces.
154
157
  | `event` | when | says |
155
158
  |---|---|---|
156
159
  | `extension-loaded` | pi loads Longhaul (at startup and on a reload) | `piVersion`, `piVersionVerified` (`true` for an exact verified version, `"line"` for a verified release line, `false`) |
157
- | `compaction` | a pass finished | `span`, `tokensBefore`, `summaryChars`, `narrative` (kind, calls, accepted, dropped, usage, timings, and `reasoning`: the level asked for when the model cannot turn reasoning off), `route`, `capFit`, `world`, `carried`, `durationMs` |
160
+ | `compaction` | a pass finished | `span`, `tokensBefore`, `summaryChars`, `narrative` (kind, calls, accepted, dropped, usage, timings, and `reasoning`: the level asked for when the model cannot turn reasoning off), `route`, `capFit` (the work-log lines, decisions and dead ends retired to fit the hard cap, and `condensedCorrections`), `world`, `carried`, `durationMs` |
158
161
  | `compacted` | pi wrote the compaction entry | `fromExtension` (was the summary Longhaul's?), `reason`, `span`, `summaryChars`, `tokensBefore` |
159
162
  | `compact-failed` | pi reported the compaction failed | `reason`, `span` |
160
163
  | `kept-window-strip` | old reasoning was first left out of a new kept window | `cutoff`, `strippedBlocks`, `strippedChars`, `contextMessages` |
package/docs/security.md CHANGED
@@ -59,6 +59,17 @@ the session (a web page, a README, another agent's message) could try to plant a
59
59
  - A `/goal` objective counts only when the message has pi-goal's exact template **and** pi-goal recorded that
60
60
  goal in its own custom entry: text that reaches pi as a user message can copy the template, but only
61
61
  extension code writes a custom entry. A stopped goal is cleared.
62
+ - In a message that opens with pi's `/skill:` expansion (`<skill name=… location=…>…</skill>`), only the words
63
+ after the block are quotable: the block is the skill author's text, not the user's. pi does not escape a skill's
64
+ text, so the block ends at the LAST closing tag followed by a blank line or the end of the message (pi's own parse
65
+ takes the first): a skill that carries its own closing tag cannot pass part of its text off as the user's words.
66
+ The skill is found before a /goal block, and the goal test reads only the user's words, so a skill that shows a
67
+ goal block never names the objective. Text that copies the block's shape only hides its own words from being
68
+ quoted, which fails safe (a rule is missed, never planted). The block is found by a loop over its closing tags,
69
+ linear in the message, never by a backtracking pattern.
70
+ - Known limit: a pi prompt template (`/<name> args`) replaces the user message with the template's text and
71
+ leaves no mark in the transcript, so its rule-like lines can be quoted as the user's. Installing and invoking a
72
+ template is the user's choice, and a package that ships one can already run code as an extension.
62
73
  - The line under the state's title scopes "newer wins" to progress (Now, World, Work log). Standing rules stay
63
74
  until a user message withdraws one, and text in tool results or relayed from other agents is named as data.
64
75
  - Every rendered field is one line: no id, reference, evidence tag or narrative can carry a line break into
@@ -217,6 +228,64 @@ the session (a web page, a README, another agent's message) could try to plant a
217
228
  replacement. A credential token anywhere in those turns refuses a value of the span as well, and so does one in
218
229
  the original words of an edited message, in a message another extension injected, or on a credential line of the
219
230
  summary, which is never a source itself.
231
+ - What the summarizer writes into the state (the narrative, every Now item and Resume, decisions, dead ends) is
232
+ scrubbed before the span record is written (`credential-scrub.ts`): each credential value is replaced by
233
+ `<redacted>` and the words around it stay. A value counts when its shape is one wherever it stands (a JWT, a Vault
234
+ token, a private key's whole body, AWS, GitHub, GitLab, Google API, Stripe, npm, Hugging Face, OpenAI-style, Slack
235
+ and age keys); when the text names it, as a credential key's value (`DB_PASSWORD=`, `PGPASSWORD=`, `MYSQL_PWD=`,
236
+ `"api_key":`, escaped quotes too, symbols kept in the value up to a space or a quote; a password word may be glued
237
+ to its prefix, any other only after `_` or `-`, so `compass:` is no key; a value-like or opaque one of twelve
238
+ characters or more, never a path, and a number, count, version or date only as six digits and more under a key
239
+ that says password in full, so `token: missing`, `tests pass: 120/120` and `e2e_pass: 1234567` stay), a value-like
240
+ `Bearer` or `Basic` value, the password of a `user:pass@` URL (to the word's last `@`), or a random-looking value
241
+ (a UUID, hex from 20 characters, a long mixed-case run with digits) starting within 40 characters after a
242
+ credential word; or when it is one of the span's credentials (`span-credentials.ts`): a whole value the span
243
+ itself showed as a credential, found however the summarizer wrapped it (quoted, bold, between dashes or curly
244
+ quotes, after `=`, glued to a flag, in a login with or without a scheme: `app/x@db`, `app:x@db:5432`). The span's
245
+ credentials come from everything the summarizer was shown: the span, the previous summary, a switchover's earlier
246
+ user messages and, in warm mode, the session's last request. They are built from strong evidence only, because each
247
+ one is taken out everywhere: what the text rules above name; the value of a key named for one credential,
248
+ ending in its name or a short part after it (singular, so `tokens: 1200` and a list of `secrets` are not one, and
249
+ no key about one: `password_policy`, `token_env`), any shape under a password key
250
+ (`Summer2024.x`, `unmade-tremor-oxidize`, `48291375`), not a name under a token, secret or key key (`app-token`,
251
+ `APP_TOKEN`, a file or host name); a command's password argument (`mysql -pX`, `sshpass -p X`, `redis-cli -a
252
+ X`, `curl -u user:X`, `htpasswd -b FILE USER X`, `--db-password X`, `openssl -pass pass:X`, `keytool -storepass
253
+ X`); a random-looking word on another credential line; a value a secret store printed alone (or a random-looking
254
+ one among its other output); and `the password is …`, `the db pass is …`. Never a number (but a password's),
255
+ version, date or path, nor one plain lowercase word. A key holds its quoted string or its first word (after Go's
256
+ `:=` or a type, `db_password: str = "…"`; a YAML password's plain value runs to the line's end), never the rest of
257
+ a command line, and nothing when its value is computed (`TOKEN=$(vault kv get … | python3 -c "import sys,json")`
258
+ once made `python3` a credential); a random-looking word the word before it on its line labels as an id or a hash
259
+ (`Evaluation ID:`, `plan_hash`, `commit`) is that, not a secret, unless the label names a credential
260
+ (`secret_id`); and an upper-case `--- PASS: TestName` is a test runner's status, while `pass: 'x'` and `pass=x`
261
+ are keys. A credential word inside a path or file name (`/tmp/admin-bearer.txt`,
262
+ `~/.vault-token`) says where one lives, so the value after it is not taken for one. The broader set that refuses a named value
263
+ (narrative/named-values.ts, with the words after "pin", "card" or "login" and a secret store's whole output)
264
+ redacted versions, commit hashes and names in about a third of real fields, so the scrub does not use it. A
265
+ `sha256:` digest is never one, and a variable or placeholder (`$KEY`, `<from vault>`) stays. The projection applies
266
+ the text rules only, since only the pass that wrote a record had its span, so a record written before the scrub
267
+ shows redacted wherever the state is read from its session's next compaction on: the summary, the summarizer's
268
+ prompt (the open span's remembered items too), `longhaul_recall` (every hit, error lines and the span's narrative
269
+ included) and the state CLI. Nothing rewrites what is stored: pi re-sends the latest compaction's summary string
270
+ until the next compaction (on an older branch, whenever someone returns to it), and the transcript keeps the old
271
+ summaries, `details.state` checkpoints, span records, remember entries and the turns trace's model-written
272
+ reasons. A credential found in a state was sent with every request since; rotate it. `longhaul_remember` refuses
273
+ a decision or dead end that the scrub would change, with the credentials of every message still in context (the
274
+ kept window included). Measured on 24 real transcripts: the text rules redact 22 values over 6612 model-written
275
+ fields, at least one of them not a secret (an Ed25519 public key after "key"); the span's credentials, each span
276
+ against its own messages and the summary before it, redact 7 more over 2981 fields in 224 spans, every one a
277
+ credential or the visible part of one (a Wi-Fi passphrase, two admin passwords, a token's printed prefix, a rotated
278
+ secret's prefix). The summarizer prompts say never to
279
+ copy a credential's value; the scrub is what holds when a model does. An error signature with a command-style
280
+ shape is never recorded; any other is recorded as the scrub leaves it (`password (64-hex): <redacted>`), carried
281
+ ones too, so an ordinary failure that mentions a secret by id stays open. Every pattern is linear on hostile text
282
+ (T4); a private key without an END line is cut 16 KiB on. Not found: a credential the span never showed as one,
283
+ written in prose with no credential word near it; a token or secret key's value shaped as a name; without the
284
+ span, a value on the line after its key (`password:` then the value), a key in smart quotes or before a full-width
285
+ colon; a password given to `docker login -p` or piped to a `--password-stdin` command; a passphrase with spaces
286
+ (`correct horse battery staple`); a key about a password rather than holding one (`password_policy:`) gives no
287
+ value, by design; and a symbol at a value's edge (`Summer2024!` leaves `<redacted>!`). The user's own words (objective,
288
+ done-when and correction quotes) are verbatim by design and not scrubbed.
220
289
  - The ledger redacts commands that carry a secret, by the same patterns (a one-time-password seed passed to
221
290
  `oathtool -b` (or `-bv`, `--base32`, or a hex or base32 key of 16 or more characters), `ykman oath accounts add`,
222
291
  `ykman ... --key`, `sshpass -p`, `htpasswd -b`, `openssl -pass pass:`, `keytool -storepass`, `redis-cli -a`,
@@ -256,7 +325,14 @@ A transcript or a model answer can be arbitrarily long, and a compaction runs in
256
325
  - Reading the state back never spreads a transcript's entries into a function call (`Math.max(...)`,
257
326
  `push(...)`), whose arguments live on the stack: a branch with hundreds of thousands of events folds.
258
327
  - The rendered state has a hard cap (`caps.hardChars`) and every section its own; the background-task line is
259
- bounded on its own because it renders after World's fitted lines.
328
+ bounded on its own because it renders after World's fitted lines. Corrections are the exception by design: none
329
+ is retired to fit (an old rule binds as much as a new one), so once the work log, decisions and dead ends are
330
+ gone they are condensed, oldest first, to an 80-character excerpt naming the message. Only a state with more rules
331
+ than condensed lines fit stays over the cap (`capExhausted` in the log). Rules are bounded elsewhere: one message
332
+ can yield several (distinct passages of one long paste each count), so with no count cap set a state keeps at most
333
+ 64 (`CORRECTIONS_CEILING`), the oldest past it retiring and still recallable from the transcript, which no real
334
+ session has come near (the most seen was 22). One answer carries at most 32 rules, lifts, decisions or dead ends,
335
+ and each rule's text in the summarizer prompt is cut to `caps.objectiveQuoteChars`, naming its recall.
260
336
  - `longhaul_recall` answers are capped at 4,000 characters and output pages at 8,000.
261
337
  - The narrative call is bounded in count (`maxChunks` × (3 + `fixableRetries`): one more call per chunk, only when the
262
338
  last answer is first refused because a lift aimed at a rule that was replaced, and one replacement question per
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-longhaul",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
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.3/.github/assets/infographic.png"
35
+ "image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.1.1/.github/assets/infographic.png"
36
36
  },
37
37
  "files": [
38
38
  "src/**/*.ts",
package/src/config.ts CHANGED
@@ -69,7 +69,7 @@ export function readConfigFile(agentDir: string = getAgentDir()): string | undef
69
69
  }
70
70
  }
71
71
 
72
- const CAP_FLOORS: Record<keyof Caps, number> = { hardChars: 2000, objectiveQuoteChars: 40, corrections: 1, correctionChars: 40, decisions: 1, deadEnds: 1, itemChars: 40, workLogLines: 1, openErrors: 0, worldChars: 200, filesChars: 0, learnedChars: 0 };
72
+ const CAP_FLOORS: Record<keyof Caps, number> = { hardChars: 2000, objectiveQuoteChars: 40, corrections: 0, correctionChars: 40, decisions: 1, deadEnds: 1, itemChars: 40, workLogLines: 1, openErrors: 0, worldChars: 200, filesChars: 0, learnedChars: 0 };
73
73
  const CLAIM_KINDS: ReadonlySet<string> = new Set<ClaimKind>(["test", "build", "lint", "custom"]);
74
74
 
75
75
  function parseObject(raw: string | undefined): Record<string, unknown> {
@@ -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
+ }