From 957219cbaa16177261fa673a0b78ddd8388a0a4e Mon Sep 17 00:00:00 2001 From: Andrew Rose Date: Tue, 25 Aug 2026 15:17:44 -0700 Subject: [PATCH 1/4] fix(opencode): redact output.metadata, not just output.output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode's tool.execute.after hook exposes metadata as a separate mutable field alongside output. OpenCode populates metadata with a raw copy of the tool result and persists it to the session store (and includes it in --format json / opencode export) independently of output.output — so a secret fully redacted in the field the model sees could still survive in plaintext in session metadata. Confirmed live against real bash tool output in an OpenCode session. Redact metadata via the same RedactTreeToolOutput walker the claudecode/cursor/pi adapters already use for their tree-shaped payloads. Co-Authored-By: Claude Sonnet 5 --- internal/harness/opencode/extension/ctxcop.ts | 7 ++++ internal/harness/opencode/opencode_test.go | 28 ++++++++++++++ .../harness/opencode/tool_execute_after.go | 38 ++++++++++++++----- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/internal/harness/opencode/extension/ctxcop.ts b/internal/harness/opencode/extension/ctxcop.ts index 091e70e..bb2c20f 100644 --- a/internal/harness/opencode/extension/ctxcop.ts +++ b/internal/harness/opencode/extension/ctxcop.ts @@ -64,10 +64,17 @@ export const ctxcop: { id: string; server: Plugin } = { callID: input.callID, args: input.args, output: output.output, + metadata: output.metadata, }); if (typeof r?.output === "string") { output.output = r.output; } + // metadata is a raw copy of the tool's result that OpenCode persists + // to the session store independently of output.output — redact it + // in place so a secret redacted above doesn't survive there instead. + if (r?.metadata && typeof r.metadata === "object" && output.metadata && typeof output.metadata === "object") { + replaceInPlace(output.metadata, r.metadata); + } }, }), }; diff --git a/internal/harness/opencode/opencode_test.go b/internal/harness/opencode/opencode_test.go index 1ef3ddc..28b2216 100644 --- a/internal/harness/opencode/opencode_test.go +++ b/internal/harness/opencode/opencode_test.go @@ -168,6 +168,34 @@ func TestToolExecuteAfterRedactsBashOutput(t *testing.T) { } } +// OpenCode persists output.metadata to the session store (and includes +// it in `--format json` / `opencode export`) independently of +// output.output. A tool that mirrors its raw stdout into metadata +// (observed live for `bash`) must not leak the secret there even +// though output.output came back clean/redacted. +func TestToolExecuteAfterRedactsMetadataEvenWhenOutputClean(t *testing.T) { + body := `{"tool":"bash","sessionID":"s1","callID":"c1","args":{"command":"env"},"output":"ok","metadata":{"output":"AWS_ACCESS_KEY_ID=` + akia + `\n"}}` + var out bytes.Buffer + if err := ToolExecuteAfter(strings.NewReader(body), &out); err != nil { + t.Fatal(err) + } + var resp map[string]any + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v (%q)", err, out.String()) + } + if strings.Contains(out.String(), akia) { + t.Errorf("literal credential survived in metadata: %q", out.String()) + } + meta, _ := resp["metadata"].(map[string]any) + if meta == nil { + t.Fatalf("expected `metadata` key in response, got %q", out.String()) + } + metaOut, _ := meta["output"].(string) + if !strings.Contains(metaOut, "REDACTED:ctxcop-aws-access-key") { + t.Errorf("expected placeholder in mutated metadata, got: %q", metaOut) + } +} + func TestToolExecuteAfterRedactsReadOutput(t *testing.T) { body := `{"tool":"read","sessionID":"s1","callID":"c1","args":{"filePath":"/tmp/.env"},"output":"# secrets\nKEY=` + akia + `\n"}` var out bytes.Buffer diff --git a/internal/harness/opencode/tool_execute_after.go b/internal/harness/opencode/tool_execute_after.go index ebde5af..71ae21d 100644 --- a/internal/harness/opencode/tool_execute_after.go +++ b/internal/harness/opencode/tool_execute_after.go @@ -15,16 +15,21 @@ type toolExecuteAfterInput struct { CallID string `json:"callID"` Args any `json:"args"` Output string `json:"output"` + Metadata any `json:"metadata"` } type toolExecuteAfterOutput struct { - Output string `json:"output,omitempty"` + Output string `json:"output,omitempty"` + Metadata any `json:"metadata,omitempty"` } // ToolExecuteAfter redacts credential-shape data from a tool's output // string before it lands in the message stream the model sees next -// turn. Per OpenCode plugin docs, output.output is always a string. -// Fail-open on any error. +// turn, and from output.metadata, which OpenCode persists to the +// session store (and includes in `--format json` / `opencode export`) +// independently of output.output — a raw copy of a tool's stdout +// survives there even when output.output is fully redacted. Fail-open +// on any error. func ToolExecuteAfter(r io.Reader, w io.Writer) error { if pause.IsPaused() { return passthrough(w) @@ -37,22 +42,37 @@ func ToolExecuteAfter(r io.Reader, w io.Writer) error { if err := json.Unmarshal(raw, &in); err != nil { return passthrough(w) } - if in.Output == "" { + if in.Output == "" && in.Metadata == nil { return passthrough(w) } + + var out toolExecuteAfterOutput + var allRules []string + // Tool output is untrusted — do not honor inline allow/fixture markers. - redacted, rules, err := redact.RedactToolOutput(in.Output) - if err != nil || len(rules) == 0 { + if in.Output != "" { + if redacted, rules, err := redact.RedactToolOutput(in.Output); err == nil && len(rules) > 0 { + out.Output = redacted + allRules = append(allRules, rules...) + } + } + if in.Metadata != nil { + if redacted, rules := redact.RedactTreeToolOutput(in.Metadata); len(rules) > 0 { + out.Metadata = redacted + allRules = append(allRules, rules...) + } + } + if len(allRules) == 0 { return passthrough(w) } + audit.Log(audit.Entry{ Tool: "OpenCode:tool.execute.after", Action: "redacted", - Rules: rules, - Count: len(rules), + Rules: allRules, + Count: len(allRules), Field: in.Tool, }) - out := toolExecuteAfterOutput{Output: redacted} if err := json.NewEncoder(w).Encode(out); err != nil { return passthrough(w) } From 6bd6d5327b3fdfdff48908fa94c3be4b9ec1fd1b Mon Sep 17 00:00:00 2001 From: Andrew Rose Date: Thu, 27 Aug 2026 15:10:20 -0700 Subject: [PATCH 2/4] docs(changelog): note the opencode metadata redaction fix Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd040e..100fb61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,21 @@ hardening pass done ahead of open-sourcing — is preserved in ## [Unreleased] +### Security +- **OpenCode: `output.metadata` is now redacted, not just `output.output`.** + `tool.execute.after`'s `output` has three independently-mutable fields — + `title`, `output`, `metadata` — and OpenCode populates `metadata` with a + raw copy of the tool's result, persisted to the session store and + included in `--format json` / `opencode export`, independent of + `output.output`. ctxcop's bridge only ever redacted `output.output` (the + field the model reads next turn), so a secret fully redacted in what the + model saw could still sit in plaintext in session metadata. Confirmed + live against a real OpenCode session: `output.output` came back clean + while `output.metadata.output` still carried the raw AWS/GitHub/OpenAI/ + Anthropic-shaped fixtures. `metadata` is now walked and redacted via the + same tree-walker the claudecode/cursor/pi adapters already use for their + tree-shaped payloads. + ### Removed - **Prebuilt release binaries.** v0.1.0's macOS binaries were only ad-hoc/linker-signed, which Gatekeeper rejects outright once a binary From 9ce49128ac80d347406e92925efb7b4b0b2b4374 Mon Sep 17 00:00:00 2001 From: Andrew Rose Date: Thu, 27 Aug 2026 15:11:43 -0700 Subject: [PATCH 3/4] docs(changelog): reference PR #9 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 100fb61..f1635e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ hardening pass done ahead of open-sourcing — is preserved in while `output.metadata.output` still carried the raw AWS/GitHub/OpenAI/ Anthropic-shaped fixtures. `metadata` is now walked and redacted via the same tree-walker the claudecode/cursor/pi adapters already use for their - tree-shaped payloads. + tree-shaped payloads. (#9) ### Removed - **Prebuilt release binaries.** v0.1.0's macOS binaries were only From 77640102c9bee314daf7bdf87389d7e30c9fc3db Mon Sep 17 00:00:00 2001 From: Andrew Rose Date: Thu, 27 Aug 2026 15:15:30 -0700 Subject: [PATCH 4/4] docs(readme): add Webflow OSS branding Logo + a short blurb, per Utkarsh's request in the open-sourcing Slack thread. Logo asset matches webflow/codeflow's, the other public Webflow repo. Co-Authored-By: Claude Sonnet 5 --- README.md | 14 ++++++++++++++ webflow.svg | 10 ++++++++++ 2 files changed, 24 insertions(+) create mode 100644 webflow.svg diff --git a/README.md b/README.md index 9f44b7f..f5693db 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ +
+ + + Webflow + +
+ # ctxcop Keep secrets out of AI coding agents' context windows. @@ -154,3 +161,10 @@ MIT — see [LICENSE](LICENSE). ctxcop is built on the [betterleaks](https://github.com/betterleaks/betterleaks) secret-scanning engine (MIT, © Zachary Rice). Full third-party attributions are in [NOTICES.md](NOTICES.md). + +## Webflow Open Source + +Webflow builds the visual development platform behind millions of +websites. We open source internal tools like ctxcop when we think +they're broadly useful beyond our own stack. Check out our other +projects at [github.com/webflow](https://github.com/webflow). diff --git a/webflow.svg b/webflow.svg new file mode 100644 index 0000000..3422b4f --- /dev/null +++ b/webflow.svg @@ -0,0 +1,10 @@ + + + + + + + + + +