diff --git a/CHANGELOG.md b/CHANGELOG.md
index cfd040e..f1635e1 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. (#9)
+
### Removed
- **Prebuilt release binaries.** v0.1.0's macOS binaries were only
ad-hoc/linker-signed, which Gatekeeper rejects outright once a binary
diff --git a/README.md b/README.md
index 9f44b7f..f5693db 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,10 @@
+
+
+
+
+
+
+
# 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/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)
}
diff --git a/webflow.svg b/webflow.svg
new file mode 100644
index 0000000..3422b4f
--- /dev/null
+++ b/webflow.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+