@oh-my-pi/pi-coding-agent 17.1.2 → 17.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/dist/cli.js +3806 -3798
- package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
- package/dist/types/cli/update-cli.d.ts +41 -0
- package/dist/types/cli/usage-cli.d.ts +4 -2
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +19 -0
- package/dist/types/config/settings-schema.d.ts +30 -7
- package/dist/types/cursor.d.ts +47 -1
- package/dist/types/eval/js/process-entry.d.ts +2 -1
- package/dist/types/eval/js/worker-core.d.ts +4 -1
- package/dist/types/extensibility/extensions/runner.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +9 -0
- package/dist/types/modes/components/custom-editor.d.ts +12 -8
- package/dist/types/plan-mode/approved-plan.d.ts +3 -2
- package/dist/types/secrets/obfuscator.d.ts +7 -31
- package/dist/types/session/agent-session.d.ts +19 -4
- package/dist/types/session/prewalk.d.ts +2 -1
- package/dist/types/session/session-advisors.d.ts +8 -0
- package/dist/types/session/session-metadata.d.ts +29 -0
- package/dist/types/session/turn-recovery.d.ts +5 -5
- package/dist/types/stt/sherpa-runtime.d.ts +38 -0
- package/dist/types/thinking.d.ts +24 -0
- package/dist/types/tools/auto-generated-guard.d.ts +5 -2
- package/dist/types/tools/bash.d.ts +5 -4
- package/dist/types/tools/computer/exposure.d.ts +8 -0
- package/dist/types/tools/computer/supervisor.d.ts +1 -1
- package/dist/types/tools/computer/worker-entry.d.ts +1 -1
- package/dist/types/tools/computer/worker.d.ts +1 -1
- package/dist/types/tools/computer.d.ts +6 -0
- package/dist/types/tools/memory-render.d.ts +1 -4
- package/dist/types/tools/shell-tokenize.d.ts +14 -0
- package/dist/types/tools/todo.d.ts +7 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +80 -10
- package/src/advisor/runtime.ts +27 -1
- package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
- package/src/cli/auth-gateway-cli.ts +63 -16
- package/src/cli/config-cli.ts +25 -7
- package/src/cli/update-cli.ts +229 -29
- package/src/cli/usage-cli.ts +144 -15
- package/src/cli.ts +21 -15
- package/src/commands/update.ts +6 -0
- package/src/config/__tests__/model-registry.test.ts +42 -7
- package/src/config/model-registry.ts +67 -4
- package/src/config/settings-schema.ts +39 -8
- package/src/config/settings.ts +24 -2
- package/src/cursor.ts +153 -0
- package/src/dap/session.ts +70 -11
- package/src/edit/hashline/filesystem.ts +1 -1
- package/src/edit/modes/patch.ts +1 -1
- package/src/eval/__tests__/js-context-manager.test.ts +9 -1
- package/src/eval/__tests__/process-entry-import.test.ts +111 -1
- package/src/eval/js/process-entry.ts +9 -5
- package/src/eval/js/worker-core.ts +6 -2
- package/src/exec/bash-executor.ts +4 -2
- package/src/extensibility/extensions/runner.ts +23 -8
- package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
- package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/main.ts +22 -1
- package/src/modes/acp/acp-agent.ts +6 -0
- package/src/modes/components/assistant-message.ts +88 -11
- package/src/modes/components/chat-transcript-builder.ts +2 -0
- package/src/modes/components/custom-editor.test.ts +170 -0
- package/src/modes/components/custom-editor.ts +79 -29
- package/src/modes/components/settings-defs.ts +5 -1
- package/src/modes/controllers/event-controller.ts +96 -4
- package/src/modes/controllers/selector-controller.ts +14 -0
- package/src/modes/interactive-mode.ts +34 -8
- package/src/modes/utils/context-usage.ts +19 -2
- package/src/modes/utils/interactive-context-helpers.ts +1 -0
- package/src/plan-mode/approved-plan.ts +18 -10
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +10 -1
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/sdk.ts +4 -0
- package/src/secrets/obfuscator.ts +36 -126
- package/src/session/agent-session.ts +69 -60
- package/src/session/prewalk.ts +8 -3
- package/src/session/session-advisors.ts +67 -3
- package/src/session/session-metadata.ts +53 -0
- package/src/session/session-provider-boundary.ts +0 -1
- package/src/session/session-tools.ts +35 -1
- package/src/session/stream-guards.ts +1 -1
- package/src/session/turn-recovery.ts +36 -19
- package/src/slash-commands/builtin-registry.ts +49 -7
- package/src/stt/asr-worker.ts +2 -37
- package/src/stt/sherpa-runtime.ts +71 -0
- package/src/task/executor.ts +6 -5
- package/src/thinking.ts +39 -0
- package/src/tools/auto-generated-guard.ts +18 -5
- package/src/tools/bash.ts +43 -15
- package/src/tools/computer/exposure.ts +38 -0
- package/src/tools/computer/supervisor.ts +61 -13
- package/src/tools/computer/worker-entry.ts +28 -19
- package/src/tools/computer/worker.ts +3 -7
- package/src/tools/computer.ts +65 -10
- package/src/tools/gh-cache-invalidation.ts +2 -82
- package/src/tools/memory-render.ts +11 -2
- package/src/tools/render-utils.ts +8 -3
- package/src/tools/shell-tokenize.ts +83 -0
- package/src/tools/todo.ts +44 -17
- package/src/tools/write.ts +1 -1
|
@@ -56,6 +56,22 @@ const EMPTY_STRING_PARTS: string[] = [];
|
|
|
56
56
|
const EMPTY_TOOLS: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">> = [];
|
|
57
57
|
const EMPTY_SKILLS: readonly Skill[] = [];
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Skills actually rendered into the system prompt, mirroring the filter in
|
|
61
|
+
* `buildSystemPrompt` (`system-prompt.ts`): the `read` tool must be present so
|
|
62
|
+
* the model can fetch skill content, and skills with frontmatter `hide: true`
|
|
63
|
+
* (or `disable-model-invocation`, normalized onto `hide`) are excluded.
|
|
64
|
+
* Accounting must count only these so the Skills category and the System-prompt
|
|
65
|
+
* subtraction stay aligned with the provider-facing prompt.
|
|
66
|
+
*/
|
|
67
|
+
function renderedSkills(
|
|
68
|
+
skills: readonly Skill[],
|
|
69
|
+
tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>,
|
|
70
|
+
): readonly Skill[] {
|
|
71
|
+
if (!tools.some(tool => tool.name === "read")) return EMPTY_SKILLS;
|
|
72
|
+
return skills.filter(skill => skill.hide !== true);
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
export function estimateSkillsTokens(skills: readonly Skill[]): number {
|
|
60
76
|
const fragments: string[] = [];
|
|
61
77
|
for (const skill of skills) {
|
|
@@ -171,8 +187,9 @@ export function computeNonMessageBreakdown(session: NonMessageTokenSource): {
|
|
|
171
187
|
} {
|
|
172
188
|
const entry = nonMessageTokenCacheEntry(session);
|
|
173
189
|
if (entry.breakdown) return entry.breakdown;
|
|
174
|
-
const
|
|
175
|
-
const
|
|
190
|
+
const tools = session.agent?.state?.tools ?? EMPTY_TOOLS;
|
|
191
|
+
const skillsTokens = estimateSkillsTokens(renderedSkills(session.skills ?? EMPTY_SKILLS, tools));
|
|
192
|
+
const toolsTokens = estimateToolSchemaTokens(tools);
|
|
176
193
|
const systemPromptParts = session.systemPrompt ?? EMPTY_STRING_PARTS;
|
|
177
194
|
const systemContextTokens = countTokens(systemPromptParts.slice(1));
|
|
178
195
|
const systemPromptTokens = Math.max(0, countTokens(systemPromptParts[0] ?? "") - skillsTokens);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeLocalScheme } from "../tools/path-utils";
|
|
1
2
|
import { ToolError } from "../tools/tool-errors";
|
|
2
3
|
|
|
3
4
|
/** Shape forwarded from the plan-proposal handler to InteractiveMode's
|
|
@@ -149,8 +150,9 @@ export interface ResolvedApprovedPlan {
|
|
|
149
150
|
|
|
150
151
|
/** Locate the plan file the agent wrote and finalize its title — without
|
|
151
152
|
* renaming anything. Tries, in order: the slug derived from `extra.title`
|
|
152
|
-
* (`local://<slug>-plan.md`),
|
|
153
|
-
*
|
|
153
|
+
* (`local://<slug>-plan.md`), a state plan that the artifact scan can't see,
|
|
154
|
+
* scanned plan files newest-to-oldest, then the state plan path as a final
|
|
155
|
+
* fallback. Throws a `ToolError` guiding the agent when none exist. */
|
|
154
156
|
export async function resolveApprovedPlan(input: ResolveApprovedPlanInput): Promise<ResolvedApprovedPlan> {
|
|
155
157
|
const ordered: string[] = [];
|
|
156
158
|
const consider = (url: string | undefined): void => {
|
|
@@ -159,6 +161,20 @@ export async function resolveApprovedPlan(input: ResolveApprovedPlanInput): Prom
|
|
|
159
161
|
|
|
160
162
|
const slug = planSlugFromSupplied(input.suppliedTitle);
|
|
161
163
|
consider(slug ? planFileUrlForSlug(slug) : undefined);
|
|
164
|
+
|
|
165
|
+
const listed = input.listPlanFiles ? await input.listPlanFiles() : [];
|
|
166
|
+
// A state plan the scan cannot surface (cwd-relative, or a local file whose
|
|
167
|
+
// name does not end in `plan.md`) has no mtime in `listed` to compete on, so
|
|
168
|
+
// it keeps precedence over scanned artifacts — otherwise a stale older draft
|
|
169
|
+
// could shadow the deliberately-set current plan. A state plan already inside
|
|
170
|
+
// the scan competes purely on the newest-first ordering below (issue #6569).
|
|
171
|
+
// Compare canonical `local://` spellings so a resumed `local:/…` state path
|
|
172
|
+
// still matches the scanner's `local://…` entry (normalizeLocalScheme).
|
|
173
|
+
const canonicalListed = new Set(listed.map(normalizeLocalScheme));
|
|
174
|
+
if (input.statePlanFilePath && !canonicalListed.has(normalizeLocalScheme(input.statePlanFilePath))) {
|
|
175
|
+
consider(input.statePlanFilePath);
|
|
176
|
+
}
|
|
177
|
+
for (const url of listed) consider(url);
|
|
162
178
|
consider(input.statePlanFilePath);
|
|
163
179
|
|
|
164
180
|
for (const url of ordered) {
|
|
@@ -166,14 +182,6 @@ export async function resolveApprovedPlan(input: ResolveApprovedPlanInput): Prom
|
|
|
166
182
|
if (content !== null) return finalizeApprovedPlan(url, content, input.suppliedTitle);
|
|
167
183
|
}
|
|
168
184
|
|
|
169
|
-
if (input.listPlanFiles) {
|
|
170
|
-
for (const url of await input.listPlanFiles()) {
|
|
171
|
-
if (ordered.includes(url)) continue;
|
|
172
|
-
const content = await input.readPlan(url);
|
|
173
|
-
if (content !== null) return finalizeApprovedPlan(url, content, input.suppliedTitle);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
185
|
const target = ordered[0] ?? input.statePlanFilePath;
|
|
178
186
|
throw new ToolError(
|
|
179
187
|
`Plan file not found at ${target}. Write the finalized plan to ${target} before requesting approval.`,
|
|
@@ -59,6 +59,6 @@ Rules are local constraints. You MUST read `rule://<name>` when working in that
|
|
|
59
59
|
{{/if}}
|
|
60
60
|
{{#if secretsEnabled}}
|
|
61
61
|
<redacted-content>
|
|
62
|
-
Some values in tool output are redacted for security. They appear as placeholder tokens such as
|
|
62
|
+
Some values in tool output are redacted for security. They appear as placeholder tokens such as `$$HASH$$`, `$$HASH:CASE$$`, or `$$NAME_HASH:CASE$$` (uppercase-alphanumeric digest, optional case hint, optional friendly-name prefix). These are **not errors** — they are intentional placeholders for sensitive values (API keys, passwords, tokens). Treat them as opaque strings. NEVER attempt to decode, fix, or report them as problems.
|
|
63
63
|
</redacted-content>
|
|
64
64
|
{{/if}}
|
|
@@ -79,6 +79,15 @@ Special URLs for internal resources; with most FS/bash tools they auto-resolve t
|
|
|
79
79
|
{{/if}}
|
|
80
80
|
{{/if}}
|
|
81
81
|
|
|
82
|
+
{{#has tools "computer"}}
|
|
83
|
+
# Computer Use
|
|
84
|
+
The `{{toolRefs.computer}}` tool is explicitly enabled and available in this session.
|
|
85
|
+
- MUST use `{{toolRefs.computer}}` for requests to view or control host desktop applications.
|
|
86
|
+
- NEVER claim Computer Use is unavailable while `{{toolRefs.computer}}` appears in the tool inventory.
|
|
87
|
+
- While fulfilling host-desktop requests, NEVER substitute Browser, Bash, Eval, AppleScript, accessibility commands, or `screencapture` unless the user explicitly requests that mechanism or `{{toolRefs.computer}}` returns an error.
|
|
88
|
+
- Inspect the fresh screenshot returned by every successful `{{toolRefs.computer}}` call before choosing the next action.
|
|
89
|
+
{{/has}}
|
|
90
|
+
|
|
82
91
|
{{#if xdevTools.length}}
|
|
83
92
|
# xd:// Tool Devices
|
|
84
93
|
Additional tools are mounted as virtual devices, executed by writing a JSON args object as `content` to `xd://<tool>` via `{{toolRefs.write}}`.
|
|
@@ -101,7 +110,7 @@ Use tools whenever they improve correctness, completeness, or grounding.
|
|
|
101
110
|
# Tool I/O
|
|
102
111
|
- Prefer relative paths for `path`-like fields.
|
|
103
112
|
{{#if intentTracing}}- Most tools take `{{intentField}}`: a concise intent, present participle, 2–6 words, no period, capitalized.{{/if}}
|
|
104
|
-
{{#if secretsEnabled}}- Redacted
|
|
113
|
+
{{#if secretsEnabled}}- Redacted `$$HASH$$`, `$$HASH:CASE$$`, or `$$NAME_HASH:CASE$$` tokens in output are opaque strings.{{/if}}
|
|
105
114
|
{{#has tools "inspect_image"}}- Image tasks: prefer `{{toolRefs.inspect_image}}` over `{{toolRefs.read}}` to spare session context.{{/has}}
|
|
106
115
|
|
|
107
116
|
# Specialized Tools
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Structural AST-aware rewrites via ast-grep. Use for codemods where text replace is unsafe.
|
|
1
|
+
Structural AST-aware rewrites via ast-grep. Use for codemods where text replace is unsafe. Mixed-language paths are fine: each file is parsed in its own language, and a pattern only rewrites files it parses in.
|
|
2
2
|
|
|
3
3
|
- Metavariables in `pat` (`$A`, `$$$ARGS`) substitute into `out`.
|
|
4
4
|
- **Patterns match AST structure, not text.** `$NAME` = one node; `$_` = unbound; `$$$NAME` = zero-or-more.
|
package/src/sdk.ts
CHANGED
|
@@ -201,6 +201,7 @@ import { getImageGenTools } from "./tools/image-gen";
|
|
|
201
201
|
import { wrapToolWithMetaNotice } from "./tools/output-meta";
|
|
202
202
|
import { isAutoQaEnabled } from "./tools/report-tool-issue";
|
|
203
203
|
import { queueResolveHandler } from "./tools/resolve";
|
|
204
|
+
import { USER_TODO_EDIT_CUSTOM_TYPE } from "./tools/todo";
|
|
204
205
|
import { ttsTool } from "./tools/tts";
|
|
205
206
|
import { resolveActiveRepoContext } from "./utils/active-repo-context";
|
|
206
207
|
import { EventBus } from "./utils/event-bus";
|
|
@@ -2609,6 +2610,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2609
2610
|
getTool: resolveDeviceTool,
|
|
2610
2611
|
getToolContext: () => toolContextStore.getContext(),
|
|
2611
2612
|
emitEvent: event => cursorEventEmitter?.(event),
|
|
2613
|
+
getTodoPhases: () => session.getTodoPhases(),
|
|
2614
|
+
setTodoPhases: phases => session.setTodoPhases(phases),
|
|
2615
|
+
persistTodoPhases: phases => sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases }),
|
|
2612
2616
|
});
|
|
2613
2617
|
|
|
2614
2618
|
// Resolve the inline-descriptors setting against the session-start model.
|
|
@@ -243,11 +243,6 @@ const HASH_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
|
243
243
|
// base. A collision would let a persisted placeholder deobfuscate to the wrong
|
|
244
244
|
// secret when the configured secret set or its ordering changes across sessions.
|
|
245
245
|
const HASH_LEN = 12;
|
|
246
|
-
// Pre-friendly-name sessions persisted a 4-char, index-derived token; reproduce
|
|
247
|
-
// that exact legacy format so old session text still deobfuscates. The legacy
|
|
248
|
-
// token is keyed on the entry index, not the secret value, so it leaks nothing.
|
|
249
|
-
const LEGACY_HASH_LEN = 4;
|
|
250
|
-
const LEGACY_HASH_SEED = 0x5345_4352;
|
|
251
246
|
const MAX_FRIENDLY_NAME_LEN = 32;
|
|
252
247
|
// Plain/regex obfuscate matches shorter than this are toned down (never placed
|
|
253
248
|
// behind a reversible placeholder) to avoid redacting small words/fragments.
|
|
@@ -458,17 +453,6 @@ function buildKeyedReplacementRun(key: string, length: number): string {
|
|
|
458
453
|
return out;
|
|
459
454
|
}
|
|
460
455
|
|
|
461
|
-
/** Build the pre-friendly-name index-derived placeholder for session resume compatibility. */
|
|
462
|
-
function buildLegacyPlaceholder(index: number): string {
|
|
463
|
-
let v = Bun.hash.xxHash32(String(index), LEGACY_HASH_SEED);
|
|
464
|
-
let tag = "#";
|
|
465
|
-
for (let i = 0; i < LEGACY_HASH_LEN; i++) {
|
|
466
|
-
tag += HASH_CHARS[v % HASH_CHARS.length];
|
|
467
|
-
v = Math.floor(v / HASH_CHARS.length);
|
|
468
|
-
}
|
|
469
|
-
return `${tag}#`;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
456
|
function inferCaseHint(secret: string): PlaceholderCaseHint | undefined {
|
|
473
457
|
let hasCased = false;
|
|
474
458
|
let hasUpper = false;
|
|
@@ -502,21 +486,21 @@ function inferCaseHint(secret: string): PlaceholderCaseHint | undefined {
|
|
|
502
486
|
|
|
503
487
|
function buildPlaceholder(hint: PlaceholderCaseHint | undefined, base: string, friendlyName?: string): string {
|
|
504
488
|
const prefix = friendlyName ? `${friendlyName}_` : "";
|
|
505
|
-
return hint ?
|
|
489
|
+
return hint ? `$$${prefix}${base}:${hint}$$` : `$$${prefix}${base}$$`;
|
|
506
490
|
}
|
|
507
491
|
|
|
508
|
-
/** Regex
|
|
509
|
-
const PLACEHOLDER_RE =
|
|
492
|
+
/** Regex matching `$$HASH$$`, `$$HASH:U$$`, and `$$FRIENDLY_HASH(:hint)$$` placeholders. */
|
|
493
|
+
const PLACEHOLDER_RE = /\$\$(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?\$\$/g;
|
|
510
494
|
|
|
511
495
|
function resumePlaceholderScanAfterRejectedCandidate(match: RegExpExecArray): void {
|
|
512
496
|
// RegExp#exec does not find overlapping matches. Restart at the rejected
|
|
513
|
-
// candidate's closing
|
|
514
|
-
PLACEHOLDER_RE.lastIndex = match.index + match[0].length -
|
|
497
|
+
// candidate's closing delimiter, which can open an immediately adjacent placeholder.
|
|
498
|
+
PLACEHOLDER_RE.lastIndex = match.index + match[0].length - 2;
|
|
515
499
|
}
|
|
516
500
|
|
|
517
501
|
function placeholderWithoutFriendlyName(placeholder: string): string | undefined {
|
|
518
|
-
const match =
|
|
519
|
-
return match ?
|
|
502
|
+
const match = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder);
|
|
503
|
+
return match ? `$$${match[1]}$$` : undefined;
|
|
520
504
|
}
|
|
521
505
|
|
|
522
506
|
function lookupFriendlyPlaceholderAlias(
|
|
@@ -529,14 +513,12 @@ function lookupFriendlyPlaceholderAlias(
|
|
|
529
513
|
return unprefixed !== undefined ? deobfuscateMap.get(unprefixed) : undefined;
|
|
530
514
|
}
|
|
531
515
|
|
|
532
|
-
const PENDING_PLACEHOLDER_SUFFIX_RE =
|
|
516
|
+
const PENDING_PLACEHOLDER_SUFFIX_RE = /(?:\$\$(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?|\$)$/;
|
|
533
517
|
|
|
534
518
|
// Withhold a trailing run that could be the start of a placeholder from streamed
|
|
535
519
|
// deltas, so a partial token is never emitted before deobfuscation can replace
|
|
536
|
-
// it. A lone trailing
|
|
537
|
-
//
|
|
538
|
-
// corrupt the length-sliced live draft once the token completes. The final
|
|
539
|
-
// non-streamed flush re-emits any buffered tail, so nothing is lost.
|
|
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.
|
|
540
522
|
export function stripPendingSecretPlaceholderSuffix(text: string): string {
|
|
541
523
|
const pendingPlaceholderStart = text.match(PENDING_PLACEHOLDER_SUFFIX_RE);
|
|
542
524
|
if (pendingPlaceholderStart?.index === undefined) return text;
|
|
@@ -582,13 +564,6 @@ export class SecretObfuscator {
|
|
|
582
564
|
* one without the key. */
|
|
583
565
|
#deobfuscateMap = new Map<string, { secret: string; recursive: boolean }>();
|
|
584
566
|
|
|
585
|
-
/** Legacy index-derived aliases (unkeyed `#XRRS#`), honored ONLY when replaying
|
|
586
|
-
* stored session content. They are deterministic and trivially guessable, so
|
|
587
|
-
* accepting them on live provider/tool-call paths would let a prompt-injected
|
|
588
|
-
* model synthesize one to exfiltrate a secret; they exist solely so sessions
|
|
589
|
-
* persisted before keyed placeholders still deobfuscate on resume/display. */
|
|
590
|
-
#legacyDeobfuscateMap = new Map<string, { secret: string; recursive: boolean }>();
|
|
591
|
-
|
|
592
567
|
/** Exact placeholder tokens generated by this obfuscator revision (no aliases). */
|
|
593
568
|
#generatedPlaceholders = new Set<string>();
|
|
594
569
|
|
|
@@ -669,7 +644,6 @@ export class SecretObfuscator {
|
|
|
669
644
|
}
|
|
670
645
|
}
|
|
671
646
|
let index = 0;
|
|
672
|
-
let legacyIndex = 0;
|
|
673
647
|
let hasRealSec = this.#regexEntries.length > 0;
|
|
674
648
|
for (const entry of entries) {
|
|
675
649
|
if (entry.type !== "plain") continue;
|
|
@@ -680,11 +654,6 @@ export class SecretObfuscator {
|
|
|
680
654
|
continue;
|
|
681
655
|
}
|
|
682
656
|
const placeholder = this.#createPlaceholder(entry.content, entry.friendlyName);
|
|
683
|
-
this.#legacyDeobfuscateMap.set(buildLegacyPlaceholder(legacyIndex), {
|
|
684
|
-
secret: entry.content,
|
|
685
|
-
recursive: false,
|
|
686
|
-
});
|
|
687
|
-
legacyIndex++;
|
|
688
657
|
this.#plainMappings.set(entry.content, index);
|
|
689
658
|
this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
|
|
690
659
|
this.#generatedPlaceholders.add(placeholder);
|
|
@@ -706,11 +675,6 @@ export class SecretObfuscator {
|
|
|
706
675
|
return this.#hasAny;
|
|
707
676
|
}
|
|
708
677
|
|
|
709
|
-
/** Whether stored-session restoration can resolve keyed or legacy placeholders. */
|
|
710
|
-
hasStoredSecrets(): boolean {
|
|
711
|
-
return this.#hasAny || this.#legacyDeobfuscateMap.size > 0;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
678
|
/** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */
|
|
715
679
|
obfuscate(text: string, sharedRegexSecretValues?: ReadonlySet<string>): string {
|
|
716
680
|
if (!this.#hasAny) return text;
|
|
@@ -904,24 +868,9 @@ export class SecretObfuscator {
|
|
|
904
868
|
return result;
|
|
905
869
|
}
|
|
906
870
|
|
|
907
|
-
/**
|
|
908
|
-
* Deobfuscate keyed placeholders back to original secrets for LIVE paths
|
|
909
|
-
* (provider output, tool-call arguments). Replace-mode is NOT reversed, and
|
|
910
|
-
* legacy index-derived aliases are intentionally ignored so a prompt-injected
|
|
911
|
-
* model cannot synthesize one to recover a secret.
|
|
912
|
-
*/
|
|
871
|
+
/** Deobfuscate keyed placeholders for provider output, tool-call arguments, replay, and display. */
|
|
913
872
|
deobfuscate(text: string): string {
|
|
914
|
-
return this.#deobfuscate(text
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/**
|
|
918
|
-
* Deobfuscate stored session content for replay/display. Identical to
|
|
919
|
-
* {@link deobfuscate} but additionally honors legacy index-derived aliases so
|
|
920
|
-
* sessions persisted before keyed placeholders still resume correctly. Use
|
|
921
|
-
* only for trusted on-disk session content, never for live model output.
|
|
922
|
-
*/
|
|
923
|
-
deobfuscateStored(text: string): string {
|
|
924
|
-
return this.#deobfuscate(text, true);
|
|
873
|
+
return this.#deobfuscate(text);
|
|
925
874
|
}
|
|
926
875
|
|
|
927
876
|
// Reverse-direction counterpart to `#isGeneratedPlaceholder`'s guard: the
|
|
@@ -929,7 +878,7 @@ export class SecretObfuscator {
|
|
|
929
878
|
// placeholder minted under a renamed friendly name still deobfuscates
|
|
930
879
|
// (see `#prefixIsSecretShaped`'s docstring for why), but unconditionally
|
|
931
880
|
// stripping and ignoring an attacker-authored prefix would let a forged
|
|
932
|
-
// token like
|
|
881
|
+
// token like `$$GITHUBPATABC123_<suffix-copied-from-any-real-placeholder>$$`
|
|
933
882
|
// restore to that OTHER secret's raw value with no check at all — worse
|
|
934
883
|
// than the obfuscate-direction leak, since deobfuscation is what feeds
|
|
935
884
|
// tool-call arguments and provider-output restoration. Refuse the
|
|
@@ -939,14 +888,14 @@ export class SecretObfuscator {
|
|
|
939
888
|
#lookupLiveAlias(placeholder: string): { secret: string; recursive: boolean } | undefined {
|
|
940
889
|
const direct = this.#deobfuscateMap.get(placeholder);
|
|
941
890
|
if (direct !== undefined) return direct;
|
|
942
|
-
const
|
|
891
|
+
const body = placeholder.slice(2, -2);
|
|
892
|
+
const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(body);
|
|
943
893
|
if (match === null || this.#prefixIsSecretShaped(match[1]!)) return undefined;
|
|
944
|
-
return this.#deobfuscateMap.get(
|
|
894
|
+
return this.#deobfuscateMap.get(`$$${match[2]}$$`);
|
|
945
895
|
}
|
|
946
896
|
|
|
947
|
-
#deobfuscate(text: string
|
|
948
|
-
if (
|
|
949
|
-
return text;
|
|
897
|
+
#deobfuscate(text: string): string {
|
|
898
|
+
if (!this.#hasAny || !text.includes("$$")) return text;
|
|
950
899
|
let result = text;
|
|
951
900
|
for (;;) {
|
|
952
901
|
let shouldContinue = false;
|
|
@@ -956,16 +905,9 @@ export class SecretObfuscator {
|
|
|
956
905
|
shouldContinue ||= mapped.recursive;
|
|
957
906
|
return mapped.secret;
|
|
958
907
|
}
|
|
959
|
-
if (allowLegacy) {
|
|
960
|
-
const legacy = this.#legacyDeobfuscateMap.get(match);
|
|
961
|
-
if (legacy !== undefined) {
|
|
962
|
-
shouldContinue ||= legacy.recursive;
|
|
963
|
-
return legacy.secret;
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
908
|
return match;
|
|
967
909
|
});
|
|
968
|
-
if (next === result || !shouldContinue || !next.includes("
|
|
910
|
+
if (next === result || !shouldContinue || !next.includes("$$")) return next;
|
|
969
911
|
result = next;
|
|
970
912
|
}
|
|
971
913
|
}
|
|
@@ -976,12 +918,6 @@ export class SecretObfuscator {
|
|
|
976
918
|
return deepWalkStrings(obj, s => this.deobfuscate(s));
|
|
977
919
|
}
|
|
978
920
|
|
|
979
|
-
/** Deep-walk stored session content, deobfuscating string values incl. legacy aliases. */
|
|
980
|
-
deobfuscateStoredObject<T>(obj: T): T {
|
|
981
|
-
if (!this.hasStoredSecrets()) return obj;
|
|
982
|
-
return deepWalkStrings(obj, s => this.deobfuscateStored(s));
|
|
983
|
-
}
|
|
984
|
-
|
|
985
921
|
/** Deep-walk an object, obfuscating all string values. */
|
|
986
922
|
obfuscateObject<T>(obj: T): T {
|
|
987
923
|
if (!this.#hasAny) return obj;
|
|
@@ -1390,7 +1326,7 @@ export class SecretObfuscator {
|
|
|
1390
1326
|
#placeholderForCurrentInput(placeholder: string): string {
|
|
1391
1327
|
const unprefixed = placeholderWithoutFriendlyName(placeholder);
|
|
1392
1328
|
if (unprefixed === undefined) return placeholder;
|
|
1393
|
-
const match =
|
|
1329
|
+
const match = /^([A-Z0-9]+)_/.exec(placeholder.slice(2, -2));
|
|
1394
1330
|
if (match === null || !this.#prefixIsSecretShaped(match[1]!)) return placeholder;
|
|
1395
1331
|
return unprefixed;
|
|
1396
1332
|
}
|
|
@@ -1492,10 +1428,10 @@ export class SecretObfuscator {
|
|
|
1492
1428
|
// deobfuscate-direction check in `#deobfuscate`.
|
|
1493
1429
|
#isGeneratedPlaceholder(placeholder: string): boolean {
|
|
1494
1430
|
if (this.#deobfuscateMap.has(placeholder)) return true;
|
|
1495
|
-
const match =
|
|
1431
|
+
const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(placeholder.slice(2, -2));
|
|
1496
1432
|
if (match === null) return false;
|
|
1497
1433
|
if (this.#prefixIsSecretShaped(match[1]!)) return false;
|
|
1498
|
-
return this.#deobfuscateMap.has(
|
|
1434
|
+
return this.#deobfuscateMap.has(`$$${match[2]}$$`);
|
|
1499
1435
|
}
|
|
1500
1436
|
|
|
1501
1437
|
// Replace `search` with `replacement` outside known generated placeholders while
|
|
@@ -1865,40 +1801,25 @@ export class SecretObfuscator {
|
|
|
1865
1801
|
* Restore secret placeholders for local display. Only message kinds the model
|
|
1866
1802
|
* itself authored from obfuscated context carry placeholders — assistant
|
|
1867
1803
|
* content and the LLM-written branch/compaction summaries. User, developer, and
|
|
1868
|
-
* tool-result messages are persisted with their literal text, so
|
|
1869
|
-
*
|
|
1870
|
-
* walked.
|
|
1871
|
-
*
|
|
1872
|
-
* Legacy index-derived aliases (`#XXXX#`) are unkeyed and trivially guessable,
|
|
1873
|
-
* so a prompt-injected model can plant one in any record it influences. Every
|
|
1874
|
-
* agent-feeding path (resume, history rewrite, branch switch) therefore restores
|
|
1875
|
-
* keyed placeholders ONLY (`allowLegacyAliases` false), leaving legacy tokens
|
|
1876
|
-
* inert; display-only transcripts that are never re-obfuscated opt in via
|
|
1877
|
-
* `allowLegacyAliases`.
|
|
1804
|
+
* tool-result messages are persisted with their literal text, so operator-authored
|
|
1805
|
+
* placeholder-shaped text must survive untouched; those roles are never walked.
|
|
1878
1806
|
*/
|
|
1879
1807
|
export function deobfuscateSessionContext(
|
|
1880
1808
|
sessionContext: SessionContext,
|
|
1881
1809
|
obfuscator: SecretObfuscator | undefined,
|
|
1882
|
-
allowLegacyAliases = false,
|
|
1883
1810
|
): SessionContext {
|
|
1884
|
-
if (!obfuscator
|
|
1885
|
-
|
|
1886
|
-
const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages, allowLegacyAliases);
|
|
1811
|
+
if (!obfuscator?.hasSecrets()) return sessionContext;
|
|
1812
|
+
const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages);
|
|
1887
1813
|
return messages === sessionContext.messages ? sessionContext : { ...sessionContext, messages };
|
|
1888
1814
|
}
|
|
1889
1815
|
|
|
1890
|
-
export function deobfuscateAgentMessages(
|
|
1891
|
-
|
|
1892
|
-
messages: AgentMessage[],
|
|
1893
|
-
allowLegacyAliases = false,
|
|
1894
|
-
): AgentMessage[] {
|
|
1895
|
-
const deob = (text: string): string =>
|
|
1896
|
-
allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text);
|
|
1816
|
+
export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] {
|
|
1817
|
+
const deob = (text: string): string => obfuscator.deobfuscate(text);
|
|
1897
1818
|
let changed = false;
|
|
1898
1819
|
const result = messages.map((message): AgentMessage => {
|
|
1899
1820
|
switch (message.role) {
|
|
1900
1821
|
case "assistant": {
|
|
1901
|
-
const content = deobfuscateAssistantContent(obfuscator, message.content
|
|
1822
|
+
const content = deobfuscateAssistantContent(obfuscator, message.content);
|
|
1902
1823
|
if (content === message.content) return message;
|
|
1903
1824
|
changed = true;
|
|
1904
1825
|
return { ...message, content };
|
|
@@ -1912,10 +1833,7 @@ export function deobfuscateAgentMessages(
|
|
|
1912
1833
|
case "compactionSummary": {
|
|
1913
1834
|
const summary = deob(message.summary);
|
|
1914
1835
|
const shortSummary = message.shortSummary === undefined ? undefined : deob(message.shortSummary);
|
|
1915
|
-
const blocks =
|
|
1916
|
-
message.blocks === undefined
|
|
1917
|
-
? undefined
|
|
1918
|
-
: deobfuscateTextBlocks(obfuscator, message.blocks, allowLegacyAliases);
|
|
1836
|
+
const blocks = message.blocks === undefined ? undefined : deobfuscateTextBlocks(obfuscator, message.blocks);
|
|
1919
1837
|
if (summary === message.summary && shortSummary === message.shortSummary && blocks === message.blocks) {
|
|
1920
1838
|
return message;
|
|
1921
1839
|
}
|
|
@@ -1937,11 +1855,9 @@ export function deobfuscateAgentMessages(
|
|
|
1937
1855
|
export function deobfuscateAssistantContent(
|
|
1938
1856
|
obfuscator: SecretObfuscator,
|
|
1939
1857
|
content: AssistantMessage["content"],
|
|
1940
|
-
allowLegacyAliases = false,
|
|
1941
1858
|
): AssistantMessage["content"] {
|
|
1942
|
-
if (!
|
|
1943
|
-
const deob = (text: string): string =>
|
|
1944
|
-
allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text);
|
|
1859
|
+
if (!obfuscator.hasSecrets()) return content;
|
|
1860
|
+
const deob = (text: string): string => obfuscator.deobfuscate(text);
|
|
1945
1861
|
let changed = false;
|
|
1946
1862
|
const result = content.map((block): AssistantMessage["content"][number] => {
|
|
1947
1863
|
if (block.type === "text") {
|
|
@@ -1952,7 +1868,7 @@ export function deobfuscateAssistantContent(
|
|
|
1952
1868
|
}
|
|
1953
1869
|
|
|
1954
1870
|
if (block.type === "toolCall") {
|
|
1955
|
-
const args = deobfuscateToolArguments(obfuscator, block.arguments
|
|
1871
|
+
const args = deobfuscateToolArguments(obfuscator, block.arguments);
|
|
1956
1872
|
const intent = block.intent === undefined ? undefined : deob(block.intent);
|
|
1957
1873
|
const rawBlock = block.rawBlock === undefined ? undefined : deob(block.rawBlock);
|
|
1958
1874
|
if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block;
|
|
@@ -1972,12 +1888,9 @@ export function deobfuscateAssistantContent(
|
|
|
1972
1888
|
export function deobfuscateToolArguments(
|
|
1973
1889
|
obfuscator: SecretObfuscator,
|
|
1974
1890
|
args: Record<string, unknown>,
|
|
1975
|
-
allowLegacyAliases = false,
|
|
1976
1891
|
): Record<string, unknown> {
|
|
1977
|
-
if (!
|
|
1978
|
-
|
|
1979
|
-
allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text);
|
|
1980
|
-
return mapJsonStrings(args as JsonValue, deob) as Record<string, unknown>;
|
|
1892
|
+
if (!obfuscator.hasSecrets()) return args;
|
|
1893
|
+
return mapJsonStrings(args as JsonValue, s => obfuscator.deobfuscate(s)) as Record<string, unknown>;
|
|
1981
1894
|
}
|
|
1982
1895
|
|
|
1983
1896
|
/** Redact secrets inside a tool call's arguments (same JSON-walk exception as {@link deobfuscateToolArguments}). */
|
|
@@ -2018,14 +1931,11 @@ function obfuscateTextBlocks(
|
|
|
2018
1931
|
function deobfuscateTextBlocks(
|
|
2019
1932
|
obfuscator: SecretObfuscator,
|
|
2020
1933
|
content: (TextContent | ImageContent)[],
|
|
2021
|
-
allowLegacyAliases = false,
|
|
2022
1934
|
): (TextContent | ImageContent)[] {
|
|
2023
|
-
const deob = (text: string): string =>
|
|
2024
|
-
allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text);
|
|
2025
1935
|
let changed = false;
|
|
2026
1936
|
const result = content.map((block): TextContent | ImageContent => {
|
|
2027
1937
|
if (block.type !== "text") return block;
|
|
2028
|
-
const text =
|
|
1938
|
+
const text = obfuscator.deobfuscate(block.text);
|
|
2029
1939
|
if (text === block.text) return block;
|
|
2030
1940
|
changed = true;
|
|
2031
1941
|
return { ...block, text };
|