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.
@@ -45,7 +45,7 @@ function textsIn(message: Rec): string[] {
45
45
  }
46
46
 
47
47
  /** Everything a message carries as text, call arguments string by string (so a written file is read line by line). */
48
- function everyTextIn(message: Rec): string[] {
48
+ export function everyTextIn(message: Rec): string[] {
49
49
  const out: string[] = [];
50
50
  if (typeof message.content === "string") out.push(message.content);
51
51
  if (Array.isArray(message.content)) {
@@ -89,6 +89,18 @@ export interface SpanTexts {
89
89
  holderOf?: (value: string) => string | undefined;
90
90
  }
91
91
 
92
+ /**
93
+ * Every token that stood on a credential line, after a credential named in prose, in a `user:pass@` login or in a
94
+ * secret store's output anywhere in `messages`: what a value is refused for. Broad on purpose, since refusing a value
95
+ * costs little; the credential scrub uses the stricter span-credentials.ts, as it takes each one out everywhere.
96
+ */
97
+ function credentialTokensIn(messages: readonly AgentMessage[]): Set<string> {
98
+ const tainted = new Set<string>();
99
+ for (const message of messages as unknown as Rec[]) for (const text of everyTextIn(message)) for (const token of credentialTokensOf(text)) tainted.add(token);
100
+ for (const output of secretStoreOutputs(messages as unknown as Rec[])) for (const token of tokensOf(output)) tainted.add(token);
101
+ return tainted;
102
+ }
103
+
92
104
  /**
93
105
  * Each turn's text, by the ordinal tag prepare.ts puts on its user message (thinking was stripped before), and the
94
106
  * span's credential tokens. Those are taken from every message, sources or not: a password a web page showed is no
@@ -110,14 +122,12 @@ export function spanTextsOf(messages: AgentMessage[]): SpanTexts {
110
122
  */
111
123
  export function turnTextsOf(messages: ReadonlyArray<{ ordinal?: string; message: AgentMessage; taintOnly?: boolean }>): SpanTexts {
112
124
  const turns = new Map<string, string[]>();
113
- const tainted = new Set<string>();
114
125
  let current: string[] | undefined;
115
126
  for (const { ordinal, message, taintOnly } of messages as unknown as ReadonlyArray<{ ordinal?: string; message: Rec; taintOnly?: boolean }>) {
116
- for (const text of everyTextIn(message)) for (const token of credentialTokensOf(text)) tainted.add(token);
117
127
  if (ordinal) turns.set(ordinal, (current = []));
118
128
  if (!taintOnly) current?.push(...textsIn(message));
119
129
  }
120
- for (const output of secretStoreOutputs(messages.map((entry) => entry.message) as unknown as Rec[])) for (const token of tokensOf(output)) tainted.add(token);
130
+ const tainted = credentialTokensIn(messages.map((entry) => entry.message));
121
131
  const joined = new Map([...turns].map(([ordinal, texts]) => [ordinal, texts.join("\n")]));
122
132
  // Each turn's clean text once, newest first: a value that misses is looked up again on every attempt.
123
133
  let clean: Array<[string, string]> | undefined;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
8
8
 
9
+ import { CREDENTIAL_RULE, scrubCredentials } from "../credential-scrub.ts";
9
10
  import { normalizeWs } from "../intent.ts";
10
11
  import { quoteText } from "../render.ts";
11
12
  import { DEFAULT_CAPS, type Now, type Quote, type StateEvent } from "../store/events.ts";
@@ -35,7 +36,7 @@ import { LABEL_FORMS, LIFT_GUIDANCE, RULE_DEFINITION, RULE_WHOLE_CHARS } from ".
35
36
  * state-16: outside a /goal, the session's opening request is the goal line once the Request has moved from it
36
37
  * (store/project.ts goalLine), so the project's purpose survives every compaction; never quoted as the objective.
37
38
  */
38
- export const NARRATIVE_PROMPT_REVISION = "state-16";
39
+ export const NARRATIVE_PROMPT_REVISION = "state-17";
39
40
 
40
41
  export const NARRATIVE_SYSTEM_PROMPT = `You maintain the SESSION STATE of a coding session that is being compacted. You are given the state so far, the user's messages with their ordinals (u1, u2, ...), and the conversation being compacted. Answer with ONE fenced json block and nothing else:
41
42
 
@@ -52,7 +53,7 @@ export const NARRATIVE_SYSTEM_PROMPT = `You maintain the SESSION STATE of a codi
52
53
  }
53
54
  \`\`\`
54
55
 
55
- Rules. "objective" is the request the work is answering now. It is optional: include it only when none is set or the user moved to a different request in this span, and quote the user's own message verbatim. The goal line, when present, is the active /goal or the session's opening request: code keeps it, so never quote it as the objective; under a goal the objective is the specific ask. "labels": Label EVERY message in <user-messages> with one of ${LABEL_FORMS}. ${RULE_DEFINITION} ${LIFT_GUIDANCE} The target is R<n> from the state, or u<n> for a rule given earlier in this span. "not a rule" is a request, question, answer, approval or one-off. Code copies the user's words into the state, so a short message labeled a rule needs no quote; a message longer than ${RULE_WHOLE_CHARS} characters labeled a rule must come with the rule's exact words: {"u7": {"rule": "<exact words>"}}. "corrections" is only for a standing rule that an earlier summary records and no listed message gives; quote the user's exact words. "values": values a later turn may need again that no file will give back: a measurement, a total, a port or a count a turn reported, a hash it named, a file-name format the user chose ("<name>-YYYYMMDD.tar.gz"). Copy each exactly as it appears in the turn "ref" names (the user's message, your replies and the tool output up to the next user message); a value is a number with or without its unit ("166 ms"), a version, a short commit hash, a path or file name (with <placeholders> if the format has them), a host:port or a date; nothing else is kept. "name" is a short label (at most 40 characters, no colon) saying what it is. At most 12, never a password, key or token. "decisions" and "deadEnds" are your own one-sentence records with evidence: a command id C<n>, an error id E<n>, a message u<n>, or the words "last command" / "last error". "now" replaces the previous Now entirely. Never write "done" unless the state's Done gate line says met. Empty arrays are fine. No prose outside the fence.`;
56
+ Rules. "objective" is the request the work is answering now. It is optional: include it only when none is set or the user moved to a different request in this span, and quote the user's own message verbatim. The goal line, when present, is the active /goal or the session's opening request: code keeps it, so never quote it as the objective; under a goal the objective is the specific ask. "labels": Label EVERY message in <user-messages> with one of ${LABEL_FORMS}. ${RULE_DEFINITION} ${LIFT_GUIDANCE} The target is R<n> from the state, or u<n> for a rule given earlier in this span. "not a rule" is a request, question, answer, approval or one-off. Code copies the user's words into the state, so a short message labeled a rule needs no quote; a message longer than ${RULE_WHOLE_CHARS} characters labeled a rule must come with the rule's exact words: {"u7": {"rule": "<exact words>"}}. "corrections" is only for a standing rule that an earlier summary records and no listed message gives; quote the user's exact words. "values": values a later turn may need again that no file will give back: a measurement, a total, a port or a count a turn reported, a hash it named, a file-name format the user chose ("<name>-YYYYMMDD.tar.gz"). Copy each exactly as it appears in the turn "ref" names (the user's message, your replies and the tool output up to the next user message); a value is a number with or without its unit ("166 ms"), a version, a short commit hash, a path or file name (with <placeholders> if the format has them), a host:port or a date; nothing else is kept. "name" is a short label (at most 40 characters, no colon) saying what it is. At most 12, never a password, key or token. "decisions" and "deadEnds" are your own one-sentence records with evidence: a command id C<n>, an error id E<n>, a message u<n>, or the words "last command" / "last error". "now" replaces the previous Now entirely. Never write "done" unless the state's Done gate line says met. ${CREDENTIAL_RULE} Empty arrays are fine. No prose outside the fence.`;
56
57
 
57
58
  export interface PromptState {
58
59
  /** The Goal line (store/project.ts goalLine), shown so the summarizer never re-picks it as the Request. */
@@ -110,8 +111,9 @@ const rememberedText = (event: StateEvent): { kind: string; text: string } | und
110
111
  switch (event.kind) {
111
112
  case "correction.add": return { kind: "correction", text: `${event.id} [${event.quote.ordinal}] ${event.quote.text}` };
112
113
  case "correction.lift": return { kind: "lift", text: `${event.target} lifted by ${event.by.ordinal}` };
113
- case "decision.add": return { kind: "decision", text: `${event.id} ${event.text}` };
114
- case "deadEnd.add": return { kind: "dead-end", text: `${event.id} ${event.text}` };
114
+ // Written by a tool call this span: the projection has not seen it, so it is scrubbed here (credential-scrub.ts).
115
+ case "decision.add": return { kind: "decision", text: `${event.id} ${scrubCredentials(event.text).text}` };
116
+ case "deadEnd.add": return { kind: "dead-end", text: `${event.id} ${scrubCredentials(event.text).text}` };
115
117
  case "objective.set": return { kind: "objective", text: `${event.quote.ordinal} ${event.quote.text}` };
116
118
  case "objective.doneWhen": return { kind: "done-when", text: `${event.quote.ordinal} ${event.quote.text}` };
117
119
  default: return undefined;
@@ -9,6 +9,7 @@
9
9
  import { Type } from "typebox";
10
10
  import type { AssistantMessage, Context, Message, Model, ModelThinkingLevel, Tool, ToolCall } from "@earendil-works/pi-ai";
11
11
 
12
+ import { CREDENTIAL_RULE } from "../../credential-scrub.ts";
12
13
  import type { ModelCall } from "../call.ts";
13
14
  import { timedTransport } from "../timings.ts";
14
15
  import { completeWithTransportRetry, type TransportRetryPolicy } from "../transport-retry.ts";
@@ -16,9 +17,9 @@ import type { CallTrace } from "../types.ts";
16
17
  import { callUsageOf } from "../usage.ts";
17
18
 
18
19
  /** Recorded as the span's promptRevision in turns mode, as NARRATIVE_PROMPT_REVISION is for the single call. */
19
- export const TURNS_PROMPT_REVISION = "turns-8";
20
+ export const TURNS_PROMPT_REVISION = "turns-9";
20
21
 
21
- export const TURNS_SYSTEM_PROMPT = `You maintain the SESSION STATE of a coding session that is being compacted. The first message holds the digest of the span: the state so far, the user's messages, the commands and errors (with ids), the files, the workspace and a condensed transcript. Code asks for one section at a time. Answer each with ONE JSON object and nothing else. Use only ids that appear in the digest. Call show(ref) to read a user message (u<n>), command (C<n>), error (E<n>) or tool output (T<n>) in full when the digest cut it.`;
22
+ export const TURNS_SYSTEM_PROMPT = `You maintain the SESSION STATE of a coding session that is being compacted. The first message holds the digest of the span: the state so far, the user's messages, the commands and errors (with ids), the files, the workspace and a condensed transcript. Code asks for one section at a time. Answer each with ONE JSON object and nothing else. Use only ids that appear in the digest. Call show(ref) to read a user message (u<n>), command (C<n>), error (E<n>) or tool output (T<n>) in full when the digest cut it. ${CREDENTIAL_RULE}`;
22
23
 
23
24
  export const SHOW_TOOL: Tool = {
24
25
  name: "show",
package/src/pass.ts CHANGED
@@ -21,12 +21,14 @@ import { shouldAttemptRoute, type RouteHealthState } from "./narrative/route-hea
21
21
  import { resolveSummarizationModel, type ModelLookup, type ResolvedSummarizationModel } from "./narrative/routing.ts";
22
22
  import { routeProvidersFor, type ResolvedSummarizationRoute } from "./narrative/route-config.ts";
23
23
  import { turnTextsOf, type SpanTexts } from "./narrative/named-values.ts";
24
+ import { spanCredentials } from "./span-credentials.ts";
24
25
  import { runNarrativeCall, type NarrativeInput } from "./narrative/run.ts";
25
26
  import { TURNS_PROMPT_REVISION } from "./narrative/turns/conversation.ts";
26
27
  import { runSectionTurns, type TurnsContext } from "./narrative/turns/run.ts";
27
28
  import type { NarrativeOutcome } from "./narrative/types.ts";
28
29
  import type { WarmBase } from "./narrative/warm.ts";
29
30
  import { render } from "./render.ts";
31
+ import { scrubModelWritten } from "./credential-scrub.ts";
30
32
  import type { RunningTask } from "./tasks.ts";
31
33
  import type { CapFitResult } from "./store/capfit.ts";
32
34
  import { closeSpan, type ClosedSpan } from "./store/close.ts";
@@ -123,6 +125,8 @@ interface Closed {
123
125
  ledger: { commands: CommandRecord[]; errors: ErrorRecord[] };
124
126
  files: ReturnType<typeof carryFileOps>;
125
127
  learned: LearnedValue[];
128
+ /** Credential values taken out of what the summarizer wrote (credential-scrub.ts); a count, never the values. */
129
+ redacted: number;
126
130
  }
127
131
 
128
132
  function resolveRoute(input: PassInput, config: StateConfig, deps: PassDeps): { route: ResolvedSummarizationRoute; resolved?: ResolvedSummarizationModel; routeAvailable: boolean } {
@@ -326,30 +330,44 @@ async function callNarrative(input: PassInput, fold: Fold, config: StateConfig,
326
330
  return runSectionTurns({ ...narrativeInputOf(input, fold, config, deps, at, state), turns: config.narrativeTurns, context }, narrativeDeps);
327
331
  }
328
332
 
333
+ /**
334
+ * Every message the summarizer was shown, as far as credentials go: the span, the previous summary, a switchover's
335
+ * earlier user messages, and in warm mode the session's own last request.
336
+ */
337
+ function summarizerSaw(input: PassInput, fold: Fold, config: StateConfig, spanMessages: AgentMessage[]): AgentMessage[] {
338
+ const texts = [input.preparation.previousSummary, ...(fold.seed.seedRules ?? []).map((rule) => rule.text), ...(fold.seed.seedContext ?? []).map((message) => message.text)];
339
+ const shown = texts.filter((text): text is string => Boolean(text)).map((text) => ({ role: "custom", content: text, timestamp: 0 }) as unknown as AgentMessage);
340
+ const warm = config.narrativeMode === "warm" ? (input.warmBase?.messages ?? []) as unknown as AgentMessage[] : [];
341
+ return [...spanMessages, ...shown, ...warm];
342
+ }
343
+
329
344
  async function closeAndRender(input: PassInput, fold: Fold, outcome: NarrativeOutcome, config: StateConfig, at: string): Promise<Closed> {
330
345
  const { preparation, branchEntries } = input;
331
346
  const { capture, snapshot, ledger, objectiveEvents, world, files } = await fold.settled();
332
- const modelEvents = [...objectiveEvents.map((event) => ({ ...event, seq: 0 })), ...outcome.events];
347
+ const spanMessages = [...preparation.messagesToSummarize, ...(preparation.turnPrefixMessages ?? [])];
348
+ // Before anything is recorded: a credential the summarizer was shown must not reach the span record, nor every later request.
349
+ const scrubbed = scrubModelWritten(outcome.events, outcome.narrative, spanCredentials(summarizerSaw(input, fold, config, spanMessages)));
350
+ const modelEvents = [...objectiveEvents.map((event) => ({ ...event, seq: 0 })), ...scrubbed.events];
333
351
  const diffEvents = [...modelEvents, ...seedRuleEvents(fold, outcome, modelEvents, config, at)];
334
352
  // Ids from the whole merged ledger, not the fitted slice: a value's source must not vanish with a low-ranked record.
335
353
  // The values the summarizer named come first: each was checked against its turn, and its name says what it is.
336
- const picked = learnedValues([...preparation.messagesToSummarize, ...(preparation.turnPrefixMessages ?? [])], fold.handoff.commands);
354
+ const picked = learnedValues(spanMessages, fold.handoff.commands);
337
355
  const learned = carryLearnedValues(previousLearnedValues(branchEntries), [...(outcome.values ?? []), ...picked]);
338
356
  const tasks = (await fold.tasks) ?? [];
339
357
  const measure = (candidate: SessionState) => render({ state: candidate, tokensBefore: preparation.tokensBefore, ledger, snapshot, caps: config.caps, files, learned, tasks, compactedAt: at }).length;
340
- const closed = closeSpan({ log: fold.log, caps: config.caps, diffEvents: renumber(diffEvents, fold.log.nextSeq), close: { fromRef: fold.spanInput.fromRef, toRef: fold.spanInput.toRef, narrative: outcome.narrative, narrativeLess: outcome.kind === "narrative-less", world, ...(outcome.missingChunks.length ? { missingChunks: outcome.missingChunks } : {}) }, at, measure });
341
- return { closed, summary: render({ state: closed.state, tokensBefore: preparation.tokensBefore, ledger, snapshot, caps: config.caps, files, learned, tasks, compactedAt: at }), capture, snapshot, ledger, files, learned };
358
+ const closed = closeSpan({ log: fold.log, caps: config.caps, diffEvents: renumber(diffEvents, fold.log.nextSeq), close: { fromRef: fold.spanInput.fromRef, toRef: fold.spanInput.toRef, narrative: scrubbed.narrative, narrativeLess: outcome.kind === "narrative-less", world, ...(outcome.missingChunks.length ? { missingChunks: outcome.missingChunks } : {}) }, at, measure });
359
+ return { closed, summary: render({ state: closed.state, tokensBefore: preparation.tokensBefore, ledger, snapshot, caps: config.caps, files, learned, tasks, compactedAt: at }), capture, snapshot, ledger, files, learned, redacted: scrubbed.redacted };
342
360
  }
343
361
 
344
362
  function assembleResult(input: PassInput, config: StateConfig, fold: Fold, outcome: NarrativeOutcome, built: Closed, deps: PassDeps, startedAt: Date, at: string): PassResult {
345
363
  const { preparation } = input;
346
- const { closed, summary, capture, snapshot, ledger, files, learned } = built;
364
+ const { closed, summary, capture, snapshot, ledger, files, learned, redacted } = built;
347
365
  const usage = { input: outcome.usage.input, output: outcome.usage.output, calls: outcome.usage.calls };
348
366
  const details: StateDetails = { compactor: "state", stateFormat: STATE_FORMAT, promptRevision: outcome.sections ? TURNS_PROMPT_REVISION : NARRATIVE_PROMPT_REVISION, state: closed.state, caps: config.caps, ledger, snapshot, narrative: narrativeTraceOf(outcome), readFiles: files.readFiles, modifiedFiles: files.modifiedFiles, learnedValues: learned };
349
367
  const pending: PendingSpan = { span: fold.span, events: closed.log.events.filter((event) => event.span === fold.span), ledger, snapshot, usage, caps: config.coldSliceCaps };
350
368
  const route = { spec: fold.route.spec, rule: fold.route.rule, model: fold.resolved ? `${fold.resolved.model.provider}/${fold.resolved.model.id}` : undefined, fallbackReason: fold.resolved?.fallbackReason, routeIgnored: fold.resolved?.routeIgnored, routeAvailable: fold.routeAvailable };
351
369
  const logOptions = logOptionsOf(input.env);
352
- const log = { event: "compaction", at, reason: input.reason, span: fold.span, tokensBefore: preparation.tokensBefore, summaryChars: summary.length, narrative: logSafeNarrative(details.narrative, logOptions), rejected: logSafeRejected(outcome.rejected, logOptions), route, routeHealth: outcome.routeHealth, capFit: closed.capFit.retired, capExhausted: closed.capFit.exhausted, anomalies: fold.log.anomalies, world: { claims: closed.state.world?.claims.length ?? 0, openErrors: closed.state.world?.openErrorIds.length ?? 0, dirty: closed.state.world?.dirty }, carried: { readFiles: files.readFiles.length, modifiedFiles: files.modifiedFiles.length, removed: logSafeRemoved(files.removed, logOptions), learnedValues: learned.length }, workspace: "skipped" in capture ? { skipped: logSafeSkipped(capture.skipped, logOptions) } : { captured: true }, durationMs: deps.now().getTime() - startedAt.getTime() };
370
+ const log = { event: "compaction", at, reason: input.reason, span: fold.span, tokensBefore: preparation.tokensBefore, summaryChars: summary.length, narrative: logSafeNarrative(details.narrative, logOptions), rejected: logSafeRejected(outcome.rejected, logOptions), route, routeHealth: outcome.routeHealth, capFit: closed.capFit.retired, capExhausted: closed.capFit.exhausted, anomalies: fold.log.anomalies, world: { claims: closed.state.world?.claims.length ?? 0, openErrors: closed.state.world?.openErrorIds.length ?? 0, dirty: closed.state.world?.dirty }, carried: { readFiles: files.readFiles.length, modifiedFiles: files.modifiedFiles.length, removed: logSafeRemoved(files.removed, logOptions), learnedValues: learned.length }, redacted, workspace: "skipped" in capture ? { skipped: logSafeSkipped(capture.skipped, logOptions) } : { captured: true }, durationMs: deps.now().getTime() - startedAt.getTime() };
353
371
  return {
354
372
  compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, usage: compactionUsageOf(outcome.usage), details },
355
373
  pending, outcome, routeHealth: outcome.routeHealth, route, capFit: closed.capFit, anomalies: fold.log.anomalies, notice: noticeOf(fold.span, outcome, summary.length), log,
@@ -2,17 +2,21 @@
2
2
  * The `remember` tool: one verified write into the Session state, sequential so two calls in one
3
3
  * turn never race the fold. Quotes only, resolved newest-first; the store's commitRemember is the
4
4
  * whole policy. Argument repair: a quote passed as `text` (or the reverse) is swapped with a Note,
5
- * because a model that got the field wrong got the intent right.
5
+ * because a model that got the field wrong got the intent right. A decision or dead end is the
6
+ * model's own words, so one that carries a credential value (credential-scrub.ts, with the
7
+ * credentials of the messages still in context, the kept window included) is refused, never stored (security.md T3).
6
8
  */
7
9
  import { REMEMBER_TOOL } from "./tool-names.ts";
8
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
11
  import { Type } from "typebox";
10
12
 
11
13
  import { readConfigFile, resolveStateConfig, type ReadFileFn } from "./config.ts";
14
+ import { CREDENTIAL_RULE, scrubCredentials } from "./credential-scrub.ts";
15
+ import { spanCredentials } from "./span-credentials.ts";
12
16
  import { commitRemember } from "./store/commit.ts";
13
17
  import { EVENT_ENTRY_TYPE, type PersistedEvent } from "./store/events.ts";
14
18
  import type { Proposal } from "./store/verify.ts";
15
- import { buildToolContext } from "./tool-context.ts";
19
+ import { buildToolContext, messagesInContext } from "./tool-context.ts";
16
20
  import { formatRemember } from "./tool-text.ts";
17
21
 
18
22
  export const REMEMBER_KINDS = ["correction", "lift", "decision", "dead-end", "done-when"] as const;
@@ -42,6 +46,13 @@ export function proposalOf(params: RememberParams): { proposal: Proposal; note?:
42
46
  return { proposal: { kind: "dead-end", text: words, evidence: params.evidence }, note };
43
47
  }
44
48
 
49
+ /** Why a decision or dead end is refused, or undefined: the model's own words may not hold a credential the span showed or one shaped as one. */
50
+ function credentialRefusal(proposal: Proposal, branch: Parameters<typeof messagesInContext>[0]): string | undefined {
51
+ if (proposal.kind !== "decision" && proposal.kind !== "dead-end") return undefined;
52
+ if (scrubCredentials(proposal.text, spanCredentials(messagesInContext(branch))).redacted === 0) return undefined;
53
+ return `the text carries a credential. ${CREDENTIAL_RULE}`;
54
+ }
55
+
45
56
  const PARAMETERS = Type.Object({
46
57
  kind: Type.Union(REMEMBER_KINDS.map((kind) => Type.Literal(kind)), { description: "correction: a standing rule the user gave (quote). lift: the user withdrew a correction (target + quote). decision / dead-end: your own one sentence (text, evidence). done-when: the user's acceptance words (quote)." }),
47
58
  quote: Type.Optional(Type.String({ description: "The user's exact words, a substring of one user message (12+ chars). Example: \"never use mocks in these tests\"." })),
@@ -64,8 +75,11 @@ export function registerRememberTool(pi: ExtensionAPI, deps: { readConfig: ReadF
64
75
  async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
65
76
  const mapped = proposalOf(params as RememberParams);
66
77
  if ("error" in mapped) return { content: [{ type: "text", text: `not remembered: ${mapped.error}` }], details: { ok: false, remaining: undefined, rejection: { code: "schema", detail: mapped.error } } };
78
+ const branch = ctx.sessionManager.getBranch();
79
+ const refusal = credentialRefusal(mapped.proposal, branch);
80
+ if (refusal) return { content: [{ type: "text", text: `not remembered: ${refusal}` }], details: { ok: false, remaining: undefined, rejection: { code: "credential", detail: refusal } } };
67
81
  const config = resolveStateConfig(deps.readConfig);
68
- const context = buildToolContext(ctx.sessionManager.getBranch());
82
+ const context = buildToolContext(branch);
69
83
  const outcome = commitRemember(mapped.proposal, {
70
84
  log: context.log, resolver: context.index, caps: config.caps, nearDuplicateThreshold: config.nearDuplicateThreshold, rememberCap: config.rememberPerSpan,
71
85
  now: () => deps.now().toISOString(), appendEntry: (customType: string, data: PersistedEvent) => pi.appendEntry(customType, data),
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The span's credentials (credential-scrub.ts SpanCredentials): the whole values the span itself showed as
3
+ * credentials, which the scrub then takes out of what the summarizer wrote wherever it put them (security.md T3).
4
+ * Strong evidence only, because each value is redacted everywhere it stands: the named-value taint
5
+ * (narrative/named-values.ts), right for refusing a value, counts the words after "pin", "card" or "login" and a
6
+ * secret store's whole output, and on real states it redacted versions, commit hashes and paths in about a third of
7
+ * the fields. A value counts when
8
+ * - the span's text names it as the scrub itself would (credential-scrub.ts namedCredentialValues), unless it is
9
+ * shaped as a name (below);
10
+ * - it is the value of a key named for one credential, ending in its name or a short part after it
11
+ * (`DB_PASSWORD`, `cluster_token`, `WIFI_PASSPHRASE_2400_BAUD`; not `tokens: 1200`, a test runner's `pass`, nor a key
12
+ * about one, `password_policy`, `token_env`), unless a token, secret or key key holds a value shaped as a name (`app-token`,
13
+ * `APP_TOKEN`, a path, a file name; a password key's value is one whatever its shape, `unmade-tremor-oxidize`);
14
+ * - a command gives it as a password argument (`mysql -pX`, `sshpass -p X`, `redis-cli -a X`, `curl -u user:X`,
15
+ * `htpasswd -b FILE USER X`, `--db-password X`, `openssl -pass pass:X`, `keytool -storepass X`);
16
+ * - it is a random-looking word on another credential line (a YAML block under such a key);
17
+ * - a secret store printed it alone (`vault kv get -field=...`), or random-looking among other output;
18
+ * - but never a random-looking word its label names as an id or a hash (`Evaluation ID: <uuid>`, `plan_hash <hex>`,
19
+ * `commit <sha>`), nor the words after a key whose value is computed (`TOKEN=$(vault kv get ... | python3 ...)`), nor
20
+ * a test runner's `--- PASS: TestName`;
21
+ * - or prose gives it: `the password is ...`, `the db pass is ...`.
22
+ * Never a number (except six digits and more given as a password), version, date or path, nor one plain lowercase
23
+ * word ("token: missing"). Measured on real states, these took no version, hash or path out of a field.
24
+ */
25
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
26
+
27
+ import { credentialCore, isNumberish, isNumericPasswordKey, isRandomLooking, isValueLike, namedCredentialValues, WORD } from "./credential-scrub.ts";
28
+ import { CREDENTIAL_NAME, keyedLine, secretStoreOutputs, splitCredentialLines } from "./learned-secrets.ts";
29
+ import type { Rec } from "./message-text.ts";
30
+ import { everyTextIn } from "./narrative/named-values.ts";
31
+
32
+ /** `the password is X`: the one value prose gives a password outright. */
33
+ const PROSE_PASSWORD = /(?<![A-Za-z])(?:passw(?:or)?d|passwd|pwd|pass|passphrase|passcode)\s+(?:is|was)\s+(\S+)/gi;
34
+ /**
35
+ * A command's password argument, group 1 (as ledger/handoff-commands.ts SECRET_PATTERNS, which marks the line).
36
+ * Every quantifier is bounded, each lazy run stops at a newline.
37
+ */
38
+ const COMMAND_PASSWORDS = [
39
+ /\b(?:mysql|mysqldump|mariadb)\b[^\n]{0,2000}?\s-p(?!\s)(\S+)/g,
40
+ /\bsshpass\s+(?:(?:-P\s+\S+|-[a-oq-zA-Z]\w*)\s+){0,3}-p\s*(\S+)/g,
41
+ /\bredis-cli\b[^\n]{0,200}?\s-a\s+(\S+)/g,
42
+ /(?:^|\s)(?:-u|--user)\s+[^\s:]+:(\S+)/g,
43
+ /\s-pass(?:in|out)?\s+pass:(\S+)/g,
44
+ /\s-(?:src|dest)?(?:store|key)?pass\s+(?!(?:env|file|fd):|stdin\b)(\S+)/g,
45
+ /(?:^|\s)--?(?:[a-z0-9]{1,32}[-_.]){0,3}(?:password|passwd|passphrase|pass|secret|token|api[-_]?key)(?:=|\s+)(?!-)(\S+)/gi,
46
+ ];
47
+ /** `htpasswd -b FILE USER PASS`, or `-nb USER PASS` (no file). */
48
+ const HTPASSWD = /\bhtpasswd\s+(?:-[a-zA-Z]{1,8}\s+){0,3}?-([a-zA-Z]{0,8}b[a-zA-Z]{0,8})\s+(\S+)\s+(\S+)(?:\s+(\S+))?/g;
49
+ /** A test runner's status line, `--- PASS: TestName (0.01s)` or `PASS: name`, upper case: no credential (`pass: x` is one). */
50
+ const TEST_STATUS = /^\s*(?:[-=]{3}\s*)?PASS\s*:/;
51
+ /** A value pointed at rather than held (a variable, a placeholder), once a quote or bracket before it is dropped. */
52
+ const REFERENCE = /^["'`([]*(?:\$|%|\*|\{|<|\.\.\.)/;
53
+ const NOT_A_SECRET = [
54
+ /^v?\d+(?:[.:_-]\d+)*$/i, // a number, a version, a time
55
+ /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+Z?)?$/, // a date
56
+ /^(?:\/|~\/|\.\.?\/)/, // a path
57
+ /^[a-z]+$/, // one plain lowercase word: a status
58
+ ];
59
+ const isSpanValue = (value: string) => value.length >= 4 && (/^\d{13,}$/.test(value) || !NOT_A_SECRET.some((shape) => shape.test(value)));
60
+ /** A password given as one: a span value, or six digits and more. */
61
+ const isPasswordValue = (value: string) => isSpanValue(value) || /^\d{6,}$/.test(value);
62
+ /**
63
+ * What may follow a credential's name at the end of its key: up to two short parts (`WIFI_PASSPHRASE_2400_BAUD`,
64
+ * `ADMIN_TOKEN_2`), then quotes. A part that says the key is ABOUT the credential (KEY_ABOUT) makes it no key for one.
65
+ */
66
+ const KEY_TAIL = String.raw`((?:[_.-][A-Za-z0-9]{1,12}){0,2})["'\\]{0,4}$`;
67
+ const KEY_ABOUT = /[_.-](?:policy|disabled|enabled|required|length|len|min|max|algorithm|algo|hash|hashed|salt|rounds|file|path|dir|env|var|name|id|type|kind|mode|set|present|missing|rules|reset|expiry|expires|ttl|age|count|ref|url|header|field|prompt|hint|strength|changed|updated)(?![A-Za-z0-9])/i;
68
+ const endsInCredential = (pattern: RegExp, key: string) => {
69
+ const match = pattern.exec(key.trim());
70
+ return match !== null && !KEY_ABOUT.test(match[1]);
71
+ };
72
+ /** A key that ends in a password word (`DB_PASS`, `"password"`), not one about passwords (`password_policy`). */
73
+ const PASSWORD_KEY = new RegExp(String.raw`(?:passw(?:or)?d|passwd|pwd|pass(?:phrase|code)?)s?` + KEY_TAIL, "i");
74
+ const isPasswordKey = (key: string) => endsInCredential(PASSWORD_KEY, key);
75
+ /** Push without spreading: a spread passes every element as an argument and overflows the stack on a huge line. */
76
+ function pushAll(into: string[], items: Iterable<string>): void {
77
+ for (const item of items) into.push(item);
78
+ }
79
+ /**
80
+ * A key named for one credential, in at most three words, ending in its name (quotes aside): singular, so
81
+ * `tokens: 1200` and a list of `secrets` are not one, no bare `pass` (a test runner's count), and no key about one
82
+ * (`password_policy`, `token_env`, `PASSWORD_FILE`). Letters may precede the name (`PGPASSWORD`).
83
+ */
84
+ const CREDENTIAL_KEY = new RegExp(String.raw`(?:passw(?:or)?d|passwd|passphrase|passcode|secret|token|api[_ -]?key|access[_ -]?key|private[_ -]?key|credential)` + KEY_TAIL, "i");
85
+ /** A bare lower-case `pass` key (nodemailer's `auth.pass`, YAML), which CREDENTIAL_KEY leaves out as a test runner's count. */
86
+ const BARE_PASS_KEY = /^["']?pass["']?$/;
87
+ const isCredentialKey = (key: string) => BARE_PASS_KEY.test(key.trim()) || key.length <= 40 && key.trim().split(/\s+/).length <= 3 && endsInCredential(CREDENTIAL_KEY, key);
88
+ /**
89
+ * A value shaped as a name of something rather than a secret: an identifier, an environment variable, a path, a file
90
+ * or host name, or a call that computes it (`secret = json.load(open('app.json'))`).
91
+ */
92
+ const NAME_SHAPES = [/^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)+$/, /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/, /\//, /^[\w-]+(?:\.[\w-]+)+$/, /^[A-Za-z_][\w.]{0,64}\(/];
93
+ const isNameShaped = (value: string) => !isRandomLooking(value) && NAME_SHAPES.some((shape) => shape.test(value));
94
+ /** A credential key's value: value-like, or eight characters and more; under a token, secret or key key, not shaped as a name. */
95
+ function isKeyedValue(value: string, key: string): boolean {
96
+ if (/^\d{6,}$/.test(value)) return isNumericPasswordKey(key);
97
+ if (isPasswordKey(key)) return isSpanValue(value) && (isValueLike(value) || value.length >= 6);
98
+ return (isValueLike(value) || value.length >= 8) && !isNameShaped(value) && !isNumberish(value);
99
+ }
100
+ /** A command's password argument: a password, value-like or six characters and more. */
101
+ const isCommandValue = (value: string) => isPasswordValue(value) && (isValueLike(value) || value.length >= 6);
102
+ /** A value prose gives: value-like, or a long run with a non-letter in it (a passphrase), not a long English word. */
103
+ const isProseValue = (value: string) => isValueLike(value) || (value.length >= 12 && /[^A-Za-z]/.test(value));
104
+
105
+ /** The words of `text` that hold a value, each with what wraps it trimmed. */
106
+ function coresOf(text: string): string[] {
107
+ const cores: string[] = [];
108
+ for (const [word] of text.matchAll(WORD)) {
109
+ if (REFERENCE.test(word)) continue;
110
+ const core = credentialCore(word);
111
+ if (core.end > core.start) cores.push(word.slice(core.start, core.end));
112
+ }
113
+ return cores;
114
+ }
115
+
116
+ /** A word that names the value after it as an id or a hash (`Evaluation ID:`, `plan_hash`, `commit`), not a secret. */
117
+ const ID_LABEL = /(?:^|[_.-])(?:id|ids|uuid|hash|sha|sha1|sha256|digest|commit|rev|revision|etag)$/i;
118
+
119
+ /**
120
+ * The random-looking words of `text`, except one the word before it on its line labels as an id or a hash (ID_LABEL),
121
+ * unless that label or the word before it names a credential (`secret_id`, `Secret ID`).
122
+ */
123
+ function randomWordsOf(text: string): string[] {
124
+ const values: string[] = [];
125
+ let label = "";
126
+ let previous = "";
127
+ let end = 0;
128
+ for (const match of text.matchAll(WORD)) {
129
+ if (text.slice(end, match.index).includes("\n")) label = previous = "";
130
+ const core = credentialCore(match[0]);
131
+ const bare = match[0].slice(core.start, core.end);
132
+ const idLabelled = ID_LABEL.test(label) && !CREDENTIAL_NAME.test(`${previous} ${label}`);
133
+ if (!REFERENCE.test(match[0]) && isRandomLooking(bare) && !idLabelled) values.push(bare);
134
+ previous = label;
135
+ label = bare;
136
+ end = match.index + match[0].length;
137
+ }
138
+ return values;
139
+ }
140
+
141
+ /** The password arguments of a command line. */
142
+ function commandValuesOf(line: string): string[] {
143
+ const values: string[] = [];
144
+ for (const pattern of COMMAND_PASSWORDS) for (const match of line.matchAll(pattern)) pushAll(values, coresOf(match[1]));
145
+ for (const match of line.matchAll(HTPASSWD)) {
146
+ const value = match[1].includes("n") ? match[3] : match[4];
147
+ if (value) pushAll(values, coresOf(value));
148
+ }
149
+ return values.filter(isCommandValue);
150
+ }
151
+
152
+ /** A password key written as YAML (`password: my Secret#Pass 99`), whose plain value runs to the line's end or a comment. */
153
+ const isYamlPassword = (line: string, key: string) => isPasswordKey(key) && /^\s*:\s/.test(line.trimStart().slice(key.length));
154
+ /** What is left of an assignment once the key is cut at its first colon: Go's `:=` or a type (`: str = `, `: &str = `). */
155
+ const ASSIGNED = /^(?:[^\s=]{1,40}[ \t]{1,8})?:?=(?!=)[ \t]{0,8}/;
156
+ /**
157
+ * What a key holds: its quoted string, or else its first word; the rest of the line is the command or prose around it
158
+ * (`TOKEN=$(vault kv get ... | python3 -c "import sys,json"`). Nothing when the value is computed or pointed at.
159
+ */
160
+ function heldBy(raw: string, toLineEnd: boolean): string {
161
+ const value = raw.replace(ASSIGNED, "");
162
+ const quoted = /^(\\{0,3}["'])((?:\\.|[^\n\\]){0,512}?)\1/.exec(value);
163
+ const comment = toLineEnd ? value.search(/\s#/) : -1;
164
+ const held = quoted ? quoted[2] : toLineEnd ? (comment < 0 ? value : value.slice(0, comment)) : (/^\S+/.exec(value)?.[0] ?? "");
165
+ return REFERENCE.test(held) || /^["'([]*`/.test(held) ? "" : held;
166
+ }
167
+
168
+ function valuesOf(text: string): string[] {
169
+ const values = namedCredentialValues(text).filter((value) => !isNameShaped(value));
170
+ for (const line of splitCredentialLines(text).credential) {
171
+ if (TEST_STATUS.test(line)) continue;
172
+ const keyed = keyedLine(line);
173
+ if (keyed && isCredentialKey(keyed.key)) pushAll(values, coresOf(heldBy(keyed.value, isYamlPassword(line, keyed.key))).filter((value) => isKeyedValue(value, keyed.key)));
174
+ else pushAll(values, randomWordsOf(line));
175
+ pushAll(values, commandValuesOf(line));
176
+ }
177
+ for (const match of text.matchAll(PROSE_PASSWORD)) pushAll(values, coresOf(match[1]).filter((value) => isProseValue(value) || /^\d{6,}$/.test(value)));
178
+ return values;
179
+ }
180
+
181
+ /** What a secret store printed: a value printed alone is one whatever it looks like; among other output, a random-looking one. */
182
+ function storeValuesOf(output: string): string[] {
183
+ const words = coresOf(output.trim());
184
+ return words.length === 1 ? words : randomWordsOf(output);
185
+ }
186
+
187
+ /** The span's credentials in `messages`: every whole value one of them showed as a credential. */
188
+ export function spanCredentials(messages: readonly AgentMessage[]): Set<string> {
189
+ const values = new Set<string>();
190
+ const add = (value: string) => { if (isPasswordValue(value)) values.add(value); };
191
+ for (const message of messages as unknown as Rec[]) for (const text of everyTextIn(message)) valuesOf(text).forEach(add);
192
+ for (const output of secretStoreOutputs(messages as unknown as Rec[])) storeValuesOf(output).forEach(add);
193
+ return values;
194
+ }
@@ -229,10 +229,13 @@ function isEventBody(event: Rec): boolean {
229
229
  }
230
230
  }
231
231
 
232
+ /** A span or seq: a whole number from 1, so a replayed `1e999` (Infinity) cannot stall the numbering after it. */
233
+ const isCount = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 1;
234
+
232
235
  /** Shape check for replayed entries: a malformed one is skipped and counted by the fold, never thrown. */
233
236
  export function isStateEvent(value: unknown): value is StateEvent {
234
237
  if (!isRec(value)) return false;
235
- const provenanceOk = (value.source === "remember" || value.source === "diff" || value.source === "pass") && typeof value.span === "number" && typeof value.at === "string" && typeof value.seq === "number";
238
+ const provenanceOk = (value.source === "remember" || value.source === "diff" || value.source === "pass") && isCount(value.span) && typeof value.at === "string" && isCount(value.seq);
236
239
  return provenanceOk && isEventBody(value);
237
240
  }
238
241
 
package/src/store/log.ts CHANGED
@@ -39,6 +39,7 @@ interface Fold {
39
39
  events: StateEvent[];
40
40
  spans: SpanSummary[];
41
41
  anomalies: FoldAnomaly[];
42
+ /** `span:seq` of every admitted event: a seq is unique within its span (see seqKey). */
42
43
  seqs: Set<string>;
43
44
  /** Loose events collected since the last compaction entry, keyed by nothing: in branch order. */
44
45
  loose: StateEvent[];
@@ -54,6 +55,15 @@ function quoteOf(event: StateEvent): Quote[] {
54
55
  }
55
56
  }
56
57
 
58
+ /**
59
+ * The duplicate key of an event: its seq within its span. A pass numbers from the fold it reads, and a fold that
60
+ * lost span records (another extension's entry ahead of one) numbered later spans from too low a seq, so two
61
+ * spans' records can share seqs; each span's events are still its own.
62
+ */
63
+ function seqKey(event: { span: number; seq: number }): string {
64
+ return `${event.span}:${event.seq}`;
65
+ }
66
+
57
67
  /** Admit one event into the fold, or record why not. */
58
68
  function admit(fold: Fold, raw: unknown, span: number, entryId: string, checker: QuoteChecker, into: StateEvent[]): void {
59
69
  const newer = newerStateFormat(raw);
@@ -73,7 +83,7 @@ function admit(fold: Fold, raw: unknown, span: number, entryId: string, checker:
73
83
  fold.anomalies.push({ span, entryId, reason: `event names span ${raw.span} while folding span ${span}: ${entryId}` });
74
84
  return;
75
85
  }
76
- const key = String(raw.seq);
86
+ const key = seqKey(raw);
77
87
  if (fold.seqs.has(key)) {
78
88
  fold.anomalies.push({ span, entryId, reason: `duplicate seq: ${raw.seq} at ${entryId}` });
79
89
  return;
@@ -131,7 +141,7 @@ function takeRecord(fold: Fold, entry: Extract<SessionEntry, { type: "custom" }>
131
141
  }
132
142
  // The record SUPERSEDES the span's loose events, so release their seqs before admitting its own
133
143
  // (otherwise the record's authoritative copy of a loose event is dropped as a duplicate seq).
134
- for (const loose of fold.loose) fold.seqs.delete(String(loose.seq));
144
+ for (const loose of fold.loose) fold.seqs.delete(seqKey(loose));
135
145
  const events: StateEvent[] = [];
136
146
  for (const raw of record.events) admit(fold, raw, pending.span, entry.id, checker, events);
137
147
  for (const event of events) fold.events.push(event);
@@ -140,13 +150,23 @@ function takeRecord(fold: Fold, entry: Extract<SessionEntry, { type: "custom" }>
140
150
  fold.pending = undefined;
141
151
  }
142
152
 
153
+ /**
154
+ * Whether `entry` proves a pending compaction's span record never landed: a message, another compaction, or one
155
+ * of our own event entries. Anything else another extension wrote at session_compact (pi-goal's goal-state and
156
+ * goal-contract land there, ahead of the record) is passed over, so the record that follows still closes the span.
157
+ */
158
+ function endsPendingSpan(entry: SessionEntry): boolean {
159
+ if (entry.type === "message" || entry.type === "compaction") return true;
160
+ return entry.type === "custom" && entry.customType === EVENT_ENTRY_TYPE;
161
+ }
162
+
143
163
  export function readLog(branchEntries: SessionEntry[], checker: QuoteChecker): StateLog {
144
164
  const fold: Fold = { events: [], spans: [], anomalies: [], seqs: new Set(), loose: [] };
145
165
  let compactions = 0;
146
166
  for (const entry of branchEntries) {
147
- // A pending state compaction is closed by its span record, which is the NEXT entry of interest;
148
- // anything else arriving first means the record never landed.
149
- if (fold.pending && !(entry.type === "custom" && entry.customType === SPAN_ENTRY_TYPE)) synthesizeClose(fold, undefined, entry.timestamp);
167
+ // A pending state compaction is closed by its span record, the next entry of OURS after it; a message,
168
+ // compaction or event arriving first means the record never landed.
169
+ if (fold.pending && endsPendingSpan(entry)) synthesizeClose(fold, undefined, entry.timestamp);
150
170
  if (entry.type === "compaction") {
151
171
  compactions++;
152
172
  const compactor = compactorOfEntry(entry);