Skip to content

fix: scrub blocked URLs from system[] instead of relocating all content#198

Open
s2bomb wants to merge 1 commit into
griffinmartin:mainfrom
s2bomb:fix/system-prompt-url-blocklist
Open

fix: scrub blocked URLs from system[] instead of relocating all content#198
s2bomb wants to merge 1 commit into
griffinmartin:mainfrom
s2bomb:fix/system-prompt-url-blocklist

Conversation

@s2bomb

@s2bomb s2bomb commented Apr 15, 2026

Copy link
Copy Markdown

Problem

v1.4.8 moved ALL non-core system content to the first user message to avoid Anthropic's 400 rejection. This regresses instruction priority and prompt-cache efficiency — AGENTS.md, env blocks, skills all lose system[] attention priority.

Root cause

Server-side blocklist. One URL triggers rejection: github.com/anomalyco/opencode.

Reproduces with Claude Code itself:

claude -p --append-system-prompt "https://github.com/anomalyco/opencode" "Reply OK."
# → 400 "You're out of extra usage"

Not a client fingerprint. Not an auth issue. Server-side content filter.

Probe results

15 targeted requests varying system[] content. Same OAuth, same billing, same model.

system[] contains result
billing + identity only 200
+ full OpenCode prompt (no anomalyco URL) 200
+ env block 200
+ AGENTS.md 200
+ skills block 200
+ all three combined 200
+ github.com/anomalyco/opencode 400
word "anomalyco" alone 200
word "opencode" alone 200
opencode.ai URL 200
same path on gitlab 200

Single URL. Nothing else.

Fix

Scrub blocked URL substring in-place. Entry stays in system[]. No content relocation.

OpenCode concatenates the full prompt (identity + agent prompt + env + AGENTS + skills) into one system[] entry. Old approach relocated the whole entry on match → frontloaded everything into user message. This fix removes only the URL string.

const BLOCKED_SYSTEM_STRINGS = ["github.com/anomalyco/opencode"]

for (const entry of parsed.system) {
  if (isBlocked(entry.text)) {
    for (const blocked of BLOCKED_SYSTEM_STRINGS)
      entry.text = entry.text.split(blocked).join("")
  }
}

Wire dump verification

Before (v1.4.10):

  • systemCount: 2 (billing + identity)
  • userPreview: "You are OpenCode, the best coding agent..." ← frontloaded

After:

  • systemCount: 3 (billing + identity + full prompt)
  • userPreview: "What tools do you have? List 3." ← clean
  • API: 200

Tests

214 pass. No new deps.

@bvironn bvironn left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested locally — 214/214 tests pass. The empirical probe table in the PR body is excellent: it isolates the server-side trigger to one substring.

Two follow-ups worth considering:

  1. Empty replacement leaves text artifacts: replacing github.com/anomalyco/opencode with "" can produce dangling phrases like "Visit for help" in the prompt. Consider replacing with opencode.ai (functionally equivalent project URL — passes the validator per your probe row 11) to keep the prompt grammatically intact.

  2. Blocklist extensibility: a single hardcoded string is fine today, but future Anthropic additions would force another release cycle. An optional OPENCODE_CLAUDE_AUTH_BLOCKED_STRINGS (comma-separated) merged with the built-in list would let users self-mitigate. Not blocking.

Strict improvement over v1.4.8 either way. Resolves #154 and #210.

griffinmartin added a commit to s2bomb/opencode-claude-auth-pr that referenced this pull request Apr 30, 2026
…list

- 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 griffinmartin#198.
@griffinmartin
griffinmartin force-pushed the fix/system-prompt-url-blocklist branch from 9b897cb to a10b2b6 Compare April 30, 2026 02:41
@griffinmartin

griffinmartin commented Apr 30, 2026

Copy link
Copy Markdown
Owner

@s2bomb

Getting You're out of extra usage. Add more at claude.ai/settings/usage and keep going.. I can debug later unless you're able to.

Force pushed your original commit, can bring mine back in if this is resolved.

@griffinmartin

Copy link
Copy Markdown
Owner

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR narrows the v1.4.8 workaround for Anthropic's 400 rejection: instead of moving all non-core system content to the first user message, it now scrubs only the specific blocked URL (github.com/anomalyco/opencode) from system entries in-place, preserving instruction priority and prompt-cache efficiency for everything else.

  • src/transforms.ts: Replaces the keptSystem/movedTexts relocation loop with a targeted split(blocked).join(\"\") scrub applied only to entries that contain a blocked string; all other entries stay in system[] unchanged.
  • src/transforms.test.ts: Assertions updated throughout to expect system entries to remain in system[]; a new test verifies the URL is scrubbed while surrounding text is preserved. One test description was not renamed and still says "relocates\u2026to user message" despite now asserting the opposite.

Confidence Score: 4/5

Safe to merge; the scrubbing logic is correct for the described real-world payload and the regression risk over v1.4.8 is minimal.

The core change is straightforward and well-tested. The two gaps are: no filter for entries reduced to empty strings after scrubbing, and one stale test name that contradicts its assertions. Neither is a blocking regression.

Files Needing Attention: Both changed files warrant a second look: src/transforms.ts for the missing empty-string filter, and src/transforms.test.ts for the stale test description on the multiple-entries test.

Important Files Changed

Filename Overview
src/transforms.ts Replaces wholesale system-entry relocation with targeted in-place URL scrubbing; correct for real-world payloads but missing a guard for entries reduced to empty strings after scrubbing.
src/transforms.test.ts Tests updated to match new system[]-preserving behaviour; one test description was not renamed and now contradicts its assertions.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[transformBody called] --> B{body is string?}
    B -- No --> Z[return body as-is]
    B -- Yes --> C[Parse JSON]
    C --> D[Inject billing header as system_0]
    D --> E[Split identity prefix into separate entry]
    E --> F{each system entry contains blocked URL?}
    F -- Yes --> G[Scrub blocked substring in-place]
    F -- No --> H[Leave entry in system unchanged]
    G --> H
    H --> I[Strip effort for unsupported models]
    I --> J[PascalCase-prefix tool names]
    J --> K[Repair orphaned tool pairs]
    K --> L[Return JSON.stringify result]
Loading

Fix All in Cursor Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/transforms.ts:191-200
**Empty system entry left after full-URL scrub**

If a system entry contains *only* the blocked URL (e.g. `{ type: "text", text: "github.com/anomalyco/opencode" }`), scrubbing reduces `entry.text` to `""`. That empty-text entry is then forwarded to Anthropic's API, which may reject it or silently alter billing/validation logic. The previous implementation explicitly checked `txt.length > 0` before including text in `movedTexts`, preventing this. The new code has no equivalent guard — after the mutation loop, entries with `entry.text === ""` remain in `parsed.system` unchanged.

```suggestion
    for (const entry of parsed.system) {
      if (
        entry.type === "text" &&
        typeof entry.text === "string" &&
        isBlocked(entry.text)
      ) {
        for (const blocked of BLOCKED_SYSTEM_STRINGS) {
          entry.text = entry.text.split(blocked).join("")
        }
      }
    }
    // Remove any entries that were reduced to empty strings by scrubbing.
    parsed.system = parsed.system.filter(
      (e) => !(e.type === "text" && e.text === ""),
    )
```

### Issue 2 of 2
src/transforms.test.ts:256
**Stale test description contradicts new behavior**

The test is named `"transformBody relocates multiple non-core system entries to user message as content blocks"`, but the body now asserts the exact opposite: that all entries stay in `system[]` (`parsed.system.length === 4`). This will confuse anyone reading the test output or running it in watch mode — a failing test's name would describe behavior that no longer applies.

```suggestion
  it("transformBody keeps multiple safe non-core system entries in system[]", () => {
```

Reviews (1): Last reviewed commit: "fix: only relocate blocked URLs from sys..." | Re-trigger Greptile

Comment thread src/transforms.ts
Comment on lines +191 to 200
for (const entry of parsed.system) {
if (
entry.type === "text" &&
typeof entry.text === "string" &&
isBlocked(entry.text)
) {
for (const blocked of BLOCKED_SYSTEM_STRINGS) {
entry.text = entry.text.split(blocked).join("")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty system entry left after full-URL scrub

If a system entry contains only the blocked URL (e.g. { type: "text", text: "github.com/anomalyco/opencode" }), scrubbing reduces entry.text to "". That empty-text entry is then forwarded to Anthropic's API, which may reject it or silently alter billing/validation logic. The previous implementation explicitly checked txt.length > 0 before including text in movedTexts, preventing this. The new code has no equivalent guard — after the mutation loop, entries with entry.text === "" remain in parsed.system unchanged.

Suggested change
for (const entry of parsed.system) {
if (
entry.type === "text" &&
typeof entry.text === "string" &&
isBlocked(entry.text)
) {
for (const blocked of BLOCKED_SYSTEM_STRINGS) {
entry.text = entry.text.split(blocked).join("")
}
}
for (const entry of parsed.system) {
if (
entry.type === "text" &&
typeof entry.text === "string" &&
isBlocked(entry.text)
) {
for (const blocked of BLOCKED_SYSTEM_STRINGS) {
entry.text = entry.text.split(blocked).join("")
}
}
}
// Remove any entries that were reduced to empty strings by scrubbing.
parsed.system = parsed.system.filter(
(e) => !(e.type === "text" && e.text === ""),
)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transforms.ts
Line: 191-200

Comment:
**Empty system entry left after full-URL scrub**

If a system entry contains *only* the blocked URL (e.g. `{ type: "text", text: "github.com/anomalyco/opencode" }`), scrubbing reduces `entry.text` to `""`. That empty-text entry is then forwarded to Anthropic's API, which may reject it or silently alter billing/validation logic. The previous implementation explicitly checked `txt.length > 0` before including text in `movedTexts`, preventing this. The new code has no equivalent guard — after the mutation loop, entries with `entry.text === ""` remain in `parsed.system` unchanged.

```suggestion
    for (const entry of parsed.system) {
      if (
        entry.type === "text" &&
        typeof entry.text === "string" &&
        isBlocked(entry.text)
      ) {
        for (const blocked of BLOCKED_SYSTEM_STRINGS) {
          entry.text = entry.text.split(blocked).join("")
        }
      }
    }
    // Remove any entries that were reduced to empty strings by scrubbing.
    parsed.system = parsed.system.filter(
      (e) => !(e.type === "text" && e.text === ""),
    )
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment thread src/transforms.test.ts
assert.ok(parsed.system.some((e) => e.text === "prompt"))
})

it("transformBody relocates multiple non-core system entries to user message as content blocks", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale test description contradicts new behavior

The test is named "transformBody relocates multiple non-core system entries to user message as content blocks", but the body now asserts the exact opposite: that all entries stay in system[] (parsed.system.length === 4). This will confuse anyone reading the test output or running it in watch mode — a failing test's name would describe behavior that no longer applies.

Suggested change
it("transformBody relocates multiple non-core system entries to user message as content blocks", () => {
it("transformBody keeps multiple safe non-core system entries in system[]", () => {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transforms.test.ts
Line: 256

Comment:
**Stale test description contradicts new behavior**

The test is named `"transformBody relocates multiple non-core system entries to user message as content blocks"`, but the body now asserts the exact opposite: that all entries stay in `system[]` (`parsed.system.length === 4`). This will confuse anyone reading the test output or running it in watch mode — a failing test's name would describe behavior that no longer applies.

```suggestion
  it("transformBody keeps multiple safe non-core system entries in system[]", () => {
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants