Skip to content

Commit 9b897cb

Browse files
committed
fix(transforms): use grammatical replacement and env-extensible blocklist
- Replace 'github.com/anomalyco/opencode' with 'opencode.ai' instead of empty string so surrounding prose stays grammatical (no double spaces or dangling phrases). - Add OPENCODE_CLAUDE_AUTH_BLOCKED_STRINGS env var (comma-separated, optional 'pattern=replacement' form) so users can self-mitigate future server-side additions without a release cycle. - Restate scrub-in-place comment for clarity. - Add tests for grammatical replacement, realistic concatenated prompt, and env-var parser. Fix lint formatting flagged by oxfmt --check. Addresses review feedback from @bvironn on #198.
1 parent a10b2b6 commit 9b897cb

2 files changed

Lines changed: 152 additions & 31 deletions

File tree

src/transforms.test.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import assert from "node:assert/strict"
22
import { describe, it } from "node:test"
33
import {
4+
parseBlockedReplacementsEnv,
45
repairToolPairs,
56
stripToolPrefix,
67
transformBody,
@@ -35,9 +36,14 @@ describe("transforms", () => {
3536
assert.equal(parsed.messages[0].content[0].name, "mcp_Lookup")
3637
})
3738

38-
it("transformBody scrubs blocked URL from system text in-place", () => {
39+
it("transformBody scrubs blocked URL from system text in-place with grammatical replacement", () => {
3940
const input = JSON.stringify({
40-
system: [{ type: "text", text: "Report at https://github.com/anomalyco/opencode for bugs" }],
41+
system: [
42+
{
43+
type: "text",
44+
text: "Report at https://github.com/anomalyco/opencode for bugs",
45+
},
46+
],
4147
tools: [{ name: "search" }],
4248
messages: [
4349
{ role: "user", content: [{ type: "tool_use", name: "lookup" }] },
@@ -54,14 +60,87 @@ describe("transforms", () => {
5460
}>
5561
}
5662

57-
// blocked URL scrubbed, entry stays in system[]
63+
// blocked URL scrubbed, entry stays in system[], surrounding prose intact.
5864
assert.equal(parsed.system.length, 2) // billing + scrubbed entry
5965
assert.ok(!parsed.system[1].text.includes("anomalyco"))
60-
assert.ok(parsed.system[1].text.includes("Report at"))
66+
assert.ok(!parsed.system[1].text.includes("github.com/anomalyco"))
67+
// Replacement preserves grammar — no double spaces, no dangling phrases.
68+
assert.equal(
69+
parsed.system[1].text,
70+
"Report at https://opencode.ai for bugs",
71+
)
6172
assert.equal(parsed.tools[0].name, "mcp_Search")
6273
assert.equal(parsed.messages[0].content[0].name, "mcp_Lookup")
6374
})
6475

76+
it("transformBody scrubs blocked URL embedded in a realistic concatenated system prompt", () => {
77+
// Mirrors how OpenCode actually emits system[]: identity prefix, then
78+
// a single concatenated block containing agent prompt + env + AGENTS
79+
// + skills, with the blocked URL appearing once inside the body.
80+
const identity = "You are Claude Code, Anthropic's official CLI for Claude."
81+
const concatenated = [
82+
"You are OpenCode, the best coding agent on the planet.",
83+
"Report bugs at https://github.com/anomalyco/opencode.",
84+
"Working directory: /Users/test/dev/project",
85+
"## AGENTS.md",
86+
"Use TDD when writing new features.",
87+
].join("\n\n")
88+
89+
const input = JSON.stringify({
90+
system: [
91+
{ type: "text", text: identity },
92+
{ type: "text", text: concatenated },
93+
],
94+
messages: [{ role: "user", content: "hello" }],
95+
})
96+
97+
const output = transformBody(input)
98+
const parsed = JSON.parse(output as string) as {
99+
system: Array<{ text: string }>
100+
messages: Array<{ content: string | Array<{ text?: string }> }>
101+
}
102+
103+
// billing + identity + concatenated body — body stays in system[].
104+
assert.equal(parsed.system.length, 3)
105+
assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:"))
106+
assert.equal(parsed.system[1].text, identity)
107+
// Body retained, blocked URL scrubbed, all other content intact.
108+
assert.ok(!parsed.system[2].text.includes("github.com/anomalyco"))
109+
assert.ok(parsed.system[2].text.includes("opencode.ai"))
110+
assert.ok(parsed.system[2].text.includes("Working directory"))
111+
assert.ok(parsed.system[2].text.includes("AGENTS.md"))
112+
assert.ok(parsed.system[2].text.includes("Use TDD"))
113+
// User message untouched (no relocation).
114+
assert.equal(parsed.messages[0].content, "hello")
115+
})
116+
117+
it("parseBlockedReplacementsEnv parses comma-separated patterns with optional replacements", () => {
118+
assert.deepEqual(parseBlockedReplacementsEnv(undefined), [])
119+
assert.deepEqual(parseBlockedReplacementsEnv(""), [])
120+
assert.deepEqual(parseBlockedReplacementsEnv(" "), [])
121+
122+
// Bare patterns scrub to empty string.
123+
assert.deepEqual(parseBlockedReplacementsEnv("foo.example,bar.example"), [
124+
{ pattern: "foo.example", replacement: "" },
125+
{ pattern: "bar.example", replacement: "" },
126+
])
127+
128+
// pattern=replacement form.
129+
assert.deepEqual(
130+
parseBlockedReplacementsEnv("foo.example=foo.ok,bar.example"),
131+
[
132+
{ pattern: "foo.example", replacement: "foo.ok" },
133+
{ pattern: "bar.example", replacement: "" },
134+
],
135+
)
136+
137+
// Whitespace around tokens is trimmed; empty patterns dropped.
138+
assert.deepEqual(parseBlockedReplacementsEnv(" a = b , , c=d , =orphan "), [
139+
{ pattern: "a", replacement: "b" },
140+
{ pattern: "c", replacement: "d" },
141+
])
142+
})
143+
65144
it("transformBody keeps safe non-core system text in system[]", () => {
66145
const input = JSON.stringify({
67146
system: [
@@ -82,7 +161,10 @@ describe("transforms", () => {
82161

83162
// Safe text stays in system[]
84163
assert.equal(parsed.system.length, 2) // billing + safe text
85-
assert.equal(parsed.system[1].text, "Use opencode-claude-auth plugin instructions as-is.")
164+
assert.equal(
165+
parsed.system[1].text,
166+
"Use opencode-claude-auth plugin instructions as-is.",
167+
)
86168
})
87169

88170
it("transformBody keeps safe URL/path system text in system[]", () => {
@@ -105,7 +187,10 @@ describe("transforms", () => {
105187

106188
// Safe URLs stay in system[]
107189
assert.equal(parsed.system.length, 2)
108-
assert.equal(parsed.system[1].text, "OpenCode docs: https://example.com/opencode/docs and path /var/opencode/bin")
190+
assert.equal(
191+
parsed.system[1].text,
192+
"OpenCode docs: https://example.com/opencode/docs and path /var/opencode/bin",
193+
)
109194
})
110195

111196
it("transformBody injects billing header as system[0] with computed cch", () => {

src/transforms.ts

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,53 @@ function unprefixName(name: string): string {
2222
const SYSTEM_IDENTITY =
2323
"You are Claude Code, Anthropic's official CLI for Claude."
2424

25+
/**
26+
* Substring → replacement entries used to scrub system[] text in place.
27+
* Built-in patterns target known Anthropic billing-validator triggers.
28+
* Users can extend this list at runtime via the env var
29+
* `OPENCODE_CLAUDE_AUTH_BLOCKED_STRINGS`. Format is comma-separated:
30+
*
31+
* foo.example,bar.example=baz
32+
*
33+
* Each entry is either `pattern` (replaced with empty string) or
34+
* `pattern=replacement`. Whitespace around tokens is trimmed.
35+
*/
36+
type BlockedReplacement = { pattern: string; replacement: string }
37+
38+
const BUILT_IN_BLOCKED_REPLACEMENTS: BlockedReplacement[] = [
39+
// The OpenCode GitHub repo URL trips Anthropic's OAuth billing
40+
// validator (verified by 15-row probe). `opencode.ai` is the
41+
// project's docs URL and passes the validator, keeping any
42+
// surrounding prose grammatical.
43+
{ pattern: "github.com/anomalyco/opencode", replacement: "opencode.ai" },
44+
]
45+
46+
export function parseBlockedReplacementsEnv(
47+
raw: string | undefined,
48+
): BlockedReplacement[] {
49+
if (!raw) return []
50+
return raw
51+
.split(",")
52+
.map((s) => s.trim())
53+
.filter((s) => s.length > 0)
54+
.map((entry) => {
55+
const eq = entry.indexOf("=")
56+
if (eq === -1) return { pattern: entry, replacement: "" }
57+
return {
58+
pattern: entry.slice(0, eq).trim(),
59+
replacement: entry.slice(eq + 1).trim(),
60+
}
61+
})
62+
.filter((e) => e.pattern.length > 0)
63+
}
64+
65+
const BLOCKED_REPLACEMENTS: BlockedReplacement[] = [
66+
...BUILT_IN_BLOCKED_REPLACEMENTS,
67+
...parseBlockedReplacementsEnv(
68+
process.env.OPENCODE_CLAUDE_AUTH_BLOCKED_STRINGS,
69+
),
70+
]
71+
2572
type SystemEntry = { type?: string; text?: string } & Record<string, unknown>
2673
type ContentBlock = { type?: string; text?: string } & Record<string, unknown>
2774
type Message = {
@@ -169,33 +216,22 @@ export function transformBody(
169216
}
170217
parsed.system = splitSystem
171218

172-
// --- Relocate blocked system entries to user messages ---
219+
// --- Scrub blocked substrings from system[] entries ---
173220
// Anthropic's billing validator rejects specific URLs in system[]
174-
// (e.g. the OpenCode GitHub repo URL). Instead of moving ALL
175-
// non-core system content (which regresses instruction priority
176-
// and prompt-cache efficiency), only relocate entries that contain
177-
// a blocked string. Everything else stays in system[].
178-
const BLOCKED_SYSTEM_STRINGS = [
179-
"github.com/anomalyco/opencode",
180-
]
181-
182-
function isBlocked(text: string): boolean {
183-
return BLOCKED_SYSTEM_STRINGS.some((s) => text.includes(s))
184-
}
185-
186-
// Scrub blocked strings from system entries in-place rather than
187-
// relocating entire entries. OpenCode concatenates the full system
188-
// prompt (identity + agent prompt + env + AGENTS + skills) into a
189-
// single text block, so moving the whole entry on a substring match
190-
// would frontload the entire prompt into the user message.
221+
// (e.g. the OpenCode GitHub repo URL). Scrub the offending substring
222+
// in place rather than relocating whole entries — relocation regresses
223+
// instruction priority and prompt-cache efficiency since OpenCode
224+
// concatenates the full system prompt (identity + agent prompt + env
225+
// + AGENTS + skills) into a single text block.
226+
//
227+
// Replacements are chosen to keep the surrounding prose grammatical
228+
// (e.g. `opencode.ai` is a functionally equivalent project URL that
229+
// passes the validator, verified empirically via probe).
191230
for (const entry of parsed.system) {
192-
if (
193-
entry.type === "text" &&
194-
typeof entry.text === "string" &&
195-
isBlocked(entry.text)
196-
) {
197-
for (const blocked of BLOCKED_SYSTEM_STRINGS) {
198-
entry.text = entry.text.split(blocked).join("")
231+
if (entry.type !== "text" || typeof entry.text !== "string") continue
232+
for (const { pattern, replacement } of BLOCKED_REPLACEMENTS) {
233+
if (entry.text.includes(pattern)) {
234+
entry.text = entry.text.split(pattern).join(replacement)
199235
}
200236
}
201237
}

0 commit comments

Comments
 (0)