mirror of
https://github.com/LerianStudio/ring
synced 2026-04-21 13:37:27 +00:00
Move OpenCode runtime plugin and installer into Ring monorepo under platforms/opencode/. The installer reads skills, agents, and commands directly from the Ring monorepo's canonical directories (default/, dev-team/, pm-team/, etc.) — zero asset duplication. What's included: - installer.sh: reads from Ring dirs, applies frontmatter/tool transforms, installs to ~/.config/opencode/ - plugin/: TypeScript runtime (RingUnifiedPlugin) with hooks, lifecycle, loaders - src/: CLI (doctor, config-manager) - prompts/: session-start and context-injection templates - standards/: coding standards (from dev-team/docs/) - ring.jsonc: default config with full 86-skill/35-agent/33-command inventory What's NOT included (intentionally): - assets/ directory: eliminated, content comes from Ring monorepo - scripts/codereview/: eliminated, replaced by mithril - using-ring-opencode skill: uses canonical using-ring instead Transforms applied by installer: - Agent: type→mode, strip version/changelog/output_schema/input_schema - Skill: keep name+description frontmatter, body unchanged - Command: strip argument-hint (unsupported by OpenCode) - All: normalize tool names (Bash→bash, Read→read, etc.) - All: strip Model Requirement sections from agents Replaces: LerianStudio/ring-for-opencode repository Generated-by: Gandalf AI-Model: claude-opus-4
86 lines
3.4 KiB
TypeScript
86 lines
3.4 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import * as fs from "node:fs"
|
|
import * as os from "node:os"
|
|
import * as path from "node:path"
|
|
|
|
import { deepMerge } from "../../plugin/config/loader.js"
|
|
import { loadRingAgents } from "../../plugin/loaders/agent-loader.js"
|
|
import { loadRingCommands } from "../../plugin/loaders/command-loader.js"
|
|
import { loadRingSkills } from "../../plugin/loaders/skill-loader.js"
|
|
|
|
function mkdtemp(prefix: string): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
|
}
|
|
|
|
function writeFile(filePath: string, content: string): void {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
fs.writeFileSync(filePath, content, "utf-8")
|
|
}
|
|
|
|
describe("security hardening", () => {
|
|
test("deepMerge blocks prototype pollution (__proto__/constructor/prototype)", () => {
|
|
const protoBefore = ({} as Record<string, unknown>).polluted
|
|
|
|
// JSON.parse ensures __proto__ is treated as a plain enumerable key.
|
|
const source = JSON.parse(
|
|
'{"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}},"prototype":{"polluted":true},"safe":{"__proto__":{"pollutedNested":true},"b":2}}',
|
|
) as Record<string, unknown>
|
|
|
|
try {
|
|
const merged = deepMerge({ safe: { a: 1 } }, source as unknown as Partial<{ safe: unknown }>)
|
|
|
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
|
expect(({} as Record<string, unknown>).pollutedNested).toBeUndefined()
|
|
expect(protoBefore).toBeUndefined()
|
|
expect(merged).toMatchObject({ safe: { a: 1, b: 2 } })
|
|
} finally {
|
|
// In case of regression, clean global prototype to avoid cascading failures.
|
|
delete (Object.prototype as unknown as Record<string, unknown>).polluted
|
|
delete (Object.prototype as unknown as Record<string, unknown>).pollutedNested
|
|
}
|
|
})
|
|
|
|
test("loaders building maps from filenames use null-prototype objects and skip forbidden keys", () => {
|
|
const tmp = mkdtemp("ring-sec-loaders-")
|
|
|
|
try {
|
|
const pluginRoot = path.join(tmp, "plugin")
|
|
const projectRoot = path.join(tmp, "project")
|
|
|
|
// commands
|
|
writeFile(path.join(pluginRoot, "assets", "command", "ok.md"), "# ok")
|
|
writeFile(path.join(pluginRoot, "assets", "command", "__proto__.md"), "# bad")
|
|
|
|
const { commands } = loadRingCommands(pluginRoot, projectRoot)
|
|
expect(Object.getPrototypeOf(commands)).toBeNull()
|
|
expect(Object.keys(commands)).toEqual(["ring:ok"])
|
|
|
|
// agents
|
|
writeFile(
|
|
path.join(pluginRoot, "assets", "agent", "good.md"),
|
|
"---\ndescription: good\n---\nhello",
|
|
)
|
|
writeFile(path.join(pluginRoot, "assets", "agent", "constructor.md"), "---\n---\nnope")
|
|
|
|
const agents = loadRingAgents(pluginRoot, projectRoot)
|
|
expect(Object.getPrototypeOf(agents)).toBeNull()
|
|
expect(Object.keys(agents)).toEqual(["ring:good"])
|
|
|
|
// skills
|
|
writeFile(path.join(pluginRoot, "assets", "skill", "skill-1", "SKILL.md"), "# skill")
|
|
writeFile(
|
|
path.join(pluginRoot, "assets", "skill", "__proto__", "SKILL.md"),
|
|
"# should be skipped",
|
|
)
|
|
|
|
const skills = loadRingSkills(pluginRoot, projectRoot)
|
|
expect(Object.getPrototypeOf(skills)).toBeNull()
|
|
expect(Object.keys(skills)).toEqual(["ring:skill-1"])
|
|
|
|
// ensure no global pollution from attempting to set __proto__ keys
|
|
expect(({} as Record<string, unknown>).ok).toBeUndefined()
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|