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.
- package/dist/cli/skill-validate-pretool.mjs +15 -2
- package/dist/cli/switchroom.js +678 -444
- package/dist/host-control/main.js +149 -3
- package/package.json +4 -2
- package/profiles/_base/start.sh.hbs +37 -12
- package/telegram-plugin/bridge/bridge.ts +4 -4
- package/telegram-plugin/dist/bridge/bridge.js +4 -4
- package/telegram-plugin/dist/gateway/gateway.js +317 -95
- package/telegram-plugin/dist/server.js +4 -4
- package/telegram-plugin/format.ts +100 -29
- package/telegram-plugin/gateway/gateway.ts +22 -29
- package/telegram-plugin/gateway/ipc-server.ts +18 -15
- package/telegram-plugin/gateway/model-command.ts +34 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
- package/telegram-plugin/operator-events.ts +40 -16
- package/telegram-plugin/render/unsupported-token-guard.ts +10 -6
- package/telegram-plugin/secret-detect/db-uri.ts +90 -0
- package/telegram-plugin/secret-detect/index.ts +24 -1
- package/telegram-plugin/secret-detect/inert-values.ts +147 -0
- package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
- package/telegram-plugin/secret-detect/patterns.ts +24 -4
- package/telegram-plugin/tests/format-consistency.test.ts +111 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
- package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +8 -1
- package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +36 -5
- package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
- package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
- package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
- 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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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 ≤
|
|
782
|
-
* pseudo-heading (the "**Section**" label the fleet style
|
|
783
|
-
* is NOT stripped by the per-block rule
|
|
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(
|
|
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 (
|
|
805
|
-
// Clearly over-bolded — strip every bold span,
|
|
806
|
-
|
|
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
|
|
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 (
|
|
890
|
+
if (isPseudoHeadingBlock(block)) return block
|
|
828
891
|
return unbold(block)
|
|
829
892
|
})
|
|
830
893
|
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
200
|
-
* the
|
|
201
|
-
*
|
|
202
|
-
* taxonomy
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
"
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 `[^
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
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,
|