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 @@ +
+ + + 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/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 @@ + + + + + + + + + +