@oh-my-pi/pi-coding-agent 17.2.10 → 17.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/dist/{CHANGELOG-rhwzpexa.md → CHANGELOG-k9ghy5sn.md} +53 -0
- package/dist/cli.js +10775 -10762
- package/dist/types/advisor/runtime.d.ts +1 -1
- package/dist/types/capability/mcp.d.ts +11 -0
- package/dist/types/capability/skill.d.ts +6 -0
- package/dist/types/cli/command-help.d.ts +3 -0
- package/dist/types/commands/share.d.ts +25 -0
- package/dist/types/commit/agentic/index.d.ts +3 -1
- package/dist/types/commit/execute.d.ts +32 -0
- package/dist/types/commit/index.d.ts +2 -1
- package/dist/types/commit/pipeline.d.ts +7 -1
- package/dist/types/config/custom-models.d.ts +31 -0
- package/dist/types/config/model-config-values.d.ts +19 -0
- package/dist/types/config/model-patch.d.ts +93 -0
- package/dist/types/config/model-provider-discovery.d.ts +51 -0
- package/dist/types/config/model-registry.d.ts +9 -52
- package/dist/types/config/settings-schema.d.ts +1 -34
- package/dist/types/config/settings.d.ts +5 -3
- package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
- package/dist/types/discovery/agent-plugin-format.d.ts +136 -0
- package/dist/types/discovery/agent-plugins.d.ts +1 -0
- package/dist/types/discovery/contained-path.d.ts +33 -0
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/eval/executor-base.d.ts +8 -2
- package/dist/types/eval/kernel-session-registry.d.ts +60 -0
- package/dist/types/export/share.d.ts +1 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/loader.d.ts +7 -0
- package/dist/types/extensibility/extensions/types.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +3 -2
- package/dist/types/extensibility/skills.d.ts +5 -0
- package/dist/types/lsp/diagnostics.d.ts +96 -0
- package/dist/types/lsp/index.d.ts +7 -128
- package/dist/types/lsp/servers.d.ts +72 -0
- package/dist/types/lsp/tool.d.ts +42 -0
- package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
- package/dist/types/lsp/writethrough.d.ts +33 -0
- package/dist/types/mcp/transports/header-policy.d.ts +46 -0
- package/dist/types/mcp/types.d.ts +15 -0
- package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
- package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
- package/dist/types/modes/components/agent-hub.d.ts +2 -0
- package/dist/types/modes/components/agent-transcript-viewer.d.ts +2 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -2
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/theme/color.d.ts +19 -0
- package/dist/types/modes/theme/loader.d.ts +19 -0
- package/dist/types/modes/theme/schema.d.ts +175 -0
- package/dist/types/modes/theme/symbols.d.ts +28 -0
- package/dist/types/modes/theme/theme-class.d.ts +244 -0
- package/dist/types/modes/theme/theme.d.ts +9 -280
- package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +3 -2
- package/dist/types/registry/agent-registry.d.ts +1 -3
- package/dist/types/secrets/index.d.ts +26 -2
- package/dist/types/secrets/message-transform.d.ts +40 -0
- package/dist/types/secrets/obfuscator.d.ts +0 -108
- package/dist/types/secrets/placeholder-scan.d.ts +95 -0
- package/dist/types/secrets/placeholder.d.ts +94 -0
- package/dist/types/secrets/replacement.d.ts +82 -0
- package/dist/types/session/agent-session-events.d.ts +2 -2
- package/dist/types/session/agent-session-types.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +2 -2
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/prewalk.d.ts +3 -1
- package/dist/types/session/session-handoff.d.ts +3 -3
- package/dist/types/session/session-manager.d.ts +32 -0
- package/dist/types/session/session-provider-boundary.d.ts +1 -1
- package/dist/types/session/session-tools.d.ts +8 -0
- package/dist/types/session/turn-recovery.d.ts +9 -4
- package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
- package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
- package/dist/types/slash-commands/builtin-control.d.ts +2 -0
- package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
- package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
- package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/slash-commands/builtin-session.d.ts +2 -0
- package/dist/types/task/index.d.ts +2 -0
- package/dist/types/tools/gh-common.d.ts +69 -0
- package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
- package/dist/types/tools/gh-pr-diff.d.ts +102 -0
- package/dist/types/tools/gh-run-watch.d.ts +78 -0
- package/dist/types/tools/gh-search.d.ts +61 -0
- package/dist/types/tools/gh-types.d.ts +340 -0
- package/dist/types/tools/gh-view.d.ts +88 -0
- package/dist/types/tools/gh.d.ts +4 -201
- package/dist/types/tools/read-archive.d.ts +16 -0
- package/dist/types/tools/read-format.d.ts +104 -0
- package/dist/types/tools/read-path-resolution.d.ts +19 -0
- package/dist/types/tools/read-pdf-images.d.ts +12 -0
- package/dist/types/tools/read-renderer.d.ts +24 -0
- package/dist/types/tools/read-selector.d.ts +27 -0
- package/dist/types/tools/read-sqlite.d.ts +16 -0
- package/dist/types/tools/read-summary.d.ts +19 -0
- package/dist/types/tools/read.d.ts +1 -23
- package/dist/types/tools/shell-tokenize.d.ts +18 -1
- package/dist/types/utils/changelog.d.ts +0 -5
- package/package.json +13 -13
- package/scripts/legacy-pi-virtual-module.ts +4 -1
- package/src/advisor/runtime.ts +12 -7
- package/src/capability/mcp.ts +11 -0
- package/src/capability/skill.ts +6 -0
- package/src/cli/command-help.ts +4 -0
- package/src/cli/gallery-cli.ts +1 -1
- package/src/cli-commands.ts +5 -0
- package/src/commands/commit.ts +16 -3
- package/src/commands/share.ts +71 -0
- package/src/commit/agentic/index.ts +39 -21
- package/src/commit/execute.ts +56 -0
- package/src/commit/index.ts +2 -1
- package/src/commit/pipeline.ts +20 -7
- package/src/config/custom-models.ts +188 -0
- package/src/config/model-config-values.ts +128 -0
- package/src/config/model-discovery.ts +1 -1
- package/src/config/model-patch.ts +252 -0
- package/src/config/model-provider-discovery.ts +132 -0
- package/src/config/model-registry.ts +84 -704
- package/src/config/settings-schema.ts +2 -33
- package/src/config/settings.ts +53 -3
- package/src/debug/raw-sse-buffer.ts +20 -0
- package/src/discovery/agent-plugin-format.ts +551 -0
- package/src/discovery/agent-plugins.ts +341 -0
- package/src/discovery/claude-plugins.ts +21 -5
- package/src/discovery/contained-path.ts +75 -0
- package/src/discovery/helpers.ts +23 -9
- package/src/discovery/index.ts +1 -0
- package/src/discovery/omp-plugins.ts +20 -7
- package/src/edit/hashline/diff.ts +5 -1
- package/src/eval/executor-base.ts +39 -6
- package/src/eval/jl/executor.ts +82 -371
- package/src/eval/kernel-session-registry.ts +398 -0
- package/src/eval/py/executor.ts +53 -261
- package/src/eval/rb/executor.ts +36 -279
- package/src/exec/non-interactive-env.ts +1 -0
- package/src/export/share.ts +2 -1
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/loader.ts +78 -14
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +12 -0
- package/src/extensibility/plugins/marketplace/manager.ts +2 -1
- package/src/extensibility/shared-events.ts +3 -2
- package/src/extensibility/skills.ts +9 -0
- package/src/internal-urls/skill-protocol.ts +14 -0
- package/src/internal-urls/vault-protocol.ts +2 -2
- package/src/launch/client.ts +11 -1
- package/src/launch/protocol.ts +7 -2
- package/src/lsp/diagnostics.ts +516 -0
- package/src/lsp/index.ts +20 -2819
- package/src/lsp/servers.ts +296 -0
- package/src/lsp/tool.ts +1352 -0
- package/src/lsp/workspace-diagnostics.ts +170 -0
- package/src/lsp/writethrough.ts +561 -0
- package/src/main.ts +3 -2
- package/src/mcp/config.ts +3 -0
- package/src/mcp/manager.ts +12 -9
- package/src/mcp/transports/header-policy.ts +95 -0
- package/src/mcp/transports/http.ts +35 -52
- package/src/mcp/transports/sse.ts +20 -27
- package/src/mcp/types.ts +15 -0
- package/src/modes/acp/acp-agent.ts +15 -4
- package/src/modes/components/agent-dashboard.ts +2 -0
- package/src/modes/components/agent-hub-projection.ts +2 -2
- package/src/modes/components/agent-hub-renderer.ts +3 -7
- package/src/modes/components/agent-hub.ts +5 -0
- package/src/modes/components/agent-transcript-viewer.ts +3 -0
- package/src/modes/components/chat-transcript-builder.ts +3 -0
- package/src/modes/components/custom-editor.ts +0 -9
- package/src/modes/components/status-line/component.ts +1 -0
- package/src/modes/components/status-line/segments.ts +6 -4
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/tool-execution.ts +13 -16
- package/src/modes/components/tree-selector.ts +62 -3
- package/src/modes/controllers/command-controller.ts +8 -2
- package/src/modes/controllers/event-controller.ts +15 -15
- package/src/modes/controllers/input-controller.ts +20 -2
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/modes/theme/color.ts +133 -0
- package/src/modes/theme/loader.ts +178 -0
- package/src/modes/theme/schema.ts +263 -0
- package/src/modes/theme/symbols.ts +1000 -0
- package/src/modes/theme/theme-class.ts +611 -0
- package/src/modes/theme/theme.ts +27 -2453
- package/src/modes/theme/tui-adapters.ts +279 -0
- package/src/modes/utils/transcript-render-helpers.ts +4 -2
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/registry/agent-registry.ts +2 -2
- package/src/sdk.ts +6 -49
- package/src/secrets/index.ts +51 -6
- package/src/secrets/message-transform.ts +287 -0
- package/src/secrets/obfuscator.ts +39 -1303
- package/src/secrets/placeholder-scan.ts +506 -0
- package/src/secrets/placeholder.ts +309 -0
- package/src/secrets/replacement.ts +216 -0
- package/src/session/agent-session-events.ts +2 -2
- package/src/session/agent-session-types.ts +6 -0
- package/src/session/agent-session.ts +146 -21
- package/src/session/indexed-session-storage.ts +7 -2
- package/src/session/messages.ts +1 -0
- package/src/session/prewalk.ts +57 -17
- package/src/session/session-advisors.ts +32 -34
- package/src/session/session-context.ts +3 -5
- package/src/session/session-handoff.ts +39 -16
- package/src/session/session-manager.ts +67 -3
- package/src/session/session-provider-boundary.ts +3 -6
- package/src/session/session-tools.ts +37 -3
- package/src/session/todo-tracker.ts +11 -1
- package/src/session/turn-recovery.ts +115 -84
- package/src/slash-commands/builtin-collaboration.ts +504 -0
- package/src/slash-commands/builtin-completions.ts +291 -0
- package/src/slash-commands/builtin-control.ts +78 -0
- package/src/slash-commands/builtin-lifecycle.ts +506 -0
- package/src/slash-commands/builtin-marketplace.ts +567 -0
- package/src/slash-commands/builtin-modes.ts +518 -0
- package/src/slash-commands/builtin-registry.ts +21 -2996
- package/src/slash-commands/builtin-session.ts +592 -0
- package/src/task/executor.ts +17 -14
- package/src/task/index.ts +21 -6
- package/src/tools/bash-skill-urls.ts +15 -0
- package/src/tools/bash.ts +10 -11
- package/src/tools/debug.ts +1 -1
- package/src/tools/gh-common.ts +288 -0
- package/src/tools/gh-pr-checkout.ts +679 -0
- package/src/tools/gh-pr-diff.ts +473 -0
- package/src/tools/gh-run-watch.ts +1015 -0
- package/src/tools/gh-search.ts +500 -0
- package/src/tools/gh-types.ts +379 -0
- package/src/tools/gh-view.ts +612 -0
- package/src/tools/gh.ts +109 -3821
- package/src/tools/jtd-to-json-schema.ts +123 -21
- package/src/tools/read-archive.ts +209 -0
- package/src/tools/read-format.ts +593 -0
- package/src/tools/read-path-resolution.ts +36 -0
- package/src/tools/read-pdf-images.ts +250 -0
- package/src/tools/read-renderer.ts +289 -0
- package/src/tools/read-selector.ts +84 -0
- package/src/tools/read-sqlite.ts +215 -0
- package/src/tools/read-summary.ts +199 -0
- package/src/tools/read.ts +82 -1824
- package/src/tools/shell-tokenize.ts +99 -1
- package/src/utils/changelog.ts +1 -1
- package/src/utils/title-generator.ts +3 -1
- package/src/web/search/providers/exa.ts +1 -1
- package/src/web/search/providers/zai.ts +12 -3
|
@@ -1,8 +1,43 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
buildHashBase,
|
|
3
|
+
buildKeyedReplacementRun,
|
|
4
|
+
buildPlaceholder,
|
|
5
|
+
defaultPlaceholderKey,
|
|
6
|
+
inferCaseHint,
|
|
7
|
+
lookupFriendlyPlaceholderAlias,
|
|
8
|
+
MIN_OBFUSCATE_SECRET_LEN,
|
|
9
|
+
PLACEHOLDER_RE,
|
|
10
|
+
placeholderWithoutFriendlyName,
|
|
11
|
+
resumePlaceholderScanAfterRejectedCandidate,
|
|
12
|
+
sanitizedLabelCollidesWithSecret,
|
|
13
|
+
sanitizeForCollisionCheck,
|
|
14
|
+
sanitizeSecretFriendlyName,
|
|
15
|
+
} from "./placeholder";
|
|
16
|
+
import {
|
|
17
|
+
buildReplaceRegexScan,
|
|
18
|
+
countOutsidePlaceholderRanges,
|
|
19
|
+
deepWalkStrings,
|
|
20
|
+
deobfuscateGeneratedPlaceholderRanges,
|
|
21
|
+
extendPastAdjacentPlaceholders,
|
|
22
|
+
firstOutsidePlaceholderRange,
|
|
23
|
+
mapReplaceRegexMatch,
|
|
24
|
+
outsidePlaceholderRangesAnyIndependentlyMatch,
|
|
25
|
+
placeholderInnerText,
|
|
26
|
+
redactWithFixedReplacementOutsidePlaceholders,
|
|
27
|
+
replaceRange,
|
|
28
|
+
textOutsidePlaceholderRanges,
|
|
29
|
+
trailingOutsidePreservedPlaceholderChunk,
|
|
30
|
+
transformOutsidePlaceholdersTracked,
|
|
31
|
+
} from "./placeholder-scan";
|
|
5
32
|
import { compileSecretRegex } from "./regex";
|
|
33
|
+
import {
|
|
34
|
+
ensureDistinctReplacement,
|
|
35
|
+
findNonMatchingReplacement,
|
|
36
|
+
generateDeterministicReplacement,
|
|
37
|
+
type RegexMatchContext,
|
|
38
|
+
regexHasUnresolvableShortMatchFallback,
|
|
39
|
+
regexRematchesInContext,
|
|
40
|
+
} from "./replacement";
|
|
6
41
|
|
|
7
42
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
43
|
// Types
|
|
@@ -20,525 +55,6 @@ export interface SecretEntry {
|
|
|
20
55
|
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue | undefined };
|
|
21
56
|
export type JsonRecord = { [key: string]: JsonValue | undefined };
|
|
22
57
|
|
|
23
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
|
-
// Deterministic replacement generation
|
|
25
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
-
|
|
27
|
-
const REPLACEMENT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
28
|
-
const NONMATCHING_REPLACEMENT_CHARS = `${REPLACEMENT_CHARS}!#$%&()*+,-./:;<=>?@[]^_{|}~`;
|
|
29
|
-
// Whitespace bytes used to build last-resort redactions for a default replace
|
|
30
|
-
// regex that matches every non-whitespace candidate (e.g. `\S{n}`). Only
|
|
31
|
-
// `space`/`tab` are used — never a line terminator — so a `.`-style
|
|
32
|
-
// match-everything regex (which matches space and tab but not `\n`) still
|
|
33
|
-
// exhausts to the sentinel instead of redacting to a newline run.
|
|
34
|
-
const WHITESPACE_REPLACEMENT_CHARS = " \t";
|
|
35
|
-
|
|
36
|
-
/** Generate a deterministic same-length replacement string from a secret value. */
|
|
37
|
-
function generateDeterministicReplacement(secret: string): string {
|
|
38
|
-
if (secret.length === 0) return "";
|
|
39
|
-
// Prefix generated chunks with a fixed `ZZ` so re-redacting an already-emitted
|
|
40
|
-
// 1–2 char chunk is a fixed point (the deterministic replacement of a <=2-char
|
|
41
|
-
// value is itself `Z`/`ZZ`), keeping short default-replacement remainders next
|
|
42
|
-
// to a reversible placeholder stable across an obfuscator restart.
|
|
43
|
-
const hash = BigInt(Bun.hash(secret));
|
|
44
|
-
const chars = secret.length === 1 ? ["Z"] : ["Z", "Z"];
|
|
45
|
-
let h = hash;
|
|
46
|
-
for (let i = chars.length; i < secret.length; i++) {
|
|
47
|
-
h = h ^ (BigInt(i + 1) * 0x9e3779b97f4a7c15n);
|
|
48
|
-
const idx = Number((h < 0n ? -h : h) % BigInt(REPLACEMENT_CHARS.length));
|
|
49
|
-
chars.push(REPLACEMENT_CHARS[idx]);
|
|
50
|
-
}
|
|
51
|
-
return chars.join("");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Force a length-preserving deterministic replacement to differ from the secret
|
|
56
|
-
* it stands in for. `generateDeterministicReplacement` seeds its first 1–2 chars
|
|
57
|
-
* with the `Z`/`ZZ` sentinel, so a whole configured value that is exactly `Z` or
|
|
58
|
-
* `ZZ` (or an astronomically unlikely longer hash collision) would otherwise be
|
|
59
|
-
* emitted unchanged and ship the raw secret to the provider. Flip the first char
|
|
60
|
-
* to a fixed different glyph: same length, still deterministic, guaranteed != the
|
|
61
|
-
* secret. Only safe for a whole CONFIGURED value (a plain secret matches its own
|
|
62
|
-
* literal, so the perturbed output is no longer matched and stays a fixed point);
|
|
63
|
-
* per-chunk remainders must keep the sentinel to remain idempotent across restart.
|
|
64
|
-
*/
|
|
65
|
-
function ensureDistinctReplacement(replacement: string, secret: string): string {
|
|
66
|
-
if (replacement.length === 0 || replacement !== secret) return replacement;
|
|
67
|
-
const alt = replacement[0] === REPLACEMENT_CHARS[0] ? REPLACEMENT_CHARS[1] : REPLACEMENT_CHARS[0];
|
|
68
|
-
return alt + replacement.slice(1);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// How far left of the matched span the re-match scan begins looking for a match
|
|
72
|
-
// that overlaps the candidate. This bounds ONLY the match-start search position,
|
|
73
|
-
// never the lookbehind/lookahead context: the probe below substitutes the
|
|
74
|
-
// candidate into the FULL text, so a regex's lookbehind/lookahead assertions
|
|
75
|
-
// always evaluate against complete context regardless of width. The single
|
|
76
|
-
// re-match this misses is one that begins more than this many bytes before the
|
|
77
|
-
// span and extends into it (a single match longer than the window) — that only
|
|
78
|
-
// churns the chosen redaction marker between candidates, never back to the raw
|
|
79
|
-
// matched value, so it cannot leak a secret.
|
|
80
|
-
const REGEX_REMATCH_BACKSCAN = 512;
|
|
81
|
-
|
|
82
|
-
interface RegexMatchContext {
|
|
83
|
-
/** Full text the match was found in (positions are offsets into it). */
|
|
84
|
-
text: string;
|
|
85
|
-
/** Start/end of the matched span being replaced. */
|
|
86
|
-
start: number;
|
|
87
|
-
end: number;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Whether `candidate`, substituted for the matched span in its surrounding text,
|
|
92
|
-
* is re-matched by `regex` at its own position. A replace-mode regex that depends
|
|
93
|
-
* on context (lookbehind/lookahead/`\b`) can match a candidate that does NOT match
|
|
94
|
-
* in isolation: e.g. `(?<=api=)[AZ]` never matches a bare `A`, but `api=A` does, so
|
|
95
|
-
* a candidate `A` chosen by an isolation test is re-redacted on the next obfuscate()
|
|
96
|
-
* pass and can oscillate back to the raw matched value. The probe substitutes the
|
|
97
|
-
* candidate into the FULL text — not a truncated window — so a wide lookbehind or
|
|
98
|
-
* lookahead (e.g. `(?<=A{600})`) still evaluates against the context that makes it
|
|
99
|
-
* match. Truncating that context dropped the assertion's reach and falsely
|
|
100
|
-
* accepted an oscillating, leaky candidate. The scan starts a bounded distance
|
|
101
|
-
* left of the span and stops once a match begins at/after the span's end (matches
|
|
102
|
-
* arrive in order), keeping per-candidate cost independent of total text length.
|
|
103
|
-
*/
|
|
104
|
-
function regexRematchesInContext(candidate: string, regex: RegExp, ctx: RegexMatchContext): boolean {
|
|
105
|
-
const probe = ctx.text.slice(0, ctx.start) + candidate + ctx.text.slice(ctx.end);
|
|
106
|
-
const spanStart = ctx.start;
|
|
107
|
-
const spanEnd = spanStart + candidate.length;
|
|
108
|
-
regex.lastIndex = Math.max(0, spanStart - REGEX_REMATCH_BACKSCAN);
|
|
109
|
-
for (let m = regex.exec(probe); m !== null; m = regex.exec(probe)) {
|
|
110
|
-
const matchStart = m.index;
|
|
111
|
-
const matchEnd = m.index + m[0].length;
|
|
112
|
-
// Matches arrive in increasing position; once one starts at or past the
|
|
113
|
-
// span's end it cannot cover the candidate, and neither can any later one.
|
|
114
|
-
if (matchStart >= spanEnd) break;
|
|
115
|
-
// A match overlapping the candidate's own bytes means those bytes get
|
|
116
|
-
// re-redacted on a later pass — not a fixed point.
|
|
117
|
-
if (matchEnd > spanStart) return true;
|
|
118
|
-
// Zero-width matches do not advance lastIndex; step past to avoid a loop.
|
|
119
|
-
if (m[0].length === 0) regex.lastIndex++;
|
|
120
|
-
}
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Search same-length replacements for one the regex does NOT match, so a default
|
|
126
|
-
* regex secret whose deterministic replacement collides with its own value (the
|
|
127
|
-
* `Z`/`ZZ` sentinel, or an astronomical hash collision) is still redacted to a
|
|
128
|
-
* STABLE nonmatching value instead of shipping the raw secret. A nonmatching
|
|
129
|
-
* candidate is a fixed point under re-obfuscation — the regex never re-matches it,
|
|
130
|
-
* so it cannot re-leak on a later pass. The search stays bounded to O(length *
|
|
131
|
-
* alphabet) regardless of value length: first exhaust every single-position
|
|
132
|
-
* substitution against a deterministic baseline (`AAAA…`, then `!AAA…`, `A!AA…`,
|
|
133
|
-
* …) so any regex that only needs one out-of-class byte — regardless of position —
|
|
134
|
-
* is found in a handful of probes rather than enumerating every combination (which
|
|
135
|
-
* for a 3-byte match-everything config, e.g. `[\s\S]{3}`, would otherwise run
|
|
136
|
-
* 90**3 = 729000 candidates through the regex on every single match, stalling
|
|
137
|
-
* provider requests). Candidates are enumerated deterministically over a stable
|
|
138
|
-
* ASCII alphabet: alphanumerics first (usually enough), then punctuation fallback
|
|
139
|
-
* bytes when the regex covers every alphanumeric candidate. When the regex still
|
|
140
|
-
* matches around a lone perturbed byte (for example `[A-Za-z0-9].*` matching the
|
|
141
|
-
* unperturbed tail), full-width same-byte candidates (`!!!!!`, `_____`, …) are
|
|
142
|
-
* tried next. When the regex covers every non-whitespace candidate (e.g. `\S{n}`),
|
|
143
|
-
* whitespace markers (a full space/tab run, then a single whitespace byte among
|
|
144
|
-
* non-whitespace filler) are tried as a last resort. A genuine match-everything
|
|
145
|
-
* regex (`.`/`[\s\S]`, which also matches space and tab) still exhausts this bounded
|
|
146
|
-
* sweep and returns undefined, letting the caller keep its own fixed-point fallback
|
|
147
|
-
* — bounded search can in principle miss an escape that depends jointly on
|
|
148
|
-
* multiple positions in a way no single-position swap reaches, but no realistic
|
|
149
|
-
* secret-redaction regex (character classes, literal matches, anchored/bounded
|
|
150
|
-
* repeats) has that shape.
|
|
151
|
-
*/
|
|
152
|
-
function findNonMatchingReplacement(value: string, regex: RegExp, context: RegexMatchContext): string | undefined {
|
|
153
|
-
const len = value.length;
|
|
154
|
-
if (len === 0) return undefined;
|
|
155
|
-
// Exhaust every single-position substitution against the deterministic baseline
|
|
156
|
-
// first (covers the common case cheaply), then fall back to full-width same-byte
|
|
157
|
-
// candidates for a regex that only rejects a lone perturbed byte in context.
|
|
158
|
-
const baseline = NONMATCHING_REPLACEMENT_CHARS[0].repeat(len);
|
|
159
|
-
for (let position = 0; position < len; position++) {
|
|
160
|
-
for (const ch of NONMATCHING_REPLACEMENT_CHARS) {
|
|
161
|
-
const candidate = `${baseline.slice(0, position)}${ch}${baseline.slice(position + 1)}`;
|
|
162
|
-
if (candidate === value) continue;
|
|
163
|
-
if (!regexRematchesInContext(candidate, regex, context)) return candidate;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// If the regex can still match around a lone punctuation byte (for example
|
|
167
|
-
// `[A-Za-z0-9].*` matching the `AAAA` tail of `!AAAA`), try full-width
|
|
168
|
-
// same-byte fallbacks like `!!!!!`, `_____`, etc. before giving up.
|
|
169
|
-
for (const ch of NONMATCHING_REPLACEMENT_CHARS) {
|
|
170
|
-
const candidate = ch.repeat(len);
|
|
171
|
-
if (candidate === value) continue;
|
|
172
|
-
if (!regexRematchesInContext(candidate, regex, context)) return candidate;
|
|
173
|
-
}
|
|
174
|
-
return findWhitespaceFallbackReplacement(value, regex, context);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Last-resort fallback for a default replace regex that matches every
|
|
179
|
-
* non-whitespace candidate. Builds same-length whitespace markers the regex
|
|
180
|
-
* cannot match: first a full space/tab run (handles `\S`-class patterns), then a
|
|
181
|
-
* single whitespace byte among non-whitespace filler (` AAAA`, `A AAA`, …). The
|
|
182
|
-
* mixed marker defeats regexes that ALSO match all-space/all-tab runs, e.g.
|
|
183
|
-
* `(?:\S{n}| {n}|\t{n})`, because the lone whitespace byte breaks every
|
|
184
|
-
* fixed-length run. A genuine match-everything regex (`.`/`[\s\S]`) matches the
|
|
185
|
-
* filler and the whitespace alike, so this still returns undefined there, keeping
|
|
186
|
-
* the caller's sentinel as the sole fixed point.
|
|
187
|
-
*/
|
|
188
|
-
function findWhitespaceFallbackReplacement(
|
|
189
|
-
value: string,
|
|
190
|
-
regex: RegExp,
|
|
191
|
-
context: RegexMatchContext,
|
|
192
|
-
): string | undefined {
|
|
193
|
-
const len = value.length;
|
|
194
|
-
const filler = NONMATCHING_REPLACEMENT_CHARS[0];
|
|
195
|
-
for (const ws of WHITESPACE_REPLACEMENT_CHARS) {
|
|
196
|
-
const full = ws.repeat(len);
|
|
197
|
-
if (full !== value) {
|
|
198
|
-
if (!regexRematchesInContext(full, regex, context)) return full;
|
|
199
|
-
}
|
|
200
|
-
for (let pos = 0; pos < len; pos++) {
|
|
201
|
-
const candidate = `${filler.repeat(pos)}${ws}${filler.repeat(len - pos - 1)}`;
|
|
202
|
-
if (candidate === value) continue;
|
|
203
|
-
if (!regexRematchesInContext(candidate, regex, context)) return candidate;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return undefined;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Whether a default (no custom `replacement`) replace-mode regex can never
|
|
211
|
-
* safely redact a 1-2 char match: `findNonMatchingReplacement`'s bounded
|
|
212
|
-
* search — the same search `#generateRegexReplacement` runs at match time —
|
|
213
|
-
* finds no candidate the regex fails to re-match. This holds independent of
|
|
214
|
-
* any actual per-install key: the search already exhausts every character in
|
|
215
|
-
* `REPLACEMENT_CHARS` (the alphabet `buildKeyedReplacementRun` draws its
|
|
216
|
-
* fallback marker from) plus punctuation and whitespace, so if none of those
|
|
217
|
-
* escape the regex, no key-derived marker drawn from the same alphabet can
|
|
218
|
-
* either — the marker is guaranteed to re-match too, making every such match
|
|
219
|
-
* unresolvable: the fallback could only ever emit the raw matched text
|
|
220
|
-
* unchanged. Probed with a value (`"\0".repeat(length)`) the bounded search
|
|
221
|
-
* never treats as a real candidate, so the result depends only on the
|
|
222
|
-
* regex's own matching behavior, not on this specific probe.
|
|
223
|
-
*/
|
|
224
|
-
export function regexHasUnresolvableShortMatchFallback(regex: RegExp): boolean {
|
|
225
|
-
return ([1, 2] as const).some(length => {
|
|
226
|
-
const probe = "\u0000".repeat(length);
|
|
227
|
-
const savedLastIndex = regex.lastIndex;
|
|
228
|
-
try {
|
|
229
|
-
return findNonMatchingReplacement(probe, regex, { text: probe, start: 0, end: length }) === undefined;
|
|
230
|
-
} finally {
|
|
231
|
-
regex.lastIndex = savedLastIndex;
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
237
|
-
// Placeholder format
|
|
238
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
239
|
-
|
|
240
|
-
const HASH_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
241
|
-
// Base length is sized for ~62 bits of entropy (64 bits of a keyed digest
|
|
242
|
-
// rendered as 12 base36 chars) so unrelated secrets do not collide on a shared
|
|
243
|
-
// base. A collision would let a persisted placeholder deobfuscate to the wrong
|
|
244
|
-
// secret when the configured secret set or its ordering changes across sessions.
|
|
245
|
-
const HASH_LEN = 12;
|
|
246
|
-
const MAX_FRIENDLY_NAME_LEN = 32;
|
|
247
|
-
// Plain/regex obfuscate matches shorter than this are toned down (never placed
|
|
248
|
-
// behind a reversible placeholder) to avoid redacting small words/fragments.
|
|
249
|
-
export const MIN_OBFUSCATE_SECRET_LEN = 8;
|
|
250
|
-
|
|
251
|
-
// Per-process fallback key used when a caller does not supply a persisted
|
|
252
|
-
// per-install key. It is random (never shipped in source), so model-visible
|
|
253
|
-
// placeholders cannot be reversed by dictionary-hashing candidate secrets; it
|
|
254
|
-
// only forgoes cross-session token stability, which the persisted key provides.
|
|
255
|
-
let ephemeralPlaceholderKey: string | undefined;
|
|
256
|
-
function defaultPlaceholderKey(): string {
|
|
257
|
-
ephemeralPlaceholderKey ??= crypto.randomBytes(32).toString("base64url");
|
|
258
|
-
return ephemeralPlaceholderKey;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
type PlaceholderCaseHint = "U" | "L" | "C" | "M";
|
|
262
|
-
|
|
263
|
-
/** Normalize a friendly name into the model-visible placeholder prefix. */
|
|
264
|
-
export function sanitizeSecretFriendlyName(name: string): string | undefined {
|
|
265
|
-
const sanitized = name
|
|
266
|
-
.replace(/[^A-Za-z0-9]/g, "")
|
|
267
|
-
.toUpperCase()
|
|
268
|
-
.slice(0, MAX_FRIENDLY_NAME_LEN);
|
|
269
|
-
return sanitized.length > 0 ? sanitized : undefined;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Normalize a secret value into the same alnum-only, uppercased shape a
|
|
274
|
-
* friendly-name label or placeholder prefix is sanitized into, so comparing a
|
|
275
|
-
* raw (possibly lowercase/punctuated) secret value against already-sanitized,
|
|
276
|
-
* model-visible text does not miss a case- or separator-only variant. Unlike
|
|
277
|
-
* `sanitizeSecretFriendlyName` this never truncates and never signals "empty"
|
|
278
|
-
* via `undefined` — callers already guard on `.length > 0` before comparing.
|
|
279
|
-
*/
|
|
280
|
-
function sanitizeForCollisionCheck(value: string): string {
|
|
281
|
-
return value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// A label leaks a secret either by containing the whole normalized secret or,
|
|
285
|
-
// once it reaches the public display cap, by being the secret's visible prefix.
|
|
286
|
-
// Shorter names like "TOKEN" can still be intentional generic labels.
|
|
287
|
-
function sanitizedLabelCollidesWithSecret(sanitizedLabel: string, sanitizedSecret: string): boolean {
|
|
288
|
-
if (sanitizedSecret.length === 0) return false;
|
|
289
|
-
if (sanitizedLabel.includes(sanitizedSecret)) return true;
|
|
290
|
-
return sanitizedLabel.length >= MAX_FRIENDLY_NAME_LEN && sanitizedSecret.startsWith(sanitizedLabel);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Whether an entry needs the persisted placeholder key: either because it can
|
|
295
|
-
* produce a reversible (keyed) obfuscate-mode placeholder, or because a default
|
|
296
|
-
* (no custom `replacement`) replace-mode regex can reach
|
|
297
|
-
* `#generateRegexReplacement`'s key-derived idempotent fallback marker (see
|
|
298
|
-
* `#generateReplacement`) when every same-length candidate re-matches a
|
|
299
|
-
* pathological match-everything config (e.g. `[\s\S]{8}`). That fallback depends
|
|
300
|
-
* on the persisted per-install key — not just length — to stay a fixed point
|
|
301
|
-
* across a process restart; without a persisted key, a fresh install falls back
|
|
302
|
-
* to a process-random key (`defaultPlaceholderKey()`), so the fallback marker
|
|
303
|
-
* would churn across restarts even though the algorithm itself is stable. A
|
|
304
|
-
* regex WITH a custom `replacement` never reaches that fallback (it always emits
|
|
305
|
-
* the literal configured string), and a plain replace secret's replacement is
|
|
306
|
-
* pure content-hash (`#generateSecretReplacement`), so neither needs the key.
|
|
307
|
-
* Short plain obfuscate entries are toned down (never placeheld), so they must
|
|
308
|
-
* NOT force key creation: otherwise a `secret-placeholder.key` file is written
|
|
309
|
-
* and persisted for a config that ends up with no active secrets, leaving the
|
|
310
|
-
* key readable via a tool and reusable for later placeholders.
|
|
311
|
-
*/
|
|
312
|
-
export function secretEntryNeedsPlaceholderKey(entry: SecretEntry): boolean {
|
|
313
|
-
if ((entry.mode ?? "obfuscate") === "obfuscate") {
|
|
314
|
-
if (entry.type === "regex") return true;
|
|
315
|
-
return entry.content.length >= MIN_OBFUSCATE_SECRET_LEN;
|
|
316
|
-
}
|
|
317
|
-
return entry.type === "regex" && entry.replacement === undefined;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Whether a plain replace-mode replacement string can contribute a fragment that
|
|
322
|
-
* helps the replace phase reconstruct an obfuscate `content`. During obfuscate()'s
|
|
323
|
-
* replace phase the output is a tiling of passthrough bytes (adversary-controlled
|
|
324
|
-
* provider text) and whole replacement outputs; any contiguous occurrence of
|
|
325
|
-
* `content` in that output is covered by interior replacement tiles (each a
|
|
326
|
-
* substring of `content`) bordered by passthrough at the ends, where the border
|
|
327
|
-
* tile may be a suffix of a replacement (forming `content`'s prefix) or a prefix
|
|
328
|
-
* of a replacement (forming `content`'s suffix). An EMPTY replacement deletes its
|
|
329
|
-
* trigger entirely, joining the passthrough on both sides; with adversary-chosen
|
|
330
|
-
* surrounding bytes that can form any non-empty `content` across the deleted gap.
|
|
331
|
-
* So a replacement can help iff it is empty, is a substring of `content`,
|
|
332
|
-
* contains `content`, or shares such a border overlap.
|
|
333
|
-
*/
|
|
334
|
-
function replacementCanFormContent(replacement: string, content: string): boolean {
|
|
335
|
-
if (replacement.length === 0) return content.length > 0;
|
|
336
|
-
if (content.includes(replacement) || replacement.includes(content)) return true;
|
|
337
|
-
const maxOverlap = Math.min(replacement.length, content.length);
|
|
338
|
-
for (let k = 1; k <= maxOverlap; k++) {
|
|
339
|
-
// A suffix of the replacement forms the prefix of the content (left border),
|
|
340
|
-
// or a prefix of the replacement forms the suffix of the content (right border).
|
|
341
|
-
if (content.startsWith(replacement.slice(replacement.length - k)) || content.endsWith(replacement.slice(0, k))) {
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return false;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Whether a SET of entries needs the persisted placeholder key. `obfuscate()`
|
|
350
|
-
* applies plain replace-mode mappings before the plain-obfuscate pass, so a plain
|
|
351
|
-
* obfuscate entry only emits a reversible (keyed) placeholder when its content can
|
|
352
|
-
* still appear AFTER the replace phase. When no obfuscate entry can ever produce a
|
|
353
|
-
* placeholder, the persisted key must NOT be required/created — otherwise an
|
|
354
|
-
* effectively replace-only secret set still writes `secret-placeholder.key` and
|
|
355
|
-
* fails startup when the agent config dir is unwritable.
|
|
356
|
-
*
|
|
357
|
-
* The decision models the replace phase as the obfuscator actually runs it:
|
|
358
|
-
* replace mappings are content-keyed (later duplicate wins) and applied in
|
|
359
|
-
* descending content-length order; for a fresh probe (no prior placeholders) that
|
|
360
|
-
* phase is plain sequential substring replacement. A plain obfuscate entry needs
|
|
361
|
-
* the key when its content survives that simulated phase (direct typing) OR when
|
|
362
|
-
* any effective replacement can form the content via tiling — a substring,
|
|
363
|
-
* wholesale superstring, or prefix/suffix border that joins with surrounding
|
|
364
|
-
* passthrough bytes (see `replacementCanFormContent`). This covers direct
|
|
365
|
-
* shadowing (`SECRET -> safe`), reintroduction, duplicate ordering, transitive
|
|
366
|
-
* chains, and context-joined fragments uniformly. Default (omitted) replacements
|
|
367
|
-
* are deterministic, length-preserving, and distinct, so a same-content shadow
|
|
368
|
-
* with no other interacting replacement stays key-free.
|
|
369
|
-
* Replacement outputs are themselves rewritten by every later (shorter-content)
|
|
370
|
-
* replacement before the plain-obfuscate pass sees them, so a fragment that a
|
|
371
|
-
* subsequent replacement erases (`AA -> SEC` then `S -> X` turns every `SEC` into
|
|
372
|
-
* `XEC`) no longer forces the key. Surrounding bytes stay modeled as arbitrary
|
|
373
|
-
* passthrough, so testing the surviving fragment only drops false positives and
|
|
374
|
-
* never under-approximates a real key need.
|
|
375
|
-
*/
|
|
376
|
-
export function secretEntriesNeedPlaceholderKey(entries: SecretEntry[]): boolean {
|
|
377
|
-
const replaceMap = new Map<string, string>();
|
|
378
|
-
for (const entry of entries) {
|
|
379
|
-
if (entry.type !== "plain" || (entry.mode ?? "obfuscate") !== "replace") continue;
|
|
380
|
-
replaceMap.set(
|
|
381
|
-
entry.content,
|
|
382
|
-
entry.replacement ?? ensureDistinctReplacement(generateDeterministicReplacement(entry.content), entry.content),
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
const replacePhase = [...replaceMap].sort((a, b) => b[0].length - a[0].length);
|
|
386
|
-
// Apply the replace phase from `start` onward. The phase runs in descending
|
|
387
|
-
// content-length order, so a replacement output emitted at index i is rewritten
|
|
388
|
-
// only by the later (shorter-content) replacements at i+1…; `start` 0 models a
|
|
389
|
-
// value typed directly into the input.
|
|
390
|
-
const applyReplacePhaseFrom = (text: string, start: number): string => {
|
|
391
|
-
let result = text;
|
|
392
|
-
for (let i = start; i < replacePhase.length; i++) {
|
|
393
|
-
result = result.split(replacePhase[i][0]).join(replacePhase[i][1]);
|
|
394
|
-
}
|
|
395
|
-
return result;
|
|
396
|
-
};
|
|
397
|
-
return entries.some(entry => {
|
|
398
|
-
if (!secretEntryNeedsPlaceholderKey(entry)) return false;
|
|
399
|
-
// Regex obfuscate entries match dynamically; conservatively require the key.
|
|
400
|
-
if (entry.type !== "plain") return true;
|
|
401
|
-
const content = entry.content;
|
|
402
|
-
if (applyReplacePhaseFrom(content, 0).includes(content)) return true;
|
|
403
|
-
// Test each replacement output in the form it SURVIVES the rest of the phase,
|
|
404
|
-
// so a fragment a later replacement erases no longer forces the key. The
|
|
405
|
-
// content it tiles into must also survive those later replacements: if a
|
|
406
|
-
// shorter-content replacement rewrites the surrounding passthrough bytes
|
|
407
|
-
// (e.g. `AA -> SEC` forms `SEC`+`RET12`, then `R -> X` turns the freshly
|
|
408
|
-
// formed `SECRET12` into `SECXET12`), the content can never reach the
|
|
409
|
-
// obfuscate pass, so the key is not needed. Requiring content stability only
|
|
410
|
-
// drops such false positives — a formation that genuinely survives is still
|
|
411
|
-
// caught at the replacement index that produces it.
|
|
412
|
-
return replacePhase.some(
|
|
413
|
-
([, replacement], i) =>
|
|
414
|
-
applyReplacePhaseFrom(content, i + 1) === content &&
|
|
415
|
-
replacementCanFormContent(applyReplacePhaseFrom(replacement, i + 1), content),
|
|
416
|
-
);
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Derive the model-visible base from a KEYED digest of the secret. xxHash is
|
|
421
|
-
// fast and unkeyed, so a fixed-seed content hash of a low-entropy secret could
|
|
422
|
-
// be dictionaried from the transcript; HMAC-SHA256 under a private per-install
|
|
423
|
-
// key cannot, since the attacker lacks the key.
|
|
424
|
-
function buildHashBase(key: string, value: string): string {
|
|
425
|
-
const digest = new Bun.CryptoHasher("sha256", key).update(value).digest();
|
|
426
|
-
let v = 0n;
|
|
427
|
-
for (let i = 0; i < 8; i++) v = (v << 8n) | BigInt(digest[i]);
|
|
428
|
-
const radix = BigInt(HASH_CHARS.length);
|
|
429
|
-
let tag = "";
|
|
430
|
-
for (let i = 0; i < HASH_LEN; i++) {
|
|
431
|
-
tag += HASH_CHARS[Number(v % radix)];
|
|
432
|
-
v /= radix;
|
|
433
|
-
}
|
|
434
|
-
return tag;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Build a deterministic, key-derived run of REPLACEMENT_CHARS of the given
|
|
438
|
-
// length. Used to redact a per-chunk replace remainder to a marker that depends
|
|
439
|
-
// only on the per-install key and the remainder length, so a fresh obfuscator
|
|
440
|
-
// reproduces the identical marker (idempotent redaction across restarts) while
|
|
441
|
-
// the run stays unpredictable without the key (raw sentinel-shaped bytes cannot
|
|
442
|
-
// equal the marker, so they are still redacted rather than passed through).
|
|
443
|
-
function buildKeyedReplacementRun(key: string, length: number): string {
|
|
444
|
-
if (length <= 0) return "";
|
|
445
|
-
const radix = REPLACEMENT_CHARS.length;
|
|
446
|
-
let out = "";
|
|
447
|
-
for (let block = 0; out.length < length; block++) {
|
|
448
|
-
const digest = new Bun.CryptoHasher("sha256", key).update(`replace-chunk\0${length}\0${block}`).digest();
|
|
449
|
-
for (let i = 0; i < digest.length && out.length < length; i++) {
|
|
450
|
-
out += REPLACEMENT_CHARS[digest[i] % radix];
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
return out;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function inferCaseHint(secret: string): PlaceholderCaseHint | undefined {
|
|
457
|
-
let hasCased = false;
|
|
458
|
-
let hasUpper = false;
|
|
459
|
-
let hasLower = false;
|
|
460
|
-
let capitalized = true;
|
|
461
|
-
let seenFirstCased = false;
|
|
462
|
-
|
|
463
|
-
for (let i = 0; i < secret.length; i++) {
|
|
464
|
-
const code = secret.charCodeAt(i);
|
|
465
|
-
const isUpper = code >= 65 && code <= 90;
|
|
466
|
-
const isLower = code >= 97 && code <= 122;
|
|
467
|
-
if (!isUpper && !isLower) continue;
|
|
468
|
-
|
|
469
|
-
hasCased = true;
|
|
470
|
-
if (isUpper) {
|
|
471
|
-
hasUpper = true;
|
|
472
|
-
if (seenFirstCased) capitalized = false;
|
|
473
|
-
} else {
|
|
474
|
-
hasLower = true;
|
|
475
|
-
if (!seenFirstCased) capitalized = false;
|
|
476
|
-
}
|
|
477
|
-
seenFirstCased = true;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (!hasCased) return undefined;
|
|
481
|
-
if (hasUpper && !hasLower) return "U";
|
|
482
|
-
if (hasLower && !hasUpper) return "L";
|
|
483
|
-
if (capitalized) return "C";
|
|
484
|
-
return "M";
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function buildPlaceholder(hint: PlaceholderCaseHint | undefined, base: string, friendlyName?: string): string {
|
|
488
|
-
const prefix = friendlyName ? `${friendlyName}_` : "";
|
|
489
|
-
return hint ? `$$${prefix}${base}:${hint}$$` : `$$${prefix}${base}$$`;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** Regex matching `$$HASH$$`, `$$HASH:U$$`, and `$$FRIENDLY_HASH(:hint)$$` placeholders. */
|
|
493
|
-
const PLACEHOLDER_RE = /\$\$(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?\$\$/g;
|
|
494
|
-
|
|
495
|
-
function resumePlaceholderScanAfterRejectedCandidate(match: RegExpExecArray): void {
|
|
496
|
-
// RegExp#exec does not find overlapping matches. Restart at the rejected
|
|
497
|
-
// candidate's closing delimiter, which can open an immediately adjacent placeholder.
|
|
498
|
-
PLACEHOLDER_RE.lastIndex = match.index + match[0].length - 2;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function placeholderWithoutFriendlyName(placeholder: string): string | undefined {
|
|
502
|
-
const match = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder);
|
|
503
|
-
return match ? `$$${match[1]}$$` : undefined;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function lookupFriendlyPlaceholderAlias(
|
|
507
|
-
deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
|
|
508
|
-
placeholder: string,
|
|
509
|
-
): { secret: string; recursive: boolean } | undefined {
|
|
510
|
-
const direct = deobfuscateMap.get(placeholder);
|
|
511
|
-
if (direct !== undefined) return direct;
|
|
512
|
-
const unprefixed = placeholderWithoutFriendlyName(placeholder);
|
|
513
|
-
return unprefixed !== undefined ? deobfuscateMap.get(unprefixed) : undefined;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const PENDING_PLACEHOLDER_SUFFIX_RE = /(?:\$\$(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?|\$)$/;
|
|
517
|
-
|
|
518
|
-
// Withhold a trailing run that could be the start of a placeholder from streamed
|
|
519
|
-
// deltas, so a partial token is never emitted before deobfuscation can replace
|
|
520
|
-
// it. A lone trailing delimiter character is always buffered because it can open
|
|
521
|
-
// a placeholder; the final non-streamed flush re-emits it when no token follows.
|
|
522
|
-
export function stripPendingSecretPlaceholderSuffix(text: string): string {
|
|
523
|
-
const pendingPlaceholderStart = text.match(PENDING_PLACEHOLDER_SUFFIX_RE);
|
|
524
|
-
if (pendingPlaceholderStart?.index === undefined) return text;
|
|
525
|
-
return text.slice(0, pendingPlaceholderStart.index);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
interface RegexScanSegment {
|
|
529
|
-
scanStart: number;
|
|
530
|
-
scanEnd: number;
|
|
531
|
-
textStart: number;
|
|
532
|
-
textEnd: number;
|
|
533
|
-
generatedPlaceholder: boolean;
|
|
534
|
-
recursive: boolean;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
interface ReplaceRegexScan {
|
|
538
|
-
text: string;
|
|
539
|
-
segments: RegexScanSegment[];
|
|
540
|
-
}
|
|
541
|
-
|
|
542
58
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
543
59
|
// SecretObfuscator
|
|
544
60
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1847,783 +1363,3 @@ export class SecretObfuscator {
|
|
|
1847
1363
|
return matches.reverse();
|
|
1848
1364
|
}
|
|
1849
1365
|
}
|
|
1850
|
-
|
|
1851
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
1852
|
-
// Display restore (inbound, persisted/provider → local display)
|
|
1853
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
1854
|
-
|
|
1855
|
-
/**
|
|
1856
|
-
* Restore secret placeholders for local display. Only message kinds the model
|
|
1857
|
-
* itself authored from obfuscated context carry placeholders — assistant
|
|
1858
|
-
* content and the LLM-written branch/compaction summaries. User, developer, and
|
|
1859
|
-
* tool-result messages are persisted with their literal text, so operator-authored
|
|
1860
|
-
* placeholder-shaped text must survive untouched; those roles are never walked.
|
|
1861
|
-
*/
|
|
1862
|
-
export function deobfuscateSessionContext(
|
|
1863
|
-
sessionContext: SessionContext,
|
|
1864
|
-
obfuscator: SecretObfuscator | undefined,
|
|
1865
|
-
): SessionContext {
|
|
1866
|
-
if (!obfuscator?.hasSecrets()) return sessionContext;
|
|
1867
|
-
const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages);
|
|
1868
|
-
return messages === sessionContext.messages ? sessionContext : { ...sessionContext, messages };
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] {
|
|
1872
|
-
const deob = (text: string): string => obfuscator.deobfuscate(text);
|
|
1873
|
-
let changed = false;
|
|
1874
|
-
const result = messages.map((message): AgentMessage => {
|
|
1875
|
-
switch (message.role) {
|
|
1876
|
-
case "assistant": {
|
|
1877
|
-
const content = deobfuscateAssistantContent(obfuscator, message.content);
|
|
1878
|
-
if (content === message.content) return message;
|
|
1879
|
-
changed = true;
|
|
1880
|
-
return { ...message, content };
|
|
1881
|
-
}
|
|
1882
|
-
case "branchSummary": {
|
|
1883
|
-
const summary = deob(message.summary);
|
|
1884
|
-
if (summary === message.summary) return message;
|
|
1885
|
-
changed = true;
|
|
1886
|
-
return { ...message, summary };
|
|
1887
|
-
}
|
|
1888
|
-
case "compactionSummary": {
|
|
1889
|
-
const summary = deob(message.summary);
|
|
1890
|
-
const shortSummary = message.shortSummary === undefined ? undefined : deob(message.shortSummary);
|
|
1891
|
-
const blocks = message.blocks === undefined ? undefined : deobfuscateTextBlocks(obfuscator, message.blocks);
|
|
1892
|
-
if (summary === message.summary && shortSummary === message.shortSummary && blocks === message.blocks) {
|
|
1893
|
-
return message;
|
|
1894
|
-
}
|
|
1895
|
-
changed = true;
|
|
1896
|
-
return { ...message, summary, shortSummary, blocks };
|
|
1897
|
-
}
|
|
1898
|
-
default:
|
|
1899
|
-
return message;
|
|
1900
|
-
}
|
|
1901
|
-
});
|
|
1902
|
-
return changed ? result : messages;
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
/**
|
|
1906
|
-
* Restore placeholders in assistant content: visible text and tool-call
|
|
1907
|
-
* arguments/intent/rawBlock. Thinking and signatures are opaque
|
|
1908
|
-
* provider-replay/hidden-reasoning data and pass through byte-identical.
|
|
1909
|
-
*/
|
|
1910
|
-
export function deobfuscateAssistantContent(
|
|
1911
|
-
obfuscator: SecretObfuscator,
|
|
1912
|
-
content: AssistantMessage["content"],
|
|
1913
|
-
): AssistantMessage["content"] {
|
|
1914
|
-
if (!obfuscator.hasSecrets()) return content;
|
|
1915
|
-
const deob = (text: string): string => obfuscator.deobfuscate(text);
|
|
1916
|
-
let changed = false;
|
|
1917
|
-
const result = content.map((block): AssistantMessage["content"][number] => {
|
|
1918
|
-
if (block.type === "text") {
|
|
1919
|
-
const text = deob(block.text);
|
|
1920
|
-
if (text === block.text) return block;
|
|
1921
|
-
changed = true;
|
|
1922
|
-
return { ...block, text };
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
if (block.type === "toolCall") {
|
|
1926
|
-
const args = deobfuscateToolArguments(obfuscator, block.arguments);
|
|
1927
|
-
const intent = block.intent === undefined ? undefined : deob(block.intent);
|
|
1928
|
-
const rawBlock = block.rawBlock === undefined ? undefined : deob(block.rawBlock);
|
|
1929
|
-
if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block;
|
|
1930
|
-
changed = true;
|
|
1931
|
-
return { ...block, arguments: args, intent, rawBlock };
|
|
1932
|
-
}
|
|
1933
|
-
return block;
|
|
1934
|
-
});
|
|
1935
|
-
return changed ? result : content;
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
/**
|
|
1939
|
-
* Restore placeholders inside a tool call's arguments. Arguments are arbitrary
|
|
1940
|
-
* model-authored JSON, so tool-call arguments are the ONLY place a recursive
|
|
1941
|
-
* JSON walk runs.
|
|
1942
|
-
*/
|
|
1943
|
-
export function deobfuscateToolArguments(
|
|
1944
|
-
obfuscator: SecretObfuscator,
|
|
1945
|
-
args: Record<string, unknown>,
|
|
1946
|
-
): Record<string, unknown> {
|
|
1947
|
-
if (!obfuscator.hasSecrets()) return args;
|
|
1948
|
-
return mapJsonStrings(args as JsonValue, s => obfuscator.deobfuscate(s)) as Record<string, unknown>;
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
/** Redact secrets inside a tool call's arguments (same JSON-walk exception as {@link deobfuscateToolArguments}). */
|
|
1952
|
-
export function obfuscateToolArguments(
|
|
1953
|
-
obfuscator: SecretObfuscator,
|
|
1954
|
-
args: Record<string, unknown>,
|
|
1955
|
-
sharedRegexSecretValues?: ReadonlySet<string>,
|
|
1956
|
-
): Record<string, unknown> {
|
|
1957
|
-
if (!obfuscator.hasSecrets()) return args;
|
|
1958
|
-
const regexSecretValues = sharedRegexSecretValues ?? collectJsonRegexSecretValues(obfuscator, args as JsonValue);
|
|
1959
|
-
return mapJsonStrings(args as JsonValue, s => obfuscator.obfuscate(s, regexSecretValues)) as Record<string, unknown>;
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
1963
|
-
// Outbound obfuscation (local → provider)
|
|
1964
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
1965
|
-
|
|
1966
|
-
type UserFacingMessage = Extract<Message, { role: "user" | "developer" | "toolResult" }>;
|
|
1967
|
-
|
|
1968
|
-
/** Obfuscate `text` blocks of a content array; image and other blocks pass through. */
|
|
1969
|
-
function obfuscateTextBlocks(
|
|
1970
|
-
obfuscator: SecretObfuscator,
|
|
1971
|
-
content: (TextContent | ImageContent)[],
|
|
1972
|
-
sharedRegexSecretValues?: ReadonlySet<string>,
|
|
1973
|
-
): (TextContent | ImageContent)[] {
|
|
1974
|
-
let changed = false;
|
|
1975
|
-
const result = content.map((block): TextContent | ImageContent => {
|
|
1976
|
-
if (block.type !== "text") return block;
|
|
1977
|
-
const text = obfuscator.obfuscate(block.text, sharedRegexSecretValues);
|
|
1978
|
-
if (text === block.text) return block;
|
|
1979
|
-
changed = true;
|
|
1980
|
-
return { ...block, text };
|
|
1981
|
-
});
|
|
1982
|
-
return changed ? result : content;
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
/** Restore placeholders in `text` blocks of a content array; image and other blocks pass through. */
|
|
1986
|
-
function deobfuscateTextBlocks(
|
|
1987
|
-
obfuscator: SecretObfuscator,
|
|
1988
|
-
content: (TextContent | ImageContent)[],
|
|
1989
|
-
): (TextContent | ImageContent)[] {
|
|
1990
|
-
let changed = false;
|
|
1991
|
-
const result = content.map((block): TextContent | ImageContent => {
|
|
1992
|
-
if (block.type !== "text") return block;
|
|
1993
|
-
const text = obfuscator.deobfuscate(block.text);
|
|
1994
|
-
if (text === block.text) return block;
|
|
1995
|
-
changed = true;
|
|
1996
|
-
return { ...block, text };
|
|
1997
|
-
});
|
|
1998
|
-
return changed ? result : content;
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
/**
|
|
2002
|
-
* Re-obfuscate assistant content before it returns to a provider after session
|
|
2003
|
-
* restoration, removing friendly prefixes made unsafe by this batch. A changed
|
|
2004
|
-
* thinking block loses its byte-bound replay signature.
|
|
2005
|
-
*/
|
|
2006
|
-
function obfuscateAssistantContentForReplay(
|
|
2007
|
-
obfuscator: SecretObfuscator,
|
|
2008
|
-
content: AssistantMessage["content"],
|
|
2009
|
-
sharedRegexSecretValues: ReadonlySet<string>,
|
|
2010
|
-
): AssistantMessage["content"] {
|
|
2011
|
-
const obfuscate = (text: string): string =>
|
|
2012
|
-
obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(
|
|
2013
|
-
obfuscator.obfuscate(text, sharedRegexSecretValues),
|
|
2014
|
-
sharedRegexSecretValues,
|
|
2015
|
-
);
|
|
2016
|
-
let changed = false;
|
|
2017
|
-
const result = content.map((block): AssistantMessage["content"][number] => {
|
|
2018
|
-
if (block.type === "text") {
|
|
2019
|
-
const text = obfuscate(block.text);
|
|
2020
|
-
if (text === block.text) return block;
|
|
2021
|
-
changed = true;
|
|
2022
|
-
return { ...block, text };
|
|
2023
|
-
}
|
|
2024
|
-
if (block.type === "thinking") {
|
|
2025
|
-
const thinking = obfuscate(block.thinking);
|
|
2026
|
-
if (thinking === block.thinking) return block;
|
|
2027
|
-
changed = true;
|
|
2028
|
-
return { ...block, thinking, thinkingSignature: undefined };
|
|
2029
|
-
}
|
|
2030
|
-
if (block.type === "toolCall") {
|
|
2031
|
-
const args = mapJsonStrings(block.arguments as JsonValue, obfuscate) as Record<string, unknown>;
|
|
2032
|
-
const intent = block.intent === undefined ? undefined : obfuscate(block.intent);
|
|
2033
|
-
const rawBlock = block.rawBlock === undefined ? undefined : obfuscate(block.rawBlock);
|
|
2034
|
-
if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block;
|
|
2035
|
-
changed = true;
|
|
2036
|
-
return { ...block, arguments: args, intent, rawBlock };
|
|
2037
|
-
}
|
|
2038
|
-
return block;
|
|
2039
|
-
});
|
|
2040
|
-
return changed ? result : content;
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
function collectMessageRegexSecretValues(obfuscator: SecretObfuscator, messages: Message[]): Set<string> {
|
|
2044
|
-
const values = new Set<string>();
|
|
2045
|
-
const addText = (text: string | undefined): void => {
|
|
2046
|
-
if (text === undefined) return;
|
|
2047
|
-
for (const value of obfuscator.collectRegexSecretValuesForObfuscation(text)) {
|
|
2048
|
-
values.add(value);
|
|
2049
|
-
}
|
|
2050
|
-
};
|
|
2051
|
-
for (const message of messages) {
|
|
2052
|
-
if (message.role === "assistant") {
|
|
2053
|
-
for (const block of message.content) {
|
|
2054
|
-
if (block.type === "text") addText(block.text);
|
|
2055
|
-
else if (block.type === "thinking") addText(block.thinking);
|
|
2056
|
-
else if (block.type === "toolCall") {
|
|
2057
|
-
for (const value of collectJsonRegexSecretValues(obfuscator, block.arguments as JsonValue)) {
|
|
2058
|
-
values.add(value);
|
|
2059
|
-
}
|
|
2060
|
-
addText(block.intent);
|
|
2061
|
-
addText(block.rawBlock);
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
continue;
|
|
2065
|
-
}
|
|
2066
|
-
if (
|
|
2067
|
-
message.role !== "user" &&
|
|
2068
|
-
message.role !== "toolResult" &&
|
|
2069
|
-
!(message.role === "developer" && message.attribution === "user")
|
|
2070
|
-
) {
|
|
2071
|
-
continue;
|
|
2072
|
-
}
|
|
2073
|
-
const target = message as UserFacingMessage;
|
|
2074
|
-
if (typeof target.content === "string") {
|
|
2075
|
-
addText(target.content);
|
|
2076
|
-
continue;
|
|
2077
|
-
}
|
|
2078
|
-
for (const block of target.content) {
|
|
2079
|
-
if (block.type === "text") addText(block.text);
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
return values;
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
/**
|
|
2086
|
-
* Redact secrets from outbound messages. User messages, tool results, and
|
|
2087
|
-
* user-authored developer messages (e.g. `@file` mentions) are obfuscated.
|
|
2088
|
-
* Assistant replay content is re-obfuscated too, because session restoration
|
|
2089
|
-
* expands keyed placeholders locally before the next provider request. Inline
|
|
2090
|
-
* image bytes are never walked.
|
|
2091
|
-
*/
|
|
2092
|
-
export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] {
|
|
2093
|
-
if (!obfuscator.hasSecrets()) return messages;
|
|
2094
|
-
const sharedRegexSecretValues = collectMessageRegexSecretValues(obfuscator, messages);
|
|
2095
|
-
let changed = false;
|
|
2096
|
-
const result = messages.map((message): Message => {
|
|
2097
|
-
if (
|
|
2098
|
-
message.role !== "user" &&
|
|
2099
|
-
message.role !== "toolResult" &&
|
|
2100
|
-
!(message.role === "developer" && message.attribution === "user")
|
|
2101
|
-
) {
|
|
2102
|
-
if (message.role !== "assistant") return message;
|
|
2103
|
-
const content = obfuscateAssistantContentForReplay(obfuscator, message.content, sharedRegexSecretValues);
|
|
2104
|
-
if (content === message.content) return message;
|
|
2105
|
-
changed = true;
|
|
2106
|
-
return { ...message, content };
|
|
2107
|
-
}
|
|
2108
|
-
const target = message as UserFacingMessage;
|
|
2109
|
-
if (typeof target.content === "string") {
|
|
2110
|
-
const content = obfuscator.obfuscate(target.content, sharedRegexSecretValues);
|
|
2111
|
-
if (content === target.content) return message;
|
|
2112
|
-
changed = true;
|
|
2113
|
-
return { ...target, content } as Message;
|
|
2114
|
-
}
|
|
2115
|
-
const content = obfuscateTextBlocks(obfuscator, target.content, sharedRegexSecretValues);
|
|
2116
|
-
if (content === target.content) return message;
|
|
2117
|
-
changed = true;
|
|
2118
|
-
return { ...target, content } as Message;
|
|
2119
|
-
});
|
|
2120
|
-
return changed ? result : messages;
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
|
-
/**
|
|
2124
|
-
* Redact outbound provider context. Only conversation messages are rewritten;
|
|
2125
|
-
* the static system prompt and tool schemas pass through unchanged.
|
|
2126
|
-
*/
|
|
2127
|
-
export function obfuscateProviderContext(obfuscator: SecretObfuscator | undefined, context: Context): Context {
|
|
2128
|
-
if (!obfuscator?.hasSecrets()) return context;
|
|
2129
|
-
const messages = obfuscateMessages(obfuscator, context.messages);
|
|
2130
|
-
return messages === context.messages ? context : { ...context, messages };
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2134
|
-
// Helpers
|
|
2135
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2136
|
-
|
|
2137
|
-
// Like the untracked walk, but threads a parallel `origin` tag string through:
|
|
2138
|
-
// preserved placeholder spans keep their existing origin tag (so a
|
|
2139
|
-
// same-call-fresh "F" placeholder is never relabeled prior-call "I", and vice
|
|
2140
|
-
// versa), while `transform`'s output — always freshly generated or redacted
|
|
2141
|
-
// content in both callers below — is tagged "I" (it must not be re-matched as
|
|
2142
|
-
// though it arrived in the input, mirroring plain-secret replacement tagging).
|
|
2143
|
-
function transformOutsidePlaceholdersTracked(
|
|
2144
|
-
text: string,
|
|
2145
|
-
origin: string,
|
|
2146
|
-
shouldSkipPlaceholder: (placeholder: string) => boolean,
|
|
2147
|
-
transform: (chunk: string) => string,
|
|
2148
|
-
preservePlaceholder?: (placeholder: string) => string,
|
|
2149
|
-
): { text: string; origin: string } {
|
|
2150
|
-
PLACEHOLDER_RE.lastIndex = 0;
|
|
2151
|
-
let result = "";
|
|
2152
|
-
let resultOrigin = "";
|
|
2153
|
-
let pendingIndex = 0;
|
|
2154
|
-
for (;;) {
|
|
2155
|
-
const match = PLACEHOLDER_RE.exec(text);
|
|
2156
|
-
if (match === null) break;
|
|
2157
|
-
if (!shouldSkipPlaceholder(match[0])) {
|
|
2158
|
-
resumePlaceholderScanAfterRejectedCandidate(match);
|
|
2159
|
-
continue;
|
|
2160
|
-
}
|
|
2161
|
-
const transformed = transform(text.slice(pendingIndex, match.index));
|
|
2162
|
-
result += transformed;
|
|
2163
|
-
resultOrigin += "I".repeat(transformed.length);
|
|
2164
|
-
const preserved = preservePlaceholder ? preservePlaceholder(match[0]) : match[0];
|
|
2165
|
-
result += preserved;
|
|
2166
|
-
resultOrigin += origin.slice(match.index, match.index + match[0].length);
|
|
2167
|
-
pendingIndex = match.index + match[0].length;
|
|
2168
|
-
}
|
|
2169
|
-
const trailing = transform(text.slice(pendingIndex));
|
|
2170
|
-
result += trailing;
|
|
2171
|
-
resultOrigin += "I".repeat(trailing.length);
|
|
2172
|
-
return { text: result, origin: resultOrigin };
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
function trailingOutsidePreservedPlaceholderChunk(
|
|
2176
|
-
text: string,
|
|
2177
|
-
shouldPreservePlaceholder: (placeholder: string) => boolean,
|
|
2178
|
-
): string {
|
|
2179
|
-
PLACEHOLDER_RE.lastIndex = 0;
|
|
2180
|
-
let pendingIndex = 0;
|
|
2181
|
-
let sawPlaceholder = false;
|
|
2182
|
-
for (;;) {
|
|
2183
|
-
const match = PLACEHOLDER_RE.exec(text);
|
|
2184
|
-
if (match === null) break;
|
|
2185
|
-
if (!shouldPreservePlaceholder(match[0])) {
|
|
2186
|
-
resumePlaceholderScanAfterRejectedCandidate(match);
|
|
2187
|
-
continue;
|
|
2188
|
-
}
|
|
2189
|
-
sawPlaceholder = true;
|
|
2190
|
-
pendingIndex = match.index + match[0].length;
|
|
2191
|
-
}
|
|
2192
|
-
return sawPlaceholder ? text.slice(pendingIndex) : "";
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
function buildReplaceRegexScan(
|
|
2196
|
-
text: string,
|
|
2197
|
-
ranges: ReadonlyArray<{ start: number; end: number }>,
|
|
2198
|
-
deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
|
|
2199
|
-
): ReplaceRegexScan {
|
|
2200
|
-
let scanText = "";
|
|
2201
|
-
let cursor = 0;
|
|
2202
|
-
const segments: RegexScanSegment[] = [];
|
|
2203
|
-
const appendSegment = (
|
|
2204
|
-
value: string,
|
|
2205
|
-
textStart: number,
|
|
2206
|
-
textEnd: number,
|
|
2207
|
-
generatedPlaceholder: boolean,
|
|
2208
|
-
recursive: boolean,
|
|
2209
|
-
) => {
|
|
2210
|
-
if (value.length === 0) return;
|
|
2211
|
-
const scanStart = scanText.length;
|
|
2212
|
-
scanText += value;
|
|
2213
|
-
segments.push({
|
|
2214
|
-
scanStart,
|
|
2215
|
-
scanEnd: scanStart + value.length,
|
|
2216
|
-
textStart,
|
|
2217
|
-
textEnd,
|
|
2218
|
-
generatedPlaceholder,
|
|
2219
|
-
recursive,
|
|
2220
|
-
});
|
|
2221
|
-
};
|
|
2222
|
-
|
|
2223
|
-
for (const range of ranges) {
|
|
2224
|
-
appendSegment(text.slice(cursor, range.start), cursor, range.start, false, false);
|
|
2225
|
-
const placeholder = text.slice(range.start, range.end);
|
|
2226
|
-
const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
|
|
2227
|
-
appendSegment(mapping?.secret ?? placeholder, range.start, range.end, true, mapping?.recursive ?? false);
|
|
2228
|
-
cursor = range.end;
|
|
2229
|
-
}
|
|
2230
|
-
appendSegment(text.slice(cursor), cursor, text.length, false, false);
|
|
2231
|
-
|
|
2232
|
-
return { text: scanText, segments };
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
function mapReplaceRegexMatch(
|
|
2236
|
-
segments: ReadonlyArray<RegexScanSegment>,
|
|
2237
|
-
scanStart: number,
|
|
2238
|
-
scanEnd: number,
|
|
2239
|
-
): {
|
|
2240
|
-
start: number;
|
|
2241
|
-
end: number;
|
|
2242
|
-
recursive: boolean;
|
|
2243
|
-
preserveGeneratedPlaceholders: boolean;
|
|
2244
|
-
partialPlaceholderCut: boolean;
|
|
2245
|
-
cutResumeIndex: number;
|
|
2246
|
-
firstPlaceholderScanStart: number;
|
|
2247
|
-
} {
|
|
2248
|
-
const startSegment = findScanSegment(segments, scanStart);
|
|
2249
|
-
const endSegment = findScanSegment(segments, scanEnd - 1);
|
|
2250
|
-
const start = startSegment.generatedPlaceholder
|
|
2251
|
-
? startSegment.textStart
|
|
2252
|
-
: startSegment.textStart + (scanStart - startSegment.scanStart);
|
|
2253
|
-
const end = endSegment.generatedPlaceholder
|
|
2254
|
-
? endSegment.textEnd
|
|
2255
|
-
: endSegment.textStart + (scanEnd - endSegment.scanStart);
|
|
2256
|
-
// A match boundary that falls strictly inside a generated placeholder's
|
|
2257
|
-
// expanded value cuts the underlying secret: the snap above pulls the span out
|
|
2258
|
-
// to the whole `#…#` token, so the obfuscate path can leave it alone instead of
|
|
2259
|
-
// consuming a partial placeholder expansion.
|
|
2260
|
-
const partialPlaceholderCut =
|
|
2261
|
-
(startSegment.generatedPlaceholder && scanStart > startSegment.scanStart) ||
|
|
2262
|
-
(endSegment.generatedPlaceholder && scanEnd < endSegment.scanEnd);
|
|
2263
|
-
let recursive = false;
|
|
2264
|
-
let preserveGeneratedPlaceholders = false;
|
|
2265
|
-
// When the match straddles a placeholder, resume scanning just past the last
|
|
2266
|
-
// overlapping placeholder so trailing wholly-outside content (e.g. an 8-char
|
|
2267
|
-
// run after the secret) still gets matched instead of being consumed by the
|
|
2268
|
-
// straddling span. `firstPlaceholderScanStart` marks where the leading
|
|
2269
|
-
// wholly-outside prefix ends, so a prefix that independently matches can be
|
|
2270
|
-
// redacted on its own rather than skipped along with the cut span.
|
|
2271
|
-
let cutResumeIndex = scanStart;
|
|
2272
|
-
let firstPlaceholderScanStart = -1;
|
|
2273
|
-
for (const segment of segments) {
|
|
2274
|
-
if (segment.scanStart >= scanEnd || segment.scanEnd <= scanStart) continue;
|
|
2275
|
-
recursive ||= segment.recursive;
|
|
2276
|
-
preserveGeneratedPlaceholders ||= segment.generatedPlaceholder;
|
|
2277
|
-
if (segment.generatedPlaceholder) {
|
|
2278
|
-
if (firstPlaceholderScanStart === -1) firstPlaceholderScanStart = segment.scanStart;
|
|
2279
|
-
if (segment.scanEnd > cutResumeIndex) cutResumeIndex = segment.scanEnd;
|
|
2280
|
-
}
|
|
2281
|
-
}
|
|
2282
|
-
return {
|
|
2283
|
-
start,
|
|
2284
|
-
end,
|
|
2285
|
-
recursive,
|
|
2286
|
-
preserveGeneratedPlaceholders,
|
|
2287
|
-
partialPlaceholderCut,
|
|
2288
|
-
cutResumeIndex,
|
|
2289
|
-
firstPlaceholderScanStart,
|
|
2290
|
-
};
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
function findScanSegment(segments: ReadonlyArray<RegexScanSegment>, scanIndex: number): RegexScanSegment {
|
|
2294
|
-
for (const segment of segments) {
|
|
2295
|
-
if (scanIndex >= segment.scanStart && scanIndex < segment.scanEnd) return segment;
|
|
2296
|
-
}
|
|
2297
|
-
throw new Error("regex match did not map to source text");
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
/**
|
|
2301
|
-
* Extend a scan-space resume position past a consecutive run of generated
|
|
2302
|
-
* placeholder segments starting exactly at it, with no raw gap in between. A
|
|
2303
|
-
* cut-resolution resume point that happens to land precisely on the START of
|
|
2304
|
-
* ANOTHER placeholder must not stop there and hand it to a fresh `regex.exec`
|
|
2305
|
-
* attempt — the same content, scanned as an opaque adjacent placeholder run,
|
|
2306
|
-
* must resolve identically whether the run's LEADING member is still raw text
|
|
2307
|
-
* (this call is about to placeholder it) or is ALREADY a placeholder from a
|
|
2308
|
-
* prior call or an earlier pass of this same call. Without this, a bounded
|
|
2309
|
-
* regex whose reach spans two adjacent secrets plus trailing spillover bytes
|
|
2310
|
-
* (e.g. `[A-Z]{9}` over `ABCDEFGH` + `SECRETUV` + `A`) resolves the leading
|
|
2311
|
-
* secret as its own independent redaction on the FIRST obfuscate() call (a
|
|
2312
|
-
* genuinely raw prefix gets its own match, then the discard for the rest
|
|
2313
|
-
* resumes right after it), but on a LATER call — once that prefix is itself a
|
|
2314
|
-
* placeholder — the very first match attempt starts already inside the
|
|
2315
|
-
* placeholder run, cannot be prefix-narrowed at all, and its discard resume
|
|
2316
|
-
* point lands mid-run instead of past it, exposing a shorter tail (`SECRETUV`
|
|
2317
|
-
* + `A`) to a clean, un-cut match the first call never attempted. Chaining the
|
|
2318
|
-
* resume point through every immediately-adjacent placeholder makes both
|
|
2319
|
-
* calls land on the exact same next scan position.
|
|
2320
|
-
*/
|
|
2321
|
-
function extendPastAdjacentPlaceholders(segments: ReadonlyArray<RegexScanSegment>, index: number): number {
|
|
2322
|
-
let cursor = index;
|
|
2323
|
-
for (;;) {
|
|
2324
|
-
const segment = segments.find(candidate => candidate.scanStart === cursor && candidate.generatedPlaceholder);
|
|
2325
|
-
if (!segment) return cursor;
|
|
2326
|
-
cursor = segment.scanEnd;
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
// Apply a fixed custom replacement across a matched span while preserving any
|
|
2331
|
-
// inner generated placeholders. Usually the replacement is the user's single
|
|
2332
|
-
// redaction marker for the whole match, so emit it for the first non-empty
|
|
2333
|
-
// surrounding chunk and drop later chunks. But bounded regexes can cut through
|
|
2334
|
-
// an already-emitted marker on the trailing side (`X#…#RED` from
|
|
2335
|
-
// `XSECRETUVREDACTED`), where dropping the later prefix would leave raw bytes
|
|
2336
|
-
// (`ACTED`) to be consumed on the next pass. Promote later chunks that are a
|
|
2337
|
-
// prefix of the replacement to the FULL marker so the first pass is already a
|
|
2338
|
-
// fixed point. The reversible placeholder stays intact in its relative
|
|
2339
|
-
// position.
|
|
2340
|
-
function redactWithFixedReplacementOutsidePlaceholders(
|
|
2341
|
-
text: string,
|
|
2342
|
-
origin: string,
|
|
2343
|
-
replacement: string,
|
|
2344
|
-
shouldPreservePlaceholder: (placeholder: string) => boolean,
|
|
2345
|
-
): { text: string; origin: string } {
|
|
2346
|
-
let emitted = false;
|
|
2347
|
-
return transformOutsidePlaceholdersTracked(
|
|
2348
|
-
text,
|
|
2349
|
-
origin,
|
|
2350
|
-
shouldPreservePlaceholder,
|
|
2351
|
-
chunk => {
|
|
2352
|
-
if (chunk.length === 0) return "";
|
|
2353
|
-
if (!emitted) {
|
|
2354
|
-
emitted = true;
|
|
2355
|
-
return replacement;
|
|
2356
|
-
}
|
|
2357
|
-
return replacement.startsWith(chunk) ? replacement : "";
|
|
2358
|
-
},
|
|
2359
|
-
placeholder => placeholder,
|
|
2360
|
-
);
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
function deobfuscateGeneratedPlaceholderRanges(
|
|
2364
|
-
text: string,
|
|
2365
|
-
start: number,
|
|
2366
|
-
end: number,
|
|
2367
|
-
ranges: ReadonlyArray<{ start: number; end: number }>,
|
|
2368
|
-
deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
|
|
2369
|
-
): { text: string; recursive: boolean } {
|
|
2370
|
-
let result = "";
|
|
2371
|
-
let cursor = start;
|
|
2372
|
-
let recursive = false;
|
|
2373
|
-
for (const range of ranges) {
|
|
2374
|
-
if (range.end <= start || range.start >= end) continue;
|
|
2375
|
-
const overlapStart = Math.max(range.start, start);
|
|
2376
|
-
const overlapEnd = Math.min(range.end, end);
|
|
2377
|
-
result += text.slice(cursor, overlapStart);
|
|
2378
|
-
const placeholder = text.slice(overlapStart, overlapEnd);
|
|
2379
|
-
const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
|
|
2380
|
-
result += mapping?.secret ?? placeholder;
|
|
2381
|
-
recursive ||= mapping?.recursive ?? false;
|
|
2382
|
-
cursor = overlapEnd;
|
|
2383
|
-
}
|
|
2384
|
-
result += text.slice(cursor, end);
|
|
2385
|
-
return { text: result, recursive };
|
|
2386
|
-
}
|
|
2387
|
-
|
|
2388
|
-
// Concatenate ONLY the deobfuscated placeholder ranges within [start, end),
|
|
2389
|
-
// dropping the bytes that lie outside them. Used to test whether a regex match
|
|
2390
|
-
// that straddles a prior-call placeholder would still match on the placeholder's
|
|
2391
|
-
// own (expanded) secret value alone — i.e. the surrounding raw bytes are greedy
|
|
2392
|
-
// spillover the match does not need, rather than content the match depends on.
|
|
2393
|
-
function placeholderInnerText(
|
|
2394
|
-
text: string,
|
|
2395
|
-
start: number,
|
|
2396
|
-
end: number,
|
|
2397
|
-
ranges: ReadonlyArray<{ start: number; end: number }>,
|
|
2398
|
-
deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
|
|
2399
|
-
): string {
|
|
2400
|
-
let result = "";
|
|
2401
|
-
for (const range of ranges) {
|
|
2402
|
-
if (range.end <= start || range.start >= end) continue;
|
|
2403
|
-
const overlapStart = Math.max(range.start, start);
|
|
2404
|
-
const overlapEnd = Math.min(range.end, end);
|
|
2405
|
-
const placeholder = text.slice(overlapStart, overlapEnd);
|
|
2406
|
-
const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
|
|
2407
|
-
result += mapping?.secret ?? placeholder;
|
|
2408
|
-
}
|
|
2409
|
-
return result;
|
|
2410
|
-
}
|
|
2411
|
-
|
|
2412
|
-
// Concatenate the bytes of [start, end) that lie OUTSIDE the given (ascending,
|
|
2413
|
-
// non-overlapping) placeholder ranges. Used to test whether a regex match that
|
|
2414
|
-
// straddles a prior-call placeholder would still match on its surrounding bytes
|
|
2415
|
-
// alone — i.e. those bytes are genuinely new content to redact rather than a
|
|
2416
|
-
// match that only exists because the deobfuscated placeholder bridges them.
|
|
2417
|
-
function textOutsidePlaceholderRanges(
|
|
2418
|
-
text: string,
|
|
2419
|
-
start: number,
|
|
2420
|
-
end: number,
|
|
2421
|
-
ranges: ReadonlyArray<{ start: number; end: number }>,
|
|
2422
|
-
): string {
|
|
2423
|
-
let result = "";
|
|
2424
|
-
let cursor = start;
|
|
2425
|
-
for (const range of ranges) {
|
|
2426
|
-
if (range.end <= start || range.start >= end) continue;
|
|
2427
|
-
const overlapStart = Math.max(range.start, start);
|
|
2428
|
-
const overlapEnd = Math.min(range.end, end);
|
|
2429
|
-
result += text.slice(cursor, overlapStart);
|
|
2430
|
-
cursor = overlapEnd;
|
|
2431
|
-
}
|
|
2432
|
-
result += text.slice(cursor, end);
|
|
2433
|
-
return result;
|
|
2434
|
-
}
|
|
2435
|
-
|
|
2436
|
-
// Like `textOutsidePlaceholderRanges`, but tests each outside chunk against
|
|
2437
|
-
// `regex` in its REAL context instead of on an isolated slice — tried in BOTH
|
|
2438
|
-
// the literal `#…#` placeholder-token text AND the EXPANDED scan context
|
|
2439
|
-
// (placeholder resolved to its secret value), since either can be the reason a
|
|
2440
|
-
// chunk independently requires redaction:
|
|
2441
|
-
// - Literal-token context matters when the placeholder TOKEN's own non-word
|
|
2442
|
-
// boundary is what completes a boundary-sensitive pattern, e.g. a prefix
|
|
2443
|
-
// "ABCDEFGH" next to a placeholder token matches `\b[A-Z]{8}\b` because the
|
|
2444
|
-
// token's leading `#` is a non-word byte — but that boundary disappears
|
|
2445
|
-
// once the placeholder expands into more `[A-Z]` bytes with no separator.
|
|
2446
|
-
// - Expanded scan context matters when a lookbehind/lookahead only resolves
|
|
2447
|
-
// once the neighboring placeholder is expanded, e.g. a prior plain
|
|
2448
|
-
// placeholder for `ABCDEFGH` next to raw `SECRET`, matched by
|
|
2449
|
-
// `(?<=ABCDEFGH)SECRET`: the literal placeholder token before `SECRET`
|
|
2450
|
-
// never satisfies the lookbehind, so literal-context alone wrongly reports
|
|
2451
|
-
// no independent match.
|
|
2452
|
-
// A match only counts when it lies ENTIRELY within one outside chunk (in
|
|
2453
|
-
// whichever context it was tested); a match that reaches into the
|
|
2454
|
-
// placeholder itself is not evidence the outside chunk independently
|
|
2455
|
-
// requires redaction.
|
|
2456
|
-
function outsidePlaceholderRangesAnyIndependentlyMatch(
|
|
2457
|
-
text: string,
|
|
2458
|
-
scanText: string,
|
|
2459
|
-
segments: ReadonlyArray<RegexScanSegment>,
|
|
2460
|
-
start: number,
|
|
2461
|
-
end: number,
|
|
2462
|
-
ranges: ReadonlyArray<{ start: number; end: number }>,
|
|
2463
|
-
regex: RegExp,
|
|
2464
|
-
): boolean {
|
|
2465
|
-
// A text-space outside chunk lies entirely within one non-placeholder scan
|
|
2466
|
-
// segment (placeholder ranges are exactly the gaps between such segments),
|
|
2467
|
-
// so its scan-space span is a fixed offset from its text-space span.
|
|
2468
|
-
const toScanSpace = (chunkStart: number, chunkEnd: number): [number, number] | undefined => {
|
|
2469
|
-
for (const segment of segments) {
|
|
2470
|
-
if (segment.generatedPlaceholder || segment.textStart > chunkStart || segment.textEnd < chunkEnd) continue;
|
|
2471
|
-
const offset = segment.scanStart - segment.textStart;
|
|
2472
|
-
return [chunkStart + offset, chunkEnd + offset];
|
|
2473
|
-
}
|
|
2474
|
-
return undefined;
|
|
2475
|
-
};
|
|
2476
|
-
const chunkIndependentlyMatches = (chunkStart: number, chunkEnd: number): boolean => {
|
|
2477
|
-
if (chunkMatchesInSourceContext(text, chunkStart, chunkEnd, regex)) return true;
|
|
2478
|
-
const scanSpan = toScanSpace(chunkStart, chunkEnd);
|
|
2479
|
-
return scanSpan !== undefined && chunkMatchesInSourceContext(scanText, scanSpan[0], scanSpan[1], regex);
|
|
2480
|
-
};
|
|
2481
|
-
let cursor = start;
|
|
2482
|
-
for (const range of ranges) {
|
|
2483
|
-
if (range.end <= start || range.start >= end) continue;
|
|
2484
|
-
const overlapStart = Math.max(range.start, start);
|
|
2485
|
-
const overlapEnd = Math.min(range.end, end);
|
|
2486
|
-
if (cursor < overlapStart && chunkIndependentlyMatches(cursor, overlapStart)) return true;
|
|
2487
|
-
cursor = overlapEnd;
|
|
2488
|
-
}
|
|
2489
|
-
return cursor < end && chunkIndependentlyMatches(cursor, end);
|
|
2490
|
-
}
|
|
2491
|
-
|
|
2492
|
-
// Whether `regex` (global) has a match fully contained in [chunkStart, chunkEnd)
|
|
2493
|
-
// when run against the full `text` — so lookbehind/lookahead see the actual
|
|
2494
|
-
// surrounding bytes rather than an isolated slice's edges.
|
|
2495
|
-
function chunkMatchesInSourceContext(text: string, chunkStart: number, chunkEnd: number, regex: RegExp): boolean {
|
|
2496
|
-
regex.lastIndex = chunkStart;
|
|
2497
|
-
for (;;) {
|
|
2498
|
-
const found = regex.exec(text);
|
|
2499
|
-
if (found === null || found.index >= chunkEnd) return false;
|
|
2500
|
-
const matchEnd = found.index + found[0].length;
|
|
2501
|
-
if (matchEnd <= chunkEnd) return true;
|
|
2502
|
-
regex.lastIndex = found[0].length === 0 ? found.index + 1 : matchEnd;
|
|
2503
|
-
}
|
|
2504
|
-
}
|
|
2505
|
-
|
|
2506
|
-
function firstOutsidePlaceholderRange(
|
|
2507
|
-
start: number,
|
|
2508
|
-
end: number,
|
|
2509
|
-
ranges: ReadonlyArray<{ start: number; end: number }>,
|
|
2510
|
-
): { start: number; end: number } | undefined {
|
|
2511
|
-
let cursor = start;
|
|
2512
|
-
for (const range of ranges) {
|
|
2513
|
-
if (range.end <= start || range.start >= end) continue;
|
|
2514
|
-
const overlapStart = Math.max(range.start, start);
|
|
2515
|
-
const overlapEnd = Math.min(range.end, end);
|
|
2516
|
-
if (cursor < overlapStart) return { start: cursor, end: overlapStart };
|
|
2517
|
-
cursor = overlapEnd;
|
|
2518
|
-
}
|
|
2519
|
-
return cursor < end ? { start: cursor, end } : undefined;
|
|
2520
|
-
}
|
|
2521
|
-
|
|
2522
|
-
function countOutsidePlaceholderRanges(
|
|
2523
|
-
start: number,
|
|
2524
|
-
end: number,
|
|
2525
|
-
ranges: ReadonlyArray<{ start: number; end: number }>,
|
|
2526
|
-
): number {
|
|
2527
|
-
let count = 0;
|
|
2528
|
-
let cursor = start;
|
|
2529
|
-
for (const range of ranges) {
|
|
2530
|
-
if (range.end <= start || range.start >= end) continue;
|
|
2531
|
-
const overlapStart = Math.max(range.start, start);
|
|
2532
|
-
const overlapEnd = Math.min(range.end, end);
|
|
2533
|
-
if (cursor < overlapStart) count++;
|
|
2534
|
-
cursor = overlapEnd;
|
|
2535
|
-
}
|
|
2536
|
-
if (cursor < end) count++;
|
|
2537
|
-
return count;
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
function replaceRange(text: string, start: number, end: number, replacement: string): string {
|
|
2541
|
-
return text.slice(0, start) + replacement + text.slice(end);
|
|
2542
|
-
}
|
|
2543
|
-
|
|
2544
|
-
/** Deep-walk an object, transforming all string values. */
|
|
2545
|
-
function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
|
|
2546
|
-
if (typeof obj === "string") {
|
|
2547
|
-
return transform(obj) as unknown as T;
|
|
2548
|
-
}
|
|
2549
|
-
if (Array.isArray(obj)) {
|
|
2550
|
-
let changed = false;
|
|
2551
|
-
const result = obj.map(item => {
|
|
2552
|
-
const transformed = deepWalkStrings(item, transform);
|
|
2553
|
-
if (transformed !== item) changed = true;
|
|
2554
|
-
return transformed;
|
|
2555
|
-
});
|
|
2556
|
-
return (changed ? result : obj) as unknown as T;
|
|
2557
|
-
}
|
|
2558
|
-
if (obj !== null && typeof obj === "object" && isPlainRecord(obj)) {
|
|
2559
|
-
let changed = false;
|
|
2560
|
-
const result: Record<string, unknown> = {};
|
|
2561
|
-
for (const key of Object.keys(obj)) {
|
|
2562
|
-
const value = (obj as Record<string, unknown>)[key];
|
|
2563
|
-
const transformed = deepWalkStrings(value, transform);
|
|
2564
|
-
if (transformed !== value) changed = true;
|
|
2565
|
-
result[key] = transformed;
|
|
2566
|
-
}
|
|
2567
|
-
return (changed ? result : obj) as T;
|
|
2568
|
-
}
|
|
2569
|
-
return obj;
|
|
2570
|
-
}
|
|
2571
|
-
|
|
2572
|
-
function isPlainRecord(obj: object): obj is Record<string, unknown> {
|
|
2573
|
-
const prototype = Object.getPrototypeOf(obj);
|
|
2574
|
-
return prototype === Object.prototype || prototype === null;
|
|
2575
|
-
}
|
|
2576
|
-
|
|
2577
|
-
function collectJsonRegexSecretValues(obfuscator: SecretObfuscator, value: JsonValue): Set<string> {
|
|
2578
|
-
const values = new Set<string>();
|
|
2579
|
-
const collect = (item: JsonValue): void => {
|
|
2580
|
-
if (typeof item === "string") {
|
|
2581
|
-
for (const secretValue of obfuscator.collectRegexSecretValuesForObfuscation(item)) {
|
|
2582
|
-
values.add(secretValue);
|
|
2583
|
-
}
|
|
2584
|
-
return;
|
|
2585
|
-
}
|
|
2586
|
-
if (Array.isArray(item)) {
|
|
2587
|
-
for (const child of item) collect(child);
|
|
2588
|
-
return;
|
|
2589
|
-
}
|
|
2590
|
-
if (item !== null && typeof item === "object") {
|
|
2591
|
-
for (const child of Object.values(item)) {
|
|
2592
|
-
if (child !== undefined) collect(child);
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
};
|
|
2596
|
-
collect(value);
|
|
2597
|
-
return values;
|
|
2598
|
-
}
|
|
2599
|
-
|
|
2600
|
-
/**
|
|
2601
|
-
* Map every string in arbitrary JSON. Used ONLY for tool-call arguments, whose
|
|
2602
|
-
* shape is model-authored and not known ahead of time. No other caller may walk
|
|
2603
|
-
* untyped data: every message/content path is handled by a typed transformer.
|
|
2604
|
-
*/
|
|
2605
|
-
function mapJsonStrings(value: JsonValue, fn: (s: string) => string): JsonValue {
|
|
2606
|
-
if (typeof value === "string") return fn(value);
|
|
2607
|
-
if (Array.isArray(value)) {
|
|
2608
|
-
let changed = false;
|
|
2609
|
-
const out = value.map(item => {
|
|
2610
|
-
const next = mapJsonStrings(item, fn);
|
|
2611
|
-
if (next !== item) changed = true;
|
|
2612
|
-
return next;
|
|
2613
|
-
});
|
|
2614
|
-
return changed ? out : value;
|
|
2615
|
-
}
|
|
2616
|
-
if (value !== null && typeof value === "object") {
|
|
2617
|
-
let changed = false;
|
|
2618
|
-
const out: JsonRecord = {};
|
|
2619
|
-
for (const key of Object.keys(value)) {
|
|
2620
|
-
const item = value[key];
|
|
2621
|
-
if (item === undefined) continue;
|
|
2622
|
-
const next = mapJsonStrings(item, fn);
|
|
2623
|
-
if (next !== item) changed = true;
|
|
2624
|
-
out[key] = next;
|
|
2625
|
-
}
|
|
2626
|
-
return changed ? out : value;
|
|
2627
|
-
}
|
|
2628
|
-
return value;
|
|
2629
|
-
}
|