Skip to content

Commit e1c6cae

Browse files
authored
fix: address post-merge code review findings from PR #556 (#578)
- fix(security): MongoDB aggregate now blocks nested `$function`/`$accumulator` via `JSON.stringify` scan (not just top-level stage keys) - fix(security): block `$out`/`$merge` write stages separately with clear error - fix(consent): `PlanExitTool` rejects dismissed/undefined dialog (`!== "Yes"`) - fix(telemetry): plan revision counter only increments once per user message, not on internal loop iterations (`planLastUserMsgId` dedup) - fix(telemetry): approval phrase detection uses word-boundary regex + negation phrases to prevent false positives ("not approved", "disapprove") - fix(ux): skip SQL suggestions (`sql_execute`, `sql_analyze`) for MongoDB - fix(mcp): `safeDetail` handles string commands with `trim()` + `/\s+/` split - fix(docs): add `text` language tag to markdown code fence - fix(test): use regex instead of hardcoded slice for telemetry block matching - chore: remove dead `hasWrite` code and BSON serialization dead branch
1 parent 2180ce0 commit e1c6cae

File tree

7 files changed

+71
-41
lines changed

7 files changed

+71
-41
lines changed

docs/docs/data-engineering/agent-modes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ Refinements are capped at **5 revisions per session** to avoid endless loops. Af
163163

164164
### Example conversation
165165

166-
```
166+
```text
167167
You: Plan a migration of our raw_events table from a view to an incremental model
168168
169169
Plan: Here's my proposed approach:

packages/drivers/src/mongodb.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,7 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
187187
if (val instanceof Date) {
188188
return val.toISOString()
189189
}
190-
// Arrays and plain objects — JSON-serialize for tabular display
191-
if (Array.isArray(val) || typeof (val as any).toJSON !== "function") {
192-
return JSON.stringify(val)
193-
}
190+
// Arrays, plain objects, and remaining BSON types — JSON-serialize for tabular display
194191
return JSON.stringify(val)
195192
}
196193

@@ -333,20 +330,37 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
333330
if (!parsed.pipeline || !Array.isArray(parsed.pipeline)) {
334331
throw new Error("aggregate requires a 'pipeline' array")
335332
}
336-
// Cap or append $limit to prevent OOM. Skip for $out/$merge write pipelines.
333+
// Block dangerous stages/operators:
334+
// - $out/$merge: write operations (top-level stage keys)
335+
// - $function/$accumulator: arbitrary JS execution (can be nested in expressions)
337336
const pipeline = [...parsed.pipeline]
338-
const hasWrite = pipeline.some((stage) => "$out" in stage || "$merge" in stage)
339-
if (!hasWrite) {
340-
const limitIdx = pipeline.findIndex((stage) => "$limit" in stage)
341-
if (limitIdx >= 0) {
342-
// Cap user-specified $limit against effectiveLimit
343-
const userLimit = (pipeline[limitIdx] as any).$limit
344-
if (typeof userLimit === "number" && userLimit > effectiveLimit) {
345-
pipeline[limitIdx] = { $limit: effectiveLimit + 1 }
346-
}
347-
} else {
348-
pipeline.push({ $limit: effectiveLimit + 1 })
337+
const blockedWriteStages = ["$out", "$merge"]
338+
const hasBlockedWrite = pipeline.some((stage) =>
339+
blockedWriteStages.some((s) => s in stage),
340+
)
341+
if (hasBlockedWrite) {
342+
throw new Error(
343+
`Pipeline contains a blocked write stage (${blockedWriteStages.join(", ")}). Write operations are not allowed.`,
344+
)
345+
}
346+
// $function/$accumulator can appear nested inside $project, $addFields, $group, etc.
347+
// Stringify and scan to catch them at any depth.
348+
const pipelineStr = JSON.stringify(pipeline)
349+
if (pipelineStr.includes('"$function"') || pipelineStr.includes('"$accumulator"')) {
350+
throw new Error(
351+
"Pipeline contains a blocked operator ($function, $accumulator). Executing arbitrary JavaScript is not allowed.",
352+
)
353+
}
354+
// Cap or append $limit to prevent OOM (write stages already blocked above).
355+
const limitIdx = pipeline.findIndex((stage) => "$limit" in stage)
356+
if (limitIdx >= 0) {
357+
// Cap user-specified $limit against effectiveLimit
358+
const userLimit = (pipeline[limitIdx] as any).$limit
359+
if (typeof userLimit === "number" && userLimit > effectiveLimit) {
360+
pipeline[limitIdx] = { $limit: effectiveLimit + 1 }
349361
}
362+
} else {
363+
pipeline.push({ $limit: effectiveLimit + 1 })
350364
}
351365

352366
const docs = await coll.aggregate(pipeline).toArray()

packages/opencode/src/altimate/tools/mcp-discover.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ async function getPersistedMcpNames(): Promise<Set<string>> {
2323
/** Redact server details for safe display — show type and name only, not commands/URLs */
2424
function safeDetail(server: { type: string } & Record<string, any>): string {
2525
if (server.type === "remote") return "(remote)"
26-
if (server.type === "local" && Array.isArray(server.command) && server.command.length > 0) {
26+
if (server.type === "local") {
2727
// Show only the executable name, not args (which may contain credentials)
28-
return `(local: ${server.command[0]})`
28+
if (Array.isArray(server.command) && server.command.length > 0) {
29+
return `(local: ${server.command[0]})`
30+
}
31+
if (typeof server.command === "string" && server.command.trim()) {
32+
return `(local: ${server.command.trim().split(/\s+/)[0]})`
33+
}
2934
}
3035
return `(${server.type})`
3136
}

packages/opencode/src/altimate/tools/post-connect-suggestions.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,19 @@ export namespace PostConnectSuggestions {
4040
)
4141
}
4242

43-
suggestions.push(
44-
"Run SQL queries against your " +
45-
ctx.warehouseType +
46-
" warehouse using sql_execute",
47-
)
48-
suggestions.push(
49-
"Analyze SQL quality and find potential issues with sql_analyze",
50-
)
43+
// MongoDB uses MQL, not SQL — skip SQL-specific suggestions
44+
const nonSqlWarehouses = ["mongodb", "mongo"]
45+
const isMongo = nonSqlWarehouses.includes(ctx.warehouseType.toLowerCase())
46+
if (!isMongo) {
47+
suggestions.push(
48+
"Run SQL queries against your " +
49+
ctx.warehouseType +
50+
" warehouse using sql_execute",
51+
)
52+
suggestions.push(
53+
"Analyze SQL quality and find potential issues with sql_analyze",
54+
)
55+
}
5156

5257
if (ctx.dbtDetected) {
5358
suggestions.push(

packages/opencode/src/session/prompt.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ export namespace SessionPrompt {
323323
// altimate_change start — plan refinement tracking
324324
let planRevisionCount = 0
325325
let planHasWritten = false
326+
let planLastUserMsgId: string | undefined
326327
// altimate_change end
327328
let emergencySessionEndFired = false
328329
// altimate_change start — quality signal, tool chain, error fingerprint tracking
@@ -637,10 +638,12 @@ export namespace SessionPrompt {
637638
const planPath = Session.plan(session)
638639
planHasWritten = await Filesystem.exists(planPath)
639640
}
640-
// If plan was already written and user sent a new message, this is a refinement
641-
if (planHasWritten && step > 1) {
642-
// Detect approval phrases in the last user message text
643-
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
641+
// If plan was already written and user sent a new message, this is a refinement.
642+
// Only count once per user message (not on internal loop iterations).
643+
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
644+
const currentUserMsgId = lastUserMsg?.info.id
645+
if (planHasWritten && step > 1 && currentUserMsgId && currentUserMsgId !== planLastUserMsgId) {
646+
planLastUserMsgId = currentUserMsgId
644647
const userText = lastUserMsg?.parts
645648
.filter((p): p is MessageV2.TextPart => p.type === "text" && !("synthetic" in p && p.synthetic))
646649
.map((p) => p.text.toLowerCase())
@@ -678,7 +681,7 @@ export namespace SessionPrompt {
678681
const refinementQualifiers = [" but ", " however ", " except ", " change ", " modify ", " update ", " instead ", " although ", " with the following", " with these"]
679682
const hasRefinementQualifier = refinementQualifiers.some((q) => userText.includes(q))
680683

681-
const rejectionPhrases = ["don't", "stop", "reject", "not good", "undo", "abort", "start over", "wrong"]
684+
const rejectionPhrases = ["don't", "stop", "reject", "not good", "not approve", "not approved", "disapprove", "undo", "abort", "start over", "wrong"]
682685
// "no" as a standalone word to avoid matching "know", "notion", etc.
683686
const rejectionWords = ["no"]
684687
const approvalPhrases = ["looks good", "proceed", "approved", "approve", "lgtm", "go ahead", "ship it", "yes", "perfect"]
@@ -689,7 +692,12 @@ export namespace SessionPrompt {
689692
return regex.test(userText)
690693
})
691694
const isRejection = isRejectionPhrase || isRejectionWord
692-
const isApproval = !isRejection && !hasRefinementQualifier && approvalPhrases.some((phrase) => userText.includes(phrase))
695+
// Use word-boundary matching for approval phrases to avoid false positives
696+
// e.g. "this doesn't look good" should NOT match "looks good"
697+
const isApproval = !isRejection && !hasRefinementQualifier && approvalPhrases.some((phrase) => {
698+
const regex = new RegExp(`\\b${phrase.replace(/\s+/g, "\\s+")}\\b`, "i")
699+
return regex.test(userText)
700+
})
693701
const action = isRejection ? "reject" : isApproval ? "approve" : "refine"
694702
Telemetry.track({
695703
type: "plan_revision",

packages/opencode/src/tool/plan.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
3939
})
4040

4141
const answer = answers[0]?.[0]
42-
if (answer === "No") throw new Question.RejectedError()
42+
if (answer !== "Yes") throw new Question.RejectedError()
4343

4444
const model = await getLastModel(ctx.sessionID)
4545

@@ -97,7 +97,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
9797
9898
const answer = answers[0]?.[0]
9999
100-
if (answer === "No") throw new Question.RejectedError()
100+
if (answer !== "Yes") throw new Question.RejectedError()
101101
102102
const model = await getLastModel(ctx.sessionID)
103103

packages/opencode/test/altimate/plan-refinement.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,10 @@ describe("plan_revision telemetry", () => {
119119
const promptTsPath = path.join(__dirname, "../../src/session/prompt.ts")
120120
const content = await fs.readFile(promptTsPath, "utf-8")
121121

122-
// Extract region around plan_revision telemetry — generous window
123-
const startIdx = content.indexOf('type: "plan_revision"')
124-
expect(startIdx).toBeGreaterThan(-1)
125-
const regionStart = Math.max(0, startIdx - 200)
126-
const regionEnd = Math.min(content.length, startIdx + 400)
127-
const trackBlock = content.slice(regionStart, regionEnd)
122+
// Find the Telemetry.track({ ... }) block containing plan_revision
123+
const trackMatch = content.match(/Telemetry\.track\(\{[^}]*type:\s*"plan_revision"[^}]*\}\)/s)
124+
expect(trackMatch).not.toBeNull()
125+
const trackBlock = trackMatch![0]
128126
expect(trackBlock).toContain("timestamp:")
129127
expect(trackBlock).toContain("session_id:")
130128
expect(trackBlock).toContain("revision_number:")

0 commit comments

Comments
 (0)