Skip to content

Commit 3695057

Browse files
authored
feat: add --sanitize flag to opencode export to strip PII or confidential info (#22489)
1 parent 4ed3afe commit 3695057

1 file changed

Lines changed: 226 additions & 19 deletions

File tree

packages/opencode/src/cli/cmd/export.ts

Lines changed: 226 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,238 @@
11
import type { Argv } from "yargs"
22
import { Session } from "../../session"
3+
import { MessageV2 } from "../../session/message-v2"
34
import { SessionID } from "../../session/schema"
45
import { cmd } from "./cmd"
56
import { bootstrap } from "../bootstrap"
67
import { UI } from "../ui"
78
import * as prompts from "@clack/prompts"
89
import { EOL } from "os"
910
import { AppRuntime } from "@/effect/app-runtime"
10-
import { Effect } from "effect"
11+
12+
function redact(kind: string, id: string, value: string) {
13+
return value.trim() ? `[redacted:${kind}:${id}]` : value
14+
}
15+
16+
function data(kind: string, id: string, value: Record<string, unknown> | undefined) {
17+
if (!value) return value
18+
return Object.keys(value).length ? { redacted: `${kind}:${id}` } : value
19+
}
20+
21+
function span(id: string, value: { value: string; start: number; end: number }) {
22+
return {
23+
...value,
24+
value: redact("file-text", id, value.value),
25+
}
26+
}
27+
28+
function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) {
29+
return diffs?.map((item, i) => ({
30+
...item,
31+
file: redact(`${kind}-file`, String(i), item.file),
32+
patch: redact(`${kind}-patch`, String(i), item.patch),
33+
}))
34+
}
35+
36+
function source(part: MessageV2.FilePart) {
37+
if (!part.source) return part.source
38+
if (part.source.type === "symbol") {
39+
return {
40+
...part.source,
41+
path: redact("file-path", part.id, part.source.path),
42+
name: redact("file-symbol", part.id, part.source.name),
43+
text: span(part.id, part.source.text),
44+
}
45+
}
46+
if (part.source.type === "resource") {
47+
return {
48+
...part.source,
49+
clientName: redact("file-client", part.id, part.source.clientName),
50+
uri: redact("file-uri", part.id, part.source.uri),
51+
text: span(part.id, part.source.text),
52+
}
53+
}
54+
return {
55+
...part.source,
56+
path: redact("file-path", part.id, part.source.path),
57+
text: span(part.id, part.source.text),
58+
}
59+
}
60+
61+
function filepart(part: MessageV2.FilePart): MessageV2.FilePart {
62+
return {
63+
...part,
64+
url: redact("file-url", part.id, part.url),
65+
filename: part.filename === undefined ? undefined : redact("file-name", part.id, part.filename),
66+
source: source(part),
67+
}
68+
}
69+
70+
function part(part: MessageV2.Part): MessageV2.Part {
71+
switch (part.type) {
72+
case "text":
73+
return {
74+
...part,
75+
text: redact("text", part.id, part.text),
76+
metadata: data("text-metadata", part.id, part.metadata),
77+
}
78+
case "reasoning":
79+
return {
80+
...part,
81+
text: redact("reasoning", part.id, part.text),
82+
metadata: data("reasoning-metadata", part.id, part.metadata),
83+
}
84+
case "file":
85+
return filepart(part)
86+
case "subtask":
87+
return {
88+
...part,
89+
prompt: redact("subtask-prompt", part.id, part.prompt),
90+
description: redact("subtask-description", part.id, part.description),
91+
command: part.command === undefined ? undefined : redact("subtask-command", part.id, part.command),
92+
}
93+
case "tool":
94+
return {
95+
...part,
96+
metadata: data("tool-metadata", part.id, part.metadata),
97+
state:
98+
part.state.status === "pending"
99+
? {
100+
...part.state,
101+
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
102+
raw: redact("tool-raw", part.id, part.state.raw),
103+
}
104+
: part.state.status === "running"
105+
? {
106+
...part.state,
107+
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
108+
title: part.state.title === undefined ? undefined : redact("tool-title", part.id, part.state.title),
109+
metadata: data("tool-state-metadata", part.id, part.state.metadata),
110+
}
111+
: part.state.status === "completed"
112+
? {
113+
...part.state,
114+
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
115+
output: redact("tool-output", part.id, part.state.output),
116+
title: redact("tool-title", part.id, part.state.title),
117+
metadata: data("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata,
118+
attachments: part.state.attachments?.map(filepart),
119+
}
120+
: {
121+
...part.state,
122+
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
123+
metadata: data("tool-state-metadata", part.id, part.state.metadata),
124+
},
125+
}
126+
case "patch":
127+
return {
128+
...part,
129+
hash: redact("patch", part.id, part.hash),
130+
files: part.files.map((item: string, i: number) => redact("patch-file", `${part.id}-${i}`, item)),
131+
}
132+
case "snapshot":
133+
return {
134+
...part,
135+
snapshot: redact("snapshot", part.id, part.snapshot),
136+
}
137+
case "step-start":
138+
return {
139+
...part,
140+
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
141+
}
142+
case "step-finish":
143+
return {
144+
...part,
145+
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
146+
}
147+
case "agent":
148+
return {
149+
...part,
150+
source: !part.source
151+
? part.source
152+
: {
153+
...part.source,
154+
value: redact("agent-source", part.id, part.source.value),
155+
},
156+
}
157+
default:
158+
return part
159+
}
160+
}
161+
162+
const partFn = part
163+
164+
function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) {
165+
return {
166+
info: {
167+
...data.info,
168+
title: redact("session-title", data.info.id, data.info.title),
169+
directory: redact("session-directory", data.info.id, data.info.directory),
170+
summary: !data.info.summary
171+
? data.info.summary
172+
: {
173+
...data.info.summary,
174+
diffs: diff("session-diff", data.info.summary.diffs),
175+
},
176+
revert: !data.info.revert
177+
? data.info.revert
178+
: {
179+
...data.info.revert,
180+
snapshot:
181+
data.info.revert.snapshot === undefined
182+
? undefined
183+
: redact("revert-snapshot", data.info.id, data.info.revert.snapshot),
184+
diff:
185+
data.info.revert.diff === undefined
186+
? undefined
187+
: redact("revert-diff", data.info.id, data.info.revert.diff),
188+
},
189+
},
190+
messages: data.messages.map((msg) => ({
191+
info:
192+
msg.info.role === "user"
193+
? {
194+
...msg.info,
195+
system: msg.info.system === undefined ? undefined : redact("system", msg.info.id, msg.info.system),
196+
summary: !msg.info.summary
197+
? msg.info.summary
198+
: {
199+
...msg.info.summary,
200+
title:
201+
msg.info.summary.title === undefined
202+
? undefined
203+
: redact("summary-title", msg.info.id, msg.info.summary.title),
204+
body:
205+
msg.info.summary.body === undefined
206+
? undefined
207+
: redact("summary-body", msg.info.id, msg.info.summary.body),
208+
diffs: diff("message-diff", msg.info.summary.diffs),
209+
},
210+
}
211+
: {
212+
...msg.info,
213+
path: {
214+
cwd: redact("cwd", msg.info.id, msg.info.path.cwd),
215+
root: redact("root", msg.info.id, msg.info.path.root),
216+
},
217+
},
218+
parts: msg.parts.map(partFn),
219+
})),
220+
}
221+
}
11222

12223
export const ExportCommand = cmd({
13224
command: "export [sessionID]",
14225
describe: "export session data as JSON",
15226
builder: (yargs: Argv) => {
16-
return yargs.positional("sessionID", {
17-
describe: "session id to export",
18-
type: "string",
19-
})
227+
return yargs
228+
.positional("sessionID", {
229+
describe: "session id to export",
230+
type: "string",
231+
})
232+
.option("sanitize", {
233+
describe: "redact sensitive transcript and file data",
234+
type: "boolean",
235+
})
20236
},
21237
handler: async (args) => {
22238
await bootstrap(process.cwd(), async () => {
@@ -69,26 +285,17 @@ export const ExportCommand = cmd({
69285
}
70286

71287
try {
72-
const { sessionInfo, messages } = await AppRuntime.runPromise(
73-
Effect.gen(function* () {
74-
const session = yield* Session.Service
75-
const sessionInfo = yield* session.get(sessionID!)
76-
return {
77-
sessionInfo,
78-
messages: yield* session.messages({ sessionID: sessionInfo.id }),
79-
}
80-
}),
288+
const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!)))
289+
const messages = await AppRuntime.runPromise(
290+
Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })),
81291
)
82292

83293
const exportData = {
84294
info: sessionInfo,
85-
messages: messages.map((msg) => ({
86-
info: msg.info,
87-
parts: msg.parts,
88-
})),
295+
messages,
89296
}
90297

91-
process.stdout.write(JSON.stringify(exportData, null, 2))
298+
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
92299
process.stdout.write(EOL)
93300
} catch (error) {
94301
UI.error(`Session not found: ${sessionID!}`)

0 commit comments

Comments
 (0)