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
12 changes: 2 additions & 10 deletions src/agents/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { librarianAgent } from "./librarian"
import { exploreAgent } from "./explore"
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
import { documentWriterAgent } from "./document-writer"
import { deepMerge } from "../shared"

const allBuiltinAgents: Record<AgentName, AgentConfig> = {
oracle: oracleAgent,
Expand All @@ -18,16 +19,7 @@ function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
): AgentConfig {
return {
...base,
...override,
tools: override.tools !== undefined
? { ...(base.tools ?? {}), ...override.tools }
: base.tools,
permission: override.permission !== undefined
? { ...(base.permission ?? {}), ...override.permission }
: base.permission,
}
return deepMerge(base, override as Partial<AgentConfig>)
}

export function createBuiltinAgents(
Expand Down
12 changes: 3 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { builtinTools, createCallOmoAgent, createBackgroundTools } from "./tools
import { BackgroundManager } from "./features/background-agent";
import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import { log } from "./shared/logger";
import { log, deepMerge } from "./shared";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
Expand Down Expand Up @@ -89,10 +89,7 @@ function mergeConfigs(
return {
...base,
...override,
agents:
override.agents !== undefined
? { ...(base.agents ?? {}), ...override.agents }
: base.agents,
agents: deepMerge(base.agents, override.agents),
disabled_agents: [
...new Set([
...(base.disabled_agents ?? []),
Expand All @@ -105,10 +102,7 @@ function mergeConfigs(
...(override.disabled_mcps ?? []),
]),
],
claude_code:
override.claude_code !== undefined || base.claude_code !== undefined
? { ...(base.claude_code ?? {}), ...(override.claude_code ?? {}) }
: undefined,
claude_code: deepMerge(base.claude_code, override.claude_code),
};
}

Expand Down
53 changes: 53 additions & 0 deletions src/shared/deep-merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_DEPTH = 50;

function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}

/**
* Deep merges two objects, with override values taking precedence.
* - Objects are recursively merged
* - Arrays are replaced (not concatenated)
* - undefined values in override do not overwrite base values
*
* @example
* deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 })
* // => { a: 1, b: { c: 10, d: 3 }, e: 5 }
*/
export function deepMerge<T extends Record<string, unknown>>(base: T, override: Partial<T>, depth?: number): T;
export function deepMerge<T extends Record<string, unknown>>(base: T | undefined, override: T | undefined, depth?: number): T | undefined;
export function deepMerge<T extends Record<string, unknown>>(
base: T | undefined,
override: T | undefined,
depth = 0
): T | undefined {
if (!base && !override) return undefined;
if (!base) return override;
if (!override) return base;
if (depth > MAX_DEPTH) return override ?? base;

const result = { ...base } as Record<string, unknown>;

for (const key of Object.keys(override)) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical: Prototype Pollution Vulnerability

This loop doesn't filter dangerous keys. If a malicious project config contains __proto__, constructor, or prototype keys, it can pollute Object.prototype and affect all objects in the runtime.

Attack scenario:

// .opencode/oh-my-opencode.json (malicious repo)
{
  "agents": {
    "__proto__": { "isAdmin": true }
  }
}
// After deepMerge executes:
const obj = {};
console.log(obj.isAdmin); // true 😱

Suggested fix:

for (const key of Object.keys(override)) {
  // 🛡️ Prototype pollution defense
  if (key === "__proto__" || key === "constructor" || key === "prototype") {
    continue;
  }
  
  const baseValue = base[key];
  // ... rest unchanged
}

This is a 1-line addition that prevents a critical security vulnerability.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c3e9404

Added DANGEROUS_KEYS set to filter __proto__, constructor, prototype keys.

if (DANGEROUS_KEYS.has(key)) continue;

const baseValue = base[key];
const overrideValue = override[key];

if (overrideValue === undefined) continue;

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Recommended: Stack Overflow Protection

Recursive calls without depth limit could cause stack overflow with deeply nested malicious configs.

Suggested improvement:

export function deepMerge<T extends Record<string, unknown>>(
  base: T | undefined,
  override: T | undefined,
  depth = 0  // Add depth tracking
): T | undefined {
  if (depth > 50) {
    // Reasonable limit for config objects
    return override ?? base;
  }
  // ...
  result[key] = deepMerge(baseValue, overrideValue, depth + 1);
}

This is optional but recommended for defense-in-depth.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c3e9404

Added MAX_DEPTH = 50 limit with early return when exceeded.

if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
result[key] = deepMerge(baseValue, overrideValue, depth + 1);
} else {
result[key] = overrideValue;
}
}

return result as T;
}
1 change: 1 addition & 0 deletions src/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from "./snake-case"
export * from "./tool-name"
export * from "./pattern-matcher"
export * from "./hook-disabled"
export * from "./deep-merge"