switchroom 0.19.35 → 0.19.37

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.
Files changed (35) hide show
  1. package/dist/cli/skill-validate-pretool.mjs +15 -2
  2. package/dist/cli/switchroom.js +678 -444
  3. package/dist/host-control/main.js +149 -3
  4. package/package.json +4 -2
  5. package/profiles/_base/start.sh.hbs +37 -12
  6. package/telegram-plugin/bridge/bridge.ts +4 -4
  7. package/telegram-plugin/dist/bridge/bridge.js +4 -4
  8. package/telegram-plugin/dist/gateway/gateway.js +317 -95
  9. package/telegram-plugin/dist/server.js +4 -4
  10. package/telegram-plugin/format.ts +100 -29
  11. package/telegram-plugin/gateway/gateway.ts +22 -29
  12. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  13. package/telegram-plugin/gateway/model-command.ts +34 -0
  14. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  15. package/telegram-plugin/operator-events.ts +40 -16
  16. package/telegram-plugin/render/unsupported-token-guard.ts +10 -6
  17. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  18. package/telegram-plugin/secret-detect/index.ts +24 -1
  19. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  20. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  21. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  22. package/telegram-plugin/tests/format-consistency.test.ts +111 -0
  23. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  24. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  25. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +8 -1
  26. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  27. package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +36 -5
  28. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  29. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  30. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  31. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  32. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  33. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  34. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
@@ -718,21 +718,37 @@ export function normalizePunctuation(text: string): string {
718
718
  const restoreLinks = (s: string): string =>
719
719
  s.replace(linkRestoreRe, (_m, idx: string) => linkMasks[Number(idx)] ?? _m)
720
720
 
721
+ // The dash rewrite runs per line so blockquote lines can be exempted:
722
+ // `>` blockquotes are reserved for VERBATIM quoted text, and rewriting an
723
+ // author's ` — ` to `, ` inside a quotation would corrupt what they quoted.
724
+ // The expandable-blockquote opener `**>` is a blockquote line too even though
725
+ // isBlockquoteLine (which keys on a leading `>`) doesn't catch the `**`
726
+ // prefix, so exempt it explicitly. Code spans and link hrefs stay masked
727
+ // throughout, so their dashes are already protected on every line.
728
+ const rewriteDashes = (line: string): string =>
729
+ line
730
+ // 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
731
+ // flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
732
+ // dashes ("a — b — c") all normalize in one pass — a consumed \S would
733
+ // swallow the char that anchors the next match.
734
+ .replace(/(\S)[ \t][—–][ \t](?=(\S))/g, (_m, a: string, b: string) =>
735
+ /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
736
+ )
737
+ // 2. Bare em-dash between word chars. Numeric range keeps a hyphen.
738
+ // Right flank is a lookahead for the same consecutive-match reason.
739
+ .replace(/(\w)—(?=(\w))/g, (_m, a: string, b: string) =>
740
+ /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
741
+ )
742
+ // 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
743
+ .replace(/(\w)–(?=\w)/g, '$1-')
744
+
745
+ const isQuotedLine = (line: string): boolean =>
746
+ isBlockquoteLine(line) || line.trimStart().startsWith('**>')
747
+
721
748
  let out = maskedAutolinks
722
- // 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
723
- // flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
724
- // dashes ("a — b — c") all normalize in one pass — a consumed \S would
725
- // swallow the char that anchors the next match.
726
- .replace(/(\S)[ \t][—–][ \t](?=(\S))/g, (_m, a: string, b: string) =>
727
- /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
728
- )
729
- // 2. Bare em-dash between word chars. Numeric range keeps a hyphen.
730
- // Right flank is a lookahead for the same consecutive-match reason.
731
- .replace(/(\w)—(?=(\w))/g, (_m, a: string, b: string) =>
732
- /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
733
- )
734
- // 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
735
- .replace(/(\w)–(?=\w)/g, '$1-')
749
+ .split('\n')
750
+ .map((line) => (isQuotedLine(line) ? line : rewriteDashes(line)))
751
+ .join('\n')
736
752
 
737
753
  // Restore link hrefs now that the dash passes are done — before the bullet
738
754
  // pass and the code restore.
@@ -761,6 +777,35 @@ function isFullyBolded(fragment: string): boolean {
761
777
  return /^\*\*[^*]+\*\*[.,:;!?]?$/.test(fragment.trim())
762
778
  }
763
779
 
780
+ /**
781
+ * Max length (markers included) of a standalone fully-bolded line that is
782
+ * treated as a legitimate pseudo-heading (`**Section**`) rather than an
783
+ * over-bolded paragraph. Single source of truth for BOTH the per-block rule
784
+ * and the global-ratio heading exemption.
785
+ */
786
+ const PSEUDO_HEADING_MAX_CHARS = 48
787
+
788
+ /**
789
+ * True when a blank-line-delimited block is a single-line pseudo-heading: one
790
+ * non-empty line that is a single fully-bolded span of ≤PSEUDO_HEADING_MAX_CHARS
791
+ * characters (`**Summary**`, `**Next steps:**`). Multi-line blocks are never
792
+ * headings, so this can never mislabel a bolded paragraph as exempt.
793
+ */
794
+ function isPseudoHeadingBlock(block: string): boolean {
795
+ const lines = block.split('\n').filter((l) => l.trim() !== '')
796
+ if (lines.length !== 1) return false
797
+ const t = lines[0].trim()
798
+ return t.length <= PSEUDO_HEADING_MAX_CHARS && isFullyBolded(t)
799
+ }
800
+
801
+ /** Diagnostic emitted when the over-bold tripwire actually removes bold. */
802
+ export interface ExcessBoldStripDiagnostic {
803
+ /** Which rule fired: the whole-message ratio, or per-block flattening. */
804
+ rule: 'global' | 'per-block'
805
+ /** Measured bold ratio: bold chars / visible (non-code) chars. */
806
+ ratio: number
807
+ }
808
+
764
809
  /** Strip `**bold**` markers from a fragment, keeping the text. */
765
810
  function unbold(fragment: string): string {
766
811
  return fragment.replace(/\*\*([^*]+)\*\*/g, '$1')
@@ -778,16 +823,24 @@ function unbold(fragment: string): string {
778
823
  * Deliberately conservative:
779
824
  * - Messages under 100 non-code characters are exempt (a short reply whose
780
825
  * one key fact is bolded is exactly the house style).
781
- * - A single-line fully-bolded paragraph of ≤48 chars is treated as a
782
- * pseudo-heading (the "**Section**" label the fleet style encourages) and
783
- * is NOT stripped by the per-block rule (it still counts toward the
784
- * global ratio).
826
+ * - A single-line fully-bolded paragraph of ≤PSEUDO_HEADING_MAX_CHARS chars
827
+ * is treated as a pseudo-heading (the "**Section**" label the fleet style
828
+ * encourages) and is NOT stripped — neither by the per-block rule NOR by
829
+ * the global-ratio rule (it still counts toward the global ratio, so a
830
+ * genuinely over-bolded message still trips, but its section headings
831
+ * survive instead of the whole reply going plain).
785
832
  * - A list with any non-fully-bolded item is left alone.
786
833
  *
787
834
  * Code spans/fences are masked (maskCodeRegions) and never counted or
788
835
  * modified. Idempotent: stripped output has no `**` spans left to trip on.
836
+ *
837
+ * `onStrip` is an optional diagnostic sink invoked exactly once, only when
838
+ * bold is actually removed, carrying which rule fired and the measured ratio.
789
839
  */
790
- export function stripExcessBold(text: string): string {
840
+ export function stripExcessBold(
841
+ text: string,
842
+ onStrip?: (d: ExcessBoldStripDiagnostic) => void,
843
+ ): string {
791
844
  if (!text.includes('**')) return text
792
845
 
793
846
  const nonce = Math.random().toString(36).slice(2)
@@ -800,14 +853,24 @@ export function stripExcessBold(text: string): string {
800
853
 
801
854
  let boldChars = 0
802
855
  for (const m of visible.matchAll(/\*\*([^*]+)\*\*/g)) boldChars += m[1].length
856
+ const ratio = boldChars / visible.length
857
+
858
+ // Blank-line-delimited blocks of the masked text (shared by both rules).
859
+ const blocks = masked.split(/\n{2,}/)
803
860
 
804
- if (boldChars / visible.length > 0.3) {
805
- // Clearly over-bolded — strip every bold span, keep the text.
806
- return restore(unbold(masked))
861
+ if (ratio > 0.3) {
862
+ // Clearly over-bolded — strip every bold span, keeping the text, EXCEPT
863
+ // standalone short pseudo-heading blocks (`**Section**`), which stay bold
864
+ // so a bold-dense digest keeps its section headings.
865
+ const rebuilt = blocks.map((block) =>
866
+ isPseudoHeadingBlock(block) ? block : unbold(block),
867
+ )
868
+ const out = rejoinBlocks(masked, rebuilt)
869
+ if (out !== masked) onStrip?.({ rule: 'global', ratio })
870
+ return restore(out)
807
871
  }
808
872
 
809
- // Per-block check on blank-line-delimited blocks of the masked text.
810
- const blocks = masked.split(/\n{2,}/)
873
+ // Per-block check on the same blocks.
811
874
  const rebuilt = blocks.map((block) => {
812
875
  const lines = block.split('\n').filter((l) => l.trim() !== '')
813
876
  if (lines.length === 0 || !block.includes('**')) return block
@@ -824,17 +887,25 @@ export function stripExcessBold(text: string): string {
824
887
  const isProseBlock = lines.every((l) => !isMarkerLine(l, placeholder))
825
888
  if (!isProseBlock) return block
826
889
  if (!lines.every((l) => isFullyBolded(l))) return block
827
- if (lines.length === 1 && lines[0].trim().length <= 48) return block
890
+ if (isPseudoHeadingBlock(block)) return block
828
891
  return unbold(block)
829
892
  })
830
893
 
831
- // Rejoin with the original gap shapes: split() lost them, so re-split the
832
- // masked text capturing the separators and interleave.
894
+ const out = rejoinBlocks(masked, rebuilt)
895
+ if (out !== masked) onStrip?.({ rule: 'per-block', ratio })
896
+ return restore(out)
897
+ }
898
+
899
+ /**
900
+ * Rejoin blocks produced by `masked.split(/\n{2,}/)` after per-block mapping,
901
+ * restoring the ORIGINAL blank-gap shapes (split() drops the separators, so
902
+ * re-capture them from the masked source and interleave).
903
+ */
904
+ function rejoinBlocks(masked: string, rebuilt: string[]): string {
833
905
  const seps = masked.match(/\n{2,}/g) ?? []
834
906
  let out = rebuilt[0] ?? ''
835
907
  for (let i = 1; i < rebuilt.length; i++) out += (seps[i - 1] ?? '\n\n') + rebuilt[i]
836
-
837
- return restore(out)
908
+ return out
838
909
  }
839
910
 
840
911
  // ---------------------------------------------------------------------------
@@ -550,6 +550,9 @@ import {
550
550
  handleModelCommand,
551
551
  classifyModelSwitchConfirmation,
552
552
  formatModelRelaunchDiagLog,
553
+ parseModelSwitchTarget,
554
+ resolveSessionModelResolutionTimeoutMs,
555
+ waitForSessionModelResolution,
553
556
  servedModelMatchesRequested,
554
557
  buildServedModelDivergenceHandler,
555
558
  deliverModelSwitchBootNotice,
@@ -23665,23 +23668,26 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23665
23668
  }
23666
23669
  } catch {}
23667
23670
 
23668
- // ─── Session-model re-hydration + LiteLLM-down alert (session relaunch) ───
23669
- //
23670
- // start.sh writes the EFFECTIVE launched model to `.active-session-model`
23671
- // on every boot (the model actually passed to `claude --model`). Re-hydrate
23672
- // the in-memory session-model override from it so `/status` and the welcome
23673
- // card stay honest after a session-relaunch restart. Only treat it as an
23674
- // override when it differs from the configured/default model — a plain boot
23675
- // on the configured model leaves the override null.
23676
- //
23677
- // Also consume the `.session-model-alert` sentinel: start.sh drops it when
23678
- // it had to DROP an sr-* override because LiteLLM was unreachable at boot
23679
- // (booting on the configured default instead of 4xx-ing against Anthropic).
23680
- // We turn it into a loud Telegram message to the operator, then delete it.
23671
+ // Rehydrate only after start.sh publishes this boot's resolved outputs.
23681
23672
  try {
23682
23673
  const smAgentDir = resolveAgentDirFromEnv()
23683
23674
  if (smAgentDir) {
23684
- const activePath = join(smAgentDir, '.active-session-model')
23675
+ const resolutionTimeoutMs = resolveSessionModelResolutionTimeoutMs(
23676
+ process.env.SWITCHROOM_SESSION_MODEL_RESOLUTION_TIMEOUT_MS,
23677
+ )
23678
+ const resolved = await waitForSessionModelResolution({
23679
+ barrierExists: () => existsSync(join(smAgentDir, '.session-model-resolved')),
23680
+ timeoutMs: resolutionTimeoutMs,
23681
+ })
23682
+ if (!resolved) {
23683
+ const target = modelSwitchReason == null
23684
+ ? '(none)'
23685
+ : (parseModelSwitchTarget(modelSwitchReason) ?? '(unknown)')
23686
+ process.stderr.write(
23687
+ `telegram gateway: gw /model relaunch UNRESOLVED agent=${getMyAgentName()} target=${target} (barrier timeout after ${resolutionTimeoutMs}ms)\n`,
23688
+ )
23689
+ } else {
23690
+ const activePath = join(smAgentDir, '.active-session-model')
23685
23691
  if (existsSync(activePath)) {
23686
23692
  try {
23687
23693
  const launched = readFileSync(activePath, 'utf8').trim()
@@ -23697,21 +23703,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23697
23703
  // a phantom session override.
23698
23704
  return resolveMainModel(raw ?? undefined)
23699
23705
  })()
23700
- // `launched !== configured` is the DETERMINISTIC "a model switch
23701
- // landed" signal (F1): the carrier is consume-once, so a launched
23702
- // model that differs from the configured default only ever
23703
- // happens on a genuine apply-boot. Seed the in-memory override
23704
- // from it — this is the ONLY success reporting, sourced from the
23705
- // real post-boot signal (`.active-session-model`), never from a
23706
- // scraped pane or an optimistic record.
23706
+ // A launched model differing from configured is a genuine apply boot.
23707
23707
  const isApplyBoot = launched.length > 0 && launched !== configured
23708
- // { verify: true } (#3427 item 4 / H1): ONLY this site arms the
23709
- // requested-vs-served tripwire — `launched` IS the token of the
23710
- // session now serving. Command-time setOverride never arms.
23711
23708
  sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })
23712
- // Boot /model cards (#3427): the divergence tripwire warn and
23713
- // the switch confirmation share one deps surface; the card
23714
- // logic lives in model-command.ts (#2996 ratchet).
23715
23709
  const modelBootCardDeps: ModelBootCardDeps = {
23716
23710
  agent: getMyAgentName(),
23717
23711
  chat: modelSwitchMarkerChat,
@@ -23722,8 +23716,6 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23722
23716
  if (isApplyBoot) {
23723
23717
  sessionModelSource.setDivergenceHandler(buildServedModelDivergenceHandler(modelBootCardDeps))
23724
23718
  }
23725
- // F1/N4: classify + log + one confirmation card. Formatters live
23726
- // in model-command.ts so this file does not inflate (#2996 ratchet).
23727
23719
  const confirmation = modelSwitchReason != null
23728
23720
  ? classifyModelSwitchConfirmation({
23729
23721
  reason: modelSwitchReason,
@@ -23793,6 +23785,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23793
23785
  process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop — ${alertText}\n`)
23794
23786
  }
23795
23787
  }
23788
+ }
23796
23789
  }
23797
23790
  } catch (err) {
23798
23791
  process.stderr.write(`telegram gateway: session-model re-hydration failed: ${(err as Error)?.message ?? String(err)}\n`)
@@ -25,6 +25,7 @@ import type {
25
25
  ToolCallResult,
26
26
  } from "./ipc-protocol.js";
27
27
  import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
28
+ import { OPERATOR_EVENT_KINDS } from "../operator-events.js";
28
29
 
29
30
  export interface IpcServerOptions {
30
31
  socketPath: string;
@@ -196,21 +197,23 @@ type SocketData = { clientId: string; buffer: string };
196
197
  * data without newline delimiters, which would cause unbounded memory growth. */
197
198
  const MAX_BUFFER_SIZE = 1024 * 1024;
198
199
 
199
- /** Allowlist of OperatorEventKind values that can arrive over IPC. Mirrors
200
- * the union in `telegram-plugin/operator-events.ts` — kept as a literal Set
201
- * here so the validator has zero cross-package type dependencies. If the
202
- * taxonomy grows, update both places. */
203
- const VALID_OPERATOR_KINDS = new Set([
204
- "credentials-expired",
205
- "credentials-invalid",
206
- "credit-exhausted",
207
- "quota-exhausted",
208
- "rate-limited",
209
- "agent-crashed",
210
- "agent-restarted-unexpectedly",
211
- "unknown-4xx",
212
- "unknown-5xx",
213
- ]);
200
+ /** Allowlist of OperatorEventKind values that can arrive over IPC. DERIVED
201
+ * from the canonical `OPERATOR_EVENT_KINDS` array in
202
+ * `telegram-plugin/operator-events.ts` — the single source of truth for the
203
+ * taxonomy — so the validator can NEVER drift out of sync with it again.
204
+ *
205
+ * History: this used to be a hand-maintained literal Set that missed the
206
+ * `provider-credit-exhausted` / `mcp-dependency-blocked` / `proxy-misconfig`
207
+ * kinds after they were added to the union. The bridge forwarded a
208
+ * `provider-credit-exhausted` operator_event; this validator rejected it as an
209
+ * "invalid IPC message shape" and dropped it, so an OpenRouter/LiteLLM 402
210
+ * credit wall produced NO loud Telegram card (the 2026-07-30 incident).
211
+ * Deriving from the canonical array closes that drift class structurally.
212
+ *
213
+ * `operator-events.ts` is a pure module (its only imports are the pure
214
+ * `format` / `raw-error-scrub` / `model-unavailable` / `provider-credit`
215
+ * leaves), so importing it here introduces no cycle back into the gateway. */
216
+ const VALID_OPERATOR_KINDS = new Set<string>(OPERATOR_EVENT_KINDS);
214
217
 
215
218
  /** Same regex as `assertSafeAgentName` and the op:* callback handler in
216
219
  * gateway.ts — keeps every entry-point that touches an agent name on the
@@ -96,6 +96,40 @@ export function isValidModelArg(arg: string): boolean {
96
96
  return MODEL_ARG_RE.test(arg)
97
97
  }
98
98
 
99
+ export const DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS = 180_000
100
+ export const SESSION_MODEL_RESOLUTION_POLL_MS = 250
101
+
102
+ /** Parse the bounded boot-resolution wait without allowing NaN/negative hangs. */
103
+ export function resolveSessionModelResolutionTimeoutMs(raw: string | undefined): number {
104
+ if (raw == null || raw.trim() === '') return DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS
105
+ const value = Number(raw)
106
+ return Number.isFinite(value) && value >= 0
107
+ ? value
108
+ : DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS
109
+ }
110
+
111
+ /**
112
+ * Asynchronously wait until start.sh atomically publishes this boot's resolved
113
+ * session-model outputs. The deadline is checked on every iteration, including
114
+ * timeout=0, so a missing barrier can never hang gateway startup indefinitely.
115
+ */
116
+ export async function waitForSessionModelResolution(input: {
117
+ barrierExists: () => boolean
118
+ timeoutMs: number
119
+ pollMs?: number
120
+ sleep?: (ms: number) => Promise<void>
121
+ }): Promise<boolean> {
122
+ const pollMs = input.pollMs ?? SESSION_MODEL_RESOLUTION_POLL_MS
123
+ const sleep = input.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
124
+ const startedAt = Date.now()
125
+ for (;;) {
126
+ if (input.barrierExists()) return true
127
+ const remaining = input.timeoutMs - (Date.now() - startedAt)
128
+ if (remaining <= 0) return false
129
+ await sleep(Math.min(pollMs, remaining))
130
+ }
131
+ }
132
+
99
133
  /** True when `name` is an sr-* (LiteLLM/OpenRouter) model identifier. */
100
134
  export function isSrModel(name: string): boolean {
101
135
  return name.startsWith('sr-')
@@ -191,7 +191,18 @@ export function normalizeOutboundBody(
191
191
  if (!literalText) text = normalizeParagraphBreaks(text)
192
192
  text = redact(text, site)
193
193
  if (!literalText) {
194
- let formatted = stripExcessBold(normalizePunctuation(text))
194
+ let formatted = stripExcessBold(normalizePunctuation(text), (d) => {
195
+ // Observability: the over-bold tripwire silently flattened formatting.
196
+ // Emit one diagnostic line naming the rule + measured ratio so a lost
197
+ // reply is traceable (it previously vanished with no signal).
198
+ try {
199
+ process.stderr.write(
200
+ `telegram gateway: strip-excess-bold: fired site=${site} rule=${d.rule} ratio=${d.ratio.toFixed(3)}\n`,
201
+ )
202
+ } catch {
203
+ // stderr write must never break the send path. Swallow.
204
+ }
205
+ })
195
206
  if (addSpacers) formatted = addParagraphSpacers(formatted)
196
207
  text = formatted
197
208
  // Temporal normalization (#3501): UTC/Zulu → local wall clock, then
@@ -23,11 +23,32 @@ import {
23
23
 
24
24
  // ─── Taxonomy ────────────────────────────────────────────────────────────────
25
25
 
26
- export type OperatorEventKind =
27
- | 'credentials-expired'
28
- | 'credentials-invalid'
29
- | 'proxy-misconfig'
30
- | 'credit-exhausted'
26
+ /**
27
+ * The CANONICAL runtime list of every operator-event kind — the SINGLE source
28
+ * of truth for the taxonomy. The `OperatorEventKind` type is derived from it
29
+ * (below), and every other place that needs the set at RUNTIME (notably the
30
+ * gateway IPC validator's `VALID_OPERATOR_KINDS` in `gateway/ipc-server.ts`)
31
+ * imports THIS array rather than re-declaring a hand-maintained literal.
32
+ *
33
+ * WHY THIS IS AN ARRAY, NOT JUST A TYPE (the drift this closes)
34
+ * ------------------------------------------------------------
35
+ * A TypeScript union erases at compile time, so a runtime allowlist could only
36
+ * mirror it by hand — and it drifted: `ipc-server.ts` shipped a 9-entry Set
37
+ * that never gained `provider-credit-exhausted` / `mcp-dependency-blocked` /
38
+ * `proxy-misconfig` when those kinds were added here. The bridge forwarded a
39
+ * `provider-credit-exhausted` operator_event; the gateway validator rejected it
40
+ * as an "invalid IPC message shape" and DROPPED it — so a real OpenRouter/
41
+ * LiteLLM 402 credit wall produced NO loud Telegram card at all (the 2026-07-30
42
+ * incident). Deriving both the type and the runtime allowlist from this one
43
+ * array makes that class of silent drop structurally impossible.
44
+ *
45
+ * Order is not significant. Adding a kind is a one-line edit HERE.
46
+ */
47
+ export const OPERATOR_EVENT_KINDS = [
48
+ 'credentials-expired',
49
+ 'credentials-invalid',
50
+ 'proxy-misconfig',
51
+ 'credit-exhausted',
31
52
  /**
32
53
  * A THIRD-PARTY model provider (OpenRouter / OpenAI / Perplexity) reports no
33
54
  * credit remaining — HTTP 402 `payment_required`, "insufficient credits",
@@ -37,7 +58,7 @@ export type OperatorEventKind =
37
58
  * must not share a card. Operator-actionable (top up in the vendor console),
38
59
  * never user-actionable — see {@link OPERATOR_ACTIONABLE_KINDS}.
39
60
  */
40
- | 'provider-credit-exhausted'
61
+ 'provider-credit-exhausted',
41
62
  /**
42
63
  * A paid MCP dependency (Perplexity, Eraser, Brevo, Postiz, Meta/Google Ads,
43
64
  * Cloudflare …) is refusing work because its KEY is the problem — out of
@@ -49,16 +70,19 @@ export type OperatorEventKind =
49
70
  * `mcp-credential-failure.ts`. Operator-actionable: only the operator can top
50
71
  * up or re-issue a key.
51
72
  */
52
- | 'mcp-dependency-blocked'
53
- | 'quota-exhausted'
54
- | 'rate-limited'
55
- | 'agent-crashed'
56
- | 'agent-restarted-unexpectedly'
57
- | 'unknown-4xx'
58
- | 'unknown-5xx'
59
- | 'config-warning'
60
- | 'always-allow-persist-failed'
61
- | 'mental-model-persist-failed'
73
+ 'mcp-dependency-blocked',
74
+ 'quota-exhausted',
75
+ 'rate-limited',
76
+ 'agent-crashed',
77
+ 'agent-restarted-unexpectedly',
78
+ 'unknown-4xx',
79
+ 'unknown-5xx',
80
+ 'config-warning',
81
+ 'always-allow-persist-failed',
82
+ 'mental-model-persist-failed',
83
+ ] as const
84
+
85
+ export type OperatorEventKind = (typeof OPERATOR_EVENT_KINDS)[number]
62
86
 
63
87
  export interface OperatorEvent {
64
88
  kind: OperatorEventKind
@@ -59,12 +59,16 @@ import { splitProtectedSegments } from "./code-segments.js";
59
59
  * `a^2+b^2=c^2`, `2^8`, and `x^n` all pass through untouched. */
60
60
  const CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
61
61
 
62
- /** Footnote reference marker `[^N]` (digits only) NOT immediately followed by
63
- * `:` (which would make it a footnote DEFINITION line we leave intact). Removed
64
- * entirely. Restricting the id to digits keeps this off in-prose bracket
65
- * literals like `array[^index]` and regex-ish `[^/]`, which are NOT footnotes
66
- * and would otherwise be silently eaten. */
67
- const FOOTNOTE_MARKER = /\[\^\d+\](?!:)/g;
62
+ /** Footnote reference marker `[^id]` where the id is a short alphanumeric run
63
+ * (`[^1]`, `[^note]`, `[^ref]`) NOT immediately followed by `:` (which would
64
+ * make it a footnote DEFINITION line we leave intact). Removed entirely.
65
+ * Requiring the id to be 1–10 ALPHANUMERIC chars keeps this off regex-ish
66
+ * literals like `[^/]`, `[^\s]`, `[^-a-z]`, whose bodies contain punctuation
67
+ * and so never match. In-prose subscript-ish `array[^i]` outside a code span
68
+ * is a rare theoretical false positive (an `i` id matches); code spans are
69
+ * masked upstream by splitProtectedSegments so real code is safe, and prose
70
+ * that writes a literal `[^i]` reads as footnote noise anyway. */
71
+ const FOOTNOTE_MARKER = /\[\^[A-Za-z0-9]{1,10}\](?!:)/g;
68
72
 
69
73
  /** `<details>…</details>` with an optional leading `<summary>…</summary>`.
70
74
  * Dot-all via `[\s\S]`; non-greedy so adjacent blocks don't merge. */
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Database / service connection-URI credential scanner.
3
+ *
4
+ * Gap closed (2026-07 hindsight write-path audit): a connection string
5
+ * like `postgres://appuser:<password>@db.internal:5432/prod` passed the
6
+ * whole detection stack untouched and was stored verbatim in agent
7
+ * memory. `url-redact.ts` only rewrites `http(s)`/`ws(s)`/`ftp` (its
8
+ * `URL_RE` is scheme-limited), and no pattern in `patterns.ts` describes
9
+ * the `scheme://user:pass@host` shape, so a production database password
10
+ * was invisible to every caller of `detectSecrets`.
11
+ *
12
+ * This scanner is deliberately SCHEME-AGNOSTIC — it matches any
13
+ * `<scheme>://<user>:<password>@<host>` — so postgres, mysql, mongodb,
14
+ * redis, amqp, clickhouse, mssql and whatever ships next are all covered
15
+ * without a scheme allowlist to keep current. Precision comes from the
16
+ * shape, not from the scheme list: userinfo-with-password in a URI is a
17
+ * credential by construction.
18
+ *
19
+ * Only the PASSWORD bytes are reported. The username is left intact: it
20
+ * is rarely the secret, and keeping it preserves the diagnostic value of
21
+ * the stored line ("which account", not just "some database").
22
+ *
23
+ * Confidence is `ambiguous`, NOT `high`. `pipeline.ts` auto-writes every
24
+ * high-confidence inbound hit to the vault and the caller DELETES the
25
+ * user's Telegram message; promoting this rule to `high` would change
26
+ * inbound gating as a side effect of a redaction fix. `redact()` masks
27
+ * ambiguous hits, which is what this change is for.
28
+ */
29
+ import type { RawHit } from './kv-scanner.js'
30
+
31
+ /**
32
+ * `<scheme>://<user>:<password>@<host>`.
33
+ *
34
+ * - scheme: RFC 3986 shape, at least two chars so a Windows drive letter
35
+ * (`c://`) can't anchor a match.
36
+ * - user: no `/`, `@`, `:` or whitespace.
37
+ * - password: anything up to an authority TERMINATOR (`/`, `?`, `#` or
38
+ * whitespace) — `@` deliberately INCLUDED. Greedy matching then backs
39
+ * off to the LAST `@` in the authority, which is where the host
40
+ * actually starts.
41
+ *
42
+ * #3982 review, MAJOR 3: excluding `@` (so the match stopped at the
43
+ * FIRST one) looked conservative and was the opposite. An unencoded
44
+ * `@` in a pasted DSN is common, and
45
+ * `postgres://appuser:p@ssW0rd123@db.internal:5432/prod` masked one
46
+ * byte and stored the other ten in clear —
47
+ * `postgres://appuser:[REDACTED:db_uri_password]@ssW0rd123@db.internal…`
48
+ * — while also telling a reader exactly how long the masked prefix
49
+ * was. `url-redact.ts` already did this right for http(s) by taking
50
+ * `lastIndexOf('@')` over the authority; this is the same rule for the
51
+ * non-HTTP schemes.
52
+ * - host: at least one non-delimiter byte, so `user:pass@` with nothing
53
+ * after it is not a connection string.
54
+ */
55
+ const DB_URI_RE =
56
+ /\b([a-zA-Z][a-zA-Z0-9+.-]+):\/\/([^\s:/@]+):([^\s/?#]+)@([^\s/@?#]+)/g
57
+
58
+ export const DB_URI_RULE_ID = 'db_uri_password'
59
+
60
+ /** Values that are already masked or are obviously not a live credential. */
61
+ function isInertPassword(value: string): boolean {
62
+ if (value.startsWith('[REDACTED')) return true
63
+ // `redactUrls()` rewrites `user:pass@` to `***@` (empty password), but a
64
+ // hand-written `***`/`xxx` mask should not round-trip into a detection
65
+ // either.
66
+ return /^[*x]+$/i.test(value)
67
+ }
68
+
69
+ export function scanDbUris(text: string): RawHit[] {
70
+ const hits: RawHit[] = []
71
+ DB_URI_RE.lastIndex = 0
72
+ let m: RegExpExecArray | null
73
+ while ((m = DB_URI_RE.exec(text)) !== null) {
74
+ const scheme = m[1]!
75
+ const user = m[2]!
76
+ const password = m[3]!
77
+ if (isInertPassword(password)) continue
78
+ // Offset of the password = match start + scheme + "://" + user + ":".
79
+ const start = m.index + scheme.length + 3 + user.length + 1
80
+ hits.push({
81
+ rule_id: DB_URI_RULE_ID,
82
+ start,
83
+ end: start + password.length,
84
+ matched_text: password,
85
+ key_name: `${scheme}_password`,
86
+ confidence: 'ambiguous',
87
+ })
88
+ }
89
+ return hits
90
+ }
@@ -30,7 +30,9 @@
30
30
  * scrub-coverage PR. Gitleaks TOML is loaded via `gitleaks-loader.ts`.
31
31
  */
32
32
  import { ALL_PATTERNS } from './patterns.js'
33
- import { scanKeyValue, type RawHit } from './kv-scanner.js'
33
+ import { scanKeyValue, scanMemorablePasswords, type RawHit } from './kv-scanner.js'
34
+ import { scanDbUris } from './db-uri.js'
35
+ import { INERT_GATED_RULES, isInertValue } from './inert-values.js'
34
36
  import { scanGenericSecrets } from './generic-entropy.js'
35
37
  import { shannonEntropy } from './entropy.js'
36
38
  import { chunk } from './chunker.js'
@@ -87,6 +89,17 @@ export function detectSecrets(text: string): Detection[] {
87
89
  const globalEnd = globalStart + cap.length
88
90
  // For env_key_value (captureIndex=3), the LHS is group 1.
89
91
  const keyName = p.rule_id === 'env_key_value' ? m[1] : undefined
92
+ // Inert VALUE gate. `PASSWORD: ${DB_PASSWORD}`,
93
+ // `POSTGRES_PASSWORD: vault:pg/password`,
94
+ // `JWT_SECRET=<generate-with-openssl-rand>` and `--token <value>`
95
+ // are documentation, not credentials — and a vault key NAME is
96
+ // exactly what an agent is meant to remember, so masking it
97
+ // deletes information and keeps none. Until #3982's review this
98
+ // list applied only inside the memorable-password rule, so letter
99
+ // case decided the outcome: `password: ${DB_PASSWORD}` survived
100
+ // and `PASSWORD: ${DB_PASSWORD}` was destroyed. See
101
+ // inert-values.ts.
102
+ if (INERT_GATED_RULES.has(p.rule_id) && isInertValue(cap)) continue
90
103
  // 2026-05-12: shape gate on env_key_value — the pattern matches
91
104
  // any value after an ALLCAPS *_KEY/_TOKEN/_SECRET/_PASSWORD
92
105
  // identifier, which previously fired on casual chat like
@@ -125,6 +138,16 @@ export function detectSecrets(text: string): Detection[] {
125
138
  for (const h of kvHits) {
126
139
  raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset })
127
140
  }
141
+ // Connection-URI credentials (`postgres://user:pass@host`) — the
142
+ // scheme-agnostic shape url-redact.ts cannot see.
143
+ for (const h of scanDbUris(win.text)) {
144
+ raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset })
145
+ }
146
+ // Human-memorable passwords behind an explicit `password` label — the
147
+ // shape the Shannon-entropy gate above is structurally blind to.
148
+ for (const h of scanMemorablePasswords(win.text)) {
149
+ raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset })
150
+ }
128
151
  // Generic bare-high-entropy fallback (ambiguous). Catches standalone
129
152
  // tokens no prefix/KV rule matched. dropOverlaps/dedupeRaw below prefer
130
153
  // a high-confidence pattern hit over a generic one on the same range,