Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./webflow.svg">
<img alt="Webflow" src="./webflow.svg" width="300">
</picture>
</div>

# ctxcop

Keep secrets out of AI coding agents' context windows.
Expand Down Expand Up @@ -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).
7 changes: 7 additions & 0 deletions internal/harness/opencode/extension/ctxcop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
},
}),
};
28 changes: 28 additions & 0 deletions internal/harness/opencode/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 29 additions & 9 deletions internal/harness/opencode/tool_execute_after.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions webflow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.