Skip to content

Conversation

@danshapiro
Copy link

@danshapiro danshapiro commented Dec 15, 2025

Summary

  • Fix Windows behavior where Codex opens the extensionless launcher instead of executing it by adding a .cmd shim.
  • Make the shared skills core load under plain Node (no repo-level ype: module) by moving it to lib/skills-core.mjs and updating imports.
  • Update Codex/OpenCode docs + tests to reference the new core module path.

Key changes

  • Added .codex/superpowers-codex.cmd (Windows shim)
  • Added lib/skills-core.mjs and made lib/skills-core.js fail fast with a clear message
  • Updated .codex/superpowers-codex to dynamically import the shared core

Why

Windows does not execute extensionless shebang scripts; it tends to open them. The .cmd shim ensures execution on Windows while keeping Linux behavior unchanged.

Summary by CodeRabbit

  • New Features

    • Added Windows batch script support for CLI tools
    • Introduced OpenCode plugin integration with skill discovery and tool management
    • Modernized core module to ES module format
  • Documentation

    • Enhanced installation guides with Windows-specific bootstrap commands
    • Updated usage documentation and troubleshooting examples with platform-specific guidance
    • Clarified shared core module references
  • Tests

    • Updated test coverage to reflect module format changes

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 15, 2025

Walkthrough

This PR migrates the shared skills core module from CommonJS (.js) to ES modules (.mjs), refactors the Codex CLI to use dynamic async imports, introduces a Windows batch launcher shim, and updates all consumer code and documentation to reference the new module path.

Changes

Cohort / File(s) Summary
Core Module Migration
lib/skills-core.js, lib/skills-core.mjs
Migrates from CommonJS to ES module format. The .js file now throws an error directing to the .mjs variant. The new .mjs file exports five utility functions: extractFrontmatter, findSkillsInDir, resolveSkillPath, checkForUpdates, and stripFrontmatter for skill discovery, resolution, and update checks.
CLI Refactoring
.codex/superpowers-codex
Replaces static CommonJS require with dynamic ES module import using pathToFileURL. Wraps existing CLI command logic (bootstrap, use-skill, find-skills) into a new async main() function with error handling.
Windows Support
.codex/superpowers-codex.cmd, .codex/INSTALL.md, .codex/superpowers-bootstrap.md
Adds Windows batch shim launcher for the Codex CLI and documents Windows-specific bootstrap commands alongside existing Unix guidance.
Plugin & Test Updates
.opencode/plugin/superpowers.js, tests/opencode/test-plugin-loading.sh, tests/opencode/test-skills-core.sh, tests/opencode/run-tests.sh
Updates import paths from skills-core.js to skills-core.mjs and revises test descriptions and checks to reflect the module name change.
User-Facing Documentation
docs/README.codex.md, docs/README.opencode.md, RELEASE-NOTES.md
Updates module references from skills-core.js to skills-core.mjs, adds Windows-specific usage examples, and documents the switch to ES module format.
Design & Implementation Plans
docs/plans/2025-11-22-opencode-support-design.md, docs/plans/2025-11-22-opencode-support-implementation.md
Updates design and implementation documentation to reflect the migration to lib/skills-core.mjs and revised import paths across the codebase.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25–35 minutes

  • lib/skills-core.mjs: Verify the five exported utility functions handle edge cases (frontmatter parsing, recursive directory traversal, git timeout logic, null returns).
  • .codex/superpowers-codex: Check async/await error handling, dynamic import compatibility, and preservation of existing CLI behavior.
  • Module migration strategy: Confirm the old .js file's throw-error redirect is appropriate and test import path updates across all consumer files.

Possibly related PRs

  • Add personal superpowers overlay system #2: Implements personal-overlay skill discovery and resolution that directly depends on the findSkillsInDir and resolveSkillPath functions now exported from the shared core module.

Poem

🐰 ES modules hoppity-hop,

From CommonJS we've made the swap,

Windows shims help Codex run,

Skills discovered, tests pass—what fun!

A shared core unifies the way,

Superpowers shine today! ✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the two main changes: adding Windows Codex launcher support (.cmd shim) and enabling Node ESM compatibility (skills-core.mjs).
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/plans/2025-11-22-opencode-support-design.md (1)

48-56: Code example uses CommonJS syntax for an ES module file.

The example shows module.exports = {...} but the file is named skills-core.mjs. ES modules use export syntax, not module.exports.

Consider updating the example to use ESM syntax:

 ```javascript
 // lib/skills-core.mjs
-module.exports = {
-  extractFrontmatter(filePath),      // Parse name + description from YAML
-  findSkillsInDir(dir, maxDepth),    // Recursive SKILL.md discovery
-  findAllSkills(dirs),                // Scan multiple directories
-  resolveSkillPath(skillName, dirs), // Handle shadowing (personal > core)
-  checkForUpdates(repoDir)           // Git fetch/status check
-};
+export function extractFrontmatter(filePath) { ... }  // Parse name + description from YAML
+export function findSkillsInDir(dir, maxDepth) { ... } // Recursive SKILL.md discovery
+export function findAllSkills(dirs) { ... }            // Scan multiple directories
+export function resolveSkillPath(skillName, dirs) { ... } // Handle shadowing (personal > core)
+export function checkForUpdates(repoDir) { ... }       // Git fetch/status check

</blockquote></details>

</blockquote></details>
🧹 Nitpick comments (9)
docs/README.opencode.md (1)

156-165: .mjs path is correct; consider updating hook wording to match current implementation

The references to lib/skills-core.mjs and its location are accurate and match the code. The only mismatch in this block is the still‑mentioned chat.message hook, while the plugin now uses event hooks (session.created / session.compacted).

You could align the docs with the implementation like this:

-**Components:**
-- Two custom tools: `use_skill`, `find_skills`
-- chat.message hook for initial context injection
-- event handler for session.compacted re-injection
-- Uses shared `lib/skills-core.mjs` module (also used by Codex)
+**Components:**
+- Two custom tools: `use_skill`, `find_skills`
+- Event hook for initial context injection on `session.created`
+- Event hook for `session.compacted` re-injection
+- Uses shared `lib/skills-core.mjs` module (also used by Codex)
lib/skills-core.js (1)

1-11: Make lib/skills-core.js a minimal stub to avoid CJS parse issues and dead code

Right now this file throws with a clear message at the top but still contains the full implementation and an export { ... } block. In a CommonJS context (no "type": "module"), the export { ... } will trigger a SyntaxError before the top-level throw executes, so callers won't see your intended "moved to .mjs" message. The remaining implementation is also dead and can go out of sync with lib/skills-core.mjs.

Consider turning this into a minimal, environment‑agnostic stub:

-/**
- * NOTE: This file is intentionally NOT the source of truth anymore.
- *
- * The shared core module is `lib/skills-core.mjs` so it can be imported as an
- * ES module without requiring a repo-level `package.json` with `"type":"module"`.
- *
- * If you are importing `lib/skills-core.js`, switch to `lib/skills-core.mjs`.
- */
-'use strict';
-
-throw new Error('superpowers: lib/skills-core.js has moved to lib/skills-core.mjs. Import that file instead.');
-
-export {
-    extractFrontmatter,
-    findSkillsInDir,
-    resolveSkillPath,
-    checkForUpdates,
-    stripFrontmatter
-};
+/**
+ * NOTE: This file is intentionally NOT the source of truth anymore.
+ *
+ * The shared core module is `lib/skills-core.mjs` so it can be imported as an
+ * ES module without requiring a repo-level `package.json` with `"type":"module"`.
+ *
+ * If you are importing `lib/skills-core.js`, switch to `lib/skills-core.mjs`.
+ */
+'use strict';
+
+throw new Error(
+  'superpowers: lib/skills-core.js has moved to lib/skills-core.mjs. Import that file instead.'
+);

That way both require('./lib/skills-core.js') and import './lib/skills-core.js' surface the same clear error, and there's no secondary, unreachable implementation to maintain.

lib/skills-core.mjs (5)

16-52: Frontmatter parser is intentionally minimal; consider key/format flexibility

The logic is straightforward and matches the documented name/description format, but it’s tightly constrained:

  • ^(\w+):\s*(.*)$ only matches single-word keys made of A-Za-z0-9_. If you ever introduce keys with dashes or spaces (e.g. display-name, short description), they will be silently ignored.
  • Multi-line values and more complex YAML constructs are not supported.

If the intent is to keep SKILL frontmatter strictly to simple one-line name and description, this is fine; otherwise, relaxing the key pattern (e.g. ^([^:]+):\s*(.*)$) or moving to a tiny YAML parser would future‑proof this.


62-97: Skill discovery recursion and depth handling look correct; think about error handling and symlinks

The recursive walk with maxDepth is logically sound and mirrors the existing behavior:

  • Starting at depth = 0 and stopping when depth > maxDepth gives you maxDepth + 1 directory levels, which matches the JSDoc.
  • You correctly fallback to entry.name when name is absent in frontmatter.

Two potential robustness tweaks, non-blocking for now:

  • If fs.readdirSync throws (permissions, transient FS issues), the whole scan will fail. Wrapping the body of recurse in a try/catch and skipping unreadable directories would make discovery more resilient.
  • entry.isDirectory() will skip symlinked directories. If you ever want to support skills via symlinks, you’d need an additional entry.isSymbolicLink() + fs.statSync branch.

Given current usage this is acceptable, but worth noting if discovery ever becomes more dynamic.


148-170: checkForUpdates behavior is reasonable but can still stall for ~3s

The function is defensive (3s timeout and a broad catch returning false), but keep in mind:

  • execSync is still fully blocking for up to 3 seconds on each call, which will be noticeable if checkForUpdates runs on every session bootstrap.
  • git fetch origin && git status ... assumes a remote named origin and a networked repo; bare or offline clones will always hit the catch and quietly return false.

If you ever find startup latency becoming annoying, consider switching this to an asynchronous check that races against a shorter timeout and returns immediately on timeout, or gating it behind a config/env flag. For now the behavior is acceptable but worth documenting.


178-200: stripFrontmatter logic is fine but may drop later --- blocks

This implementation will:

  • Correctly drop a single leading frontmatter block.
  • Treat any subsequent --- markers as part of the (still “in frontmatter”) section and skip them, because inFrontmatter is never reset once set.

If SKILL content never contains additional --- lines, this is fine. If it might, you could reset inFrontmatter = false once frontmatterEnded is set, or more simply detect the first two --- lines by index and slice content accordingly.

Also, now that you have stripFrontmatter in the shared core, it’s a good opportunity to replace any duplicated “strip YAML header” loops in Codex / OpenCode plugin with a call to this function.


1-3: ESM imports/exports look good; consider node: specifiers as a minor polish

The move to a pure ESM module with named exports is clean and aligns with dynamic import() from the launchers. One tiny stylistic improvement you might consider (not required):

import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';

Using node: specifiers makes it explicit that these are built‑ins and can help some tooling. Behavior is identical in modern Node.

Also applies to: 202-208

docs/plans/2025-11-22-opencode-support-implementation.md (2)

7-22: Plan doc still shows CommonJS for lib/skills-core.mjs; consider updating to ESM for accuracy

The implementation plan now calls out lib/skills-core.mjs as the shared core, but the code snippets in Tasks 1–4 still use:

  • #!/usr/bin/env node
  • const fs = require('fs');
  • module.exports = { ... }

whereas the actual module is an ESM file using import/export and not intended to be executed directly.

This is fine as a historical record of how the module used to be structured, but for future maintainers (or for anyone re-running these steps) it’s potentially misleading. I’d suggest either:

  • Updating the snippets to match the current ESM implementation (imports and named exports), or
  • Adding a short note near the top that the concrete code examples have since evolved and to refer to lib/skills-core.mjs in the repo as the source of truth.

That keeps this plan useful without sending people down the wrong path.

Also applies to: 23-81, 100-104, 185-242, 258-315


768-883: Markdownlint MD036: bold “Step” lines vs proper headings

The doc uses bold lines like **Step 1: ...** as pseudo‑headings, which triggers MD036 (“emphasis used instead of a heading”) in markdownlint. This is cosmetic, but if you want a clean lint run you could:

  • Change them to actual headings, e.g. ### Step 1: ..., or
  • Configure markdownlint to allow this style in this file.

Given this is a planning doc, I’d treat it as a low‑priority, when‑you-touch-it-next cleanup.

Also applies to: 1088-1094

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5faddc4 and ceb7acc.

📒 Files selected for processing (15)
  • .codex/INSTALL.md (1 hunks)
  • .codex/superpowers-bootstrap.md (1 hunks)
  • .codex/superpowers-codex (2 hunks)
  • .codex/superpowers-codex.cmd (1 hunks)
  • .opencode/plugin/superpowers.js (1 hunks)
  • RELEASE-NOTES.md (1 hunks)
  • docs/README.codex.md (2 hunks)
  • docs/README.opencode.md (1 hunks)
  • docs/plans/2025-11-22-opencode-support-design.md (5 hunks)
  • docs/plans/2025-11-22-opencode-support-implementation.md (13 hunks)
  • lib/skills-core.js (1 hunks)
  • lib/skills-core.mjs (1 hunks)
  • tests/opencode/run-tests.sh (1 hunks)
  • tests/opencode/test-plugin-loading.sh (1 hunks)
  • tests/opencode/test-skills-core.sh (1 hunks)
🧰 Additional context used
🪛 markdownlint-cli2 (0.18.1)
docs/plans/2025-11-22-opencode-support-implementation.md

21-21: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


88-88: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


103-103: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


170-170: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


249-249: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


322-322: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)

🔇 Additional comments (14)
.codex/INSTALL.md (1)

35-43: Windows verification commands look consistent with new launcher shim

The added .cmd and node variants align with the new Windows launcher behavior and give clear alternatives without changing existing Unix instructions.

tests/opencode/run-tests.sh (1)

45-48: Updated test description matches module rename

The help text now refers generically to “skills-core library functions”, which stays correct after moving from .js to .mjs.

.opencode/plugin/superpowers.js (1)

13-31: ESM import path update looks correct

Switching the plugin to import ../../lib/skills-core.mjs keeps the same API surface (resolveSkillPath, findSkillsInDir, etc.) while aligning with the new ES module core.

RELEASE-NOTES.md (1)

30-47: Release notes accurately describe the shared skills-core.mjs migration

The added bullets correctly capture that lib/skills-core.mjs is now the shared core for OpenCode and Codex, and that Codex was refactored to use the ES module variant.

tests/opencode/test-skills-core.sh (1)

2-4: Comment update matches how the tests exercise skills-core

The description now correctly states that it tests the “skills-core library functions” without tying it to a specific filename, which fits the current approach of inlining those helpers.

.codex/superpowers-bootstrap.md (1)

6-10: Windows and Node variants are clear and consistent with the launcher

The added bullets for the .cmd shim and direct node invocation nicely document the new Windows behavior while keeping the original Unix command as the primary example.

tests/opencode/test-plugin-loading.sh (1)

33-40: LGTM!

The test correctly updated to verify the new ESM module path (skills-core.mjs). The pass/fail messages are clear and consistent with the module rename.

docs/README.codex.md (3)

94-97: LGTM!

Clear Windows-specific guidance that addresses the core issue this PR fixes. The two options (.cmd shim and explicit node invocation) give users flexibility.


105-107: LGTM!

Documentation correctly updated to reference the new ES module path (lib/skills-core.mjs) and notes the ES module format.


139-143: LGTM!

Helpful troubleshooting section for Windows users encountering execution issues with the extensionless script.

docs/plans/2025-11-22-opencode-support-design.md (1)

32-35: LGTM!

Design document correctly updated to reference the new ES module path throughout the architecture, file structure, and implementation plan sections.

Also applies to: 203-203, 219-219, 226-226, 238-238

.codex/superpowers-codex (3)

6-8: LGTM!

Good approach using pathToFileURL for cross-platform file URL generation. The module-scoped let skillsCore is assigned before any consuming code runs due to the async main() flow.


243-276: LGTM!

The async main() pattern correctly handles the dynamic ESM import from CommonJS. The inline comment clearly explains why this approach is necessary (extensionless shebang script needing CommonJS, importing an ESM module). The command dispatch logic is clean and unchanged from before.


278-281: LGTM!

Good error handling pattern—using process.exitCode = 1 instead of process.exit(1) allows pending I/O to complete before the process exits. The error?.stack || String(error) fallback handles both Error objects and other thrown values.

Comment on lines +1 to +12
@echo off
setlocal

REM Windows shim for the extensionless Unix-style Node launcher (superpowers-codex).
REM This enables running:
REM C:\Users\dan\.codex\superpowers\.codex\superpowers-codex.cmd bootstrap
REM or (in many shells) just:
REM superpowers-codex bootstrap
REM when this directory is on PATH.

node "%~dp0superpowers-codex" %*

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Exit code is not propagated to the caller.

When node exits with a non-zero code (e.g., on error), the batch script doesn't forward it, which could mislead callers or CI scripts expecting accurate exit status.

Apply this diff to preserve the exit code:

 @echo off
 setlocal
 
 REM Windows shim for the extensionless Unix-style Node launcher (superpowers-codex).
 REM This enables running:
 REM   C:\Users\dan\.codex\superpowers\.codex\superpowers-codex.cmd bootstrap
 REM or (in many shells) just:
 REM   superpowers-codex bootstrap
 REM when this directory is on PATH.
 
-node "%~dp0superpowers-codex" %*
+node "%~dp0superpowers-codex" %*
+exit /b %errorlevel%
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@echo off
setlocal
REM Windows shim for the extensionless Unix-style Node launcher (superpowers-codex).
REM This enables running:
REM C:\Users\dan\.codex\superpowers\.codex\superpowers-codex.cmd bootstrap
REM or (in many shells) just:
REM superpowers-codex bootstrap
REM when this directory is on PATH.
node "%~dp0superpowers-codex" %*
@echo off
setlocal
REM Windows shim for the extensionless Unix-style Node launcher (superpowers-codex).
REM This enables running:
REM C:\Users\dan\.codex\superpowers\.codex\superpowers-codex.cmd bootstrap
REM or (in many shells) just:
REM superpowers-codex bootstrap
REM when this directory is on PATH.
node "%~dp0superpowers-codex" %*
exit /b %errorlevel%
🤖 Prompt for AI Agents
.codex/superpowers-codex.cmd lines 1-12: the batch shim runs node but does not
propagate node's exit code to the caller; after invoking node
"%~dp0superpowers-codex" %* capture and return node's exit code by exiting the
script with the current ERRORLEVEL (add a final line to exit /b %ERRORLEVEL% so
callers/CI receive the same exit status).

Comment on lines +108 to +140
function resolveSkillPath(skillName, superpowersDir, personalDir) {
// Strip superpowers: prefix if present
const forceSuperpowers = skillName.startsWith('superpowers:');
const actualSkillName = forceSuperpowers ? skillName.replace(/^superpowers:/, '') : skillName;

// Try personal skills first (unless explicitly superpowers:)
if (!forceSuperpowers && personalDir) {
const personalPath = path.join(personalDir, actualSkillName);
const personalSkillFile = path.join(personalPath, 'SKILL.md');
if (fs.existsSync(personalSkillFile)) {
return {
skillFile: personalSkillFile,
sourceType: 'personal',
skillPath: actualSkillName
};
}
}

// Try superpowers skills
if (superpowersDir) {
const superpowersPath = path.join(superpowersDir, actualSkillName);
const superpowersSkillFile = path.join(superpowersPath, 'SKILL.md');
if (fs.existsSync(superpowersSkillFile)) {
return {
skillFile: superpowersSkillFile,
sourceType: 'superpowers',
skillPath: actualSkillName
};
}
}

return null;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n lib/skills-core.mjs | sed -n '100,150p'

Repository: obra/superpowers

Length of output: 2360


🏁 Script executed:

rg 'resolveSkillPath' -A 3 -B 3

Repository: obra/superpowers

Length of output: 9183


🏁 Script executed:

python3 << 'EOF'
import os

# Simulate what path.join does with traversal patterns
test_cases = [
    ('../other-dir/secret', '/home/user/skills'),
    ('../../etc/passwd', '/home/user/skills'),
    ('normal-skill', '/home/user/skills'),
    ('.../something', '/home/user/skills'),
]

for skillName, baseDir in test_cases:
    # Using os.path.join (equivalent to Node's path.join)
    result = os.path.join(baseDir, skillName)
    normalized = os.path.normpath(result)
    print(f"skillName: {skillName!r}")
    print(f"  os.path.join result: {result}")
    print(f"  os.path.normpath result: {normalized}")
    print(f"  Escapes baseDir? {not normalized.startswith(baseDir)}")
    print()
EOF

Repository: obra/superpowers

Length of output: 146


🏁 Script executed:

rg 'resolveSkillPath\(' -B 5 -A 5 lib/ --type js --type mjs | head -100

Repository: obra/superpowers

Length of output: 87


🏁 Script executed:

rg 'skill.?name|skillName' lib/ --type js --type mjs -B 2 -A 2

Repository: obra/superpowers

Length of output: 87


🏁 Script executed:

rg 'superpowersDir|personalDir' lib/ --type js --type mjs -B 3 -A 3

Repository: obra/superpowers

Length of output: 87


🏁 Script executed:

rg 'resolveSkillPath' lib/ -B 5 -A 5

Repository: obra/superpowers

Length of output: 2421


🏁 Script executed:

git ls-files lib/ | grep -E '\.(mjs|js)$'

Repository: obra/superpowers

Length of output: 98


🏁 Script executed:

rg 'resolveSkillPath' --type-list | head -20

Repository: obra/superpowers

Length of output: 449


🏁 Script executed:

find lib -name "*.mjs" -o -name "*.js" | xargs grep -l "resolveSkillPath"

Repository: obra/superpowers

Length of output: 98


🏁 Script executed:

grep -r "resolveSkillPath" . --include="*.js" --include="*.mjs" --include="*.ts" --include="*.jsx" --include="*.mts"

Repository: obra/superpowers

Length of output: 622


🏁 Script executed:

ls -la | grep -E '\.(js|mjs)$'

Repository: obra/superpowers

Length of output: 42


🏁 Script executed:

find . -name "*.mjs" -o -name "*.js" | head -20 | xargs cat | grep -A 10 -B 5 "skill.?name"

Repository: obra/superpowers

Length of output: 42


🏁 Script executed:

cat -n package.json | head -50

Repository: obra/superpowers

Length of output: 104


🏁 Script executed:

cat -n .opencode/plugin/superpowers.js | head -100

Repository: obra/superpowers

Length of output: 4841


🏁 Script executed:

grep -B 20 -A 10 "resolveSkillPath" .opencode/plugin/superpowers.js

Repository: obra/superpowers

Length of output: 2808


🏁 Script executed:

grep -B 50 "skill_name" .opencode/plugin/superpowers.js | head -80

Repository: obra/superpowers

Length of output: 3178


🏁 Script executed:

node << 'EOF'
const path = require('path');

// Test cases simulating the vulnerability
const baseDir = '/home/user/skills';
const testCases = [
  '../../../etc/passwd',
  '../../.ssh/id_rsa',
  'normal-skill',
  '../other-dir/secret',
];

console.log('Testing path.join behavior:');
testCases.forEach(skillName => {
  const joined = path.join(baseDir, skillName);
  const normalized = path.normalize(joined);
  const startsWith = normalized.startsWith(baseDir);
  console.log(`\nskillName: ${skillName}`);
  console.log(`  path.join result: ${joined}`);
  console.log(`  path.normalize: ${normalized}`);
  console.log(`  Stays within baseDir? ${startsWith}`);
});
EOF

Repository: obra/superpowers

Length of output: 655


🏁 Script executed:

cat -n .opencode/plugin/superpowers.js | sed -n '86,120p'

Repository: obra/superpowers

Length of output: 1844


Fix path traversal vulnerability in resolveSkillPath and plugin's use_skill tool

resolveSkillPath and the calling code in .opencode/plugin/superpowers.js (lines 98, 111) directly join user-supplied skillName into base directories without validation. Node's path.join() allows .. components to escape intended roots—a user passing ../../../etc/passwd can read arbitrary files.

Both locations need sanitization:

if (actualSkillName.includes('/') || actualSkillName.includes(path.sep)) {
  return null;
}

Or validate the resolved path stays within the base directory:

const resolved = path.resolve(baseDir, actualSkillName);
if (!resolved.startsWith(path.resolve(baseDir))) {
  return null;
}

The first approach (reject separators) is simpler and aligns with skill naming conventions.

🤖 Prompt for AI Agents
In lib/skills-core.mjs around lines 108-140 (and mirror the same validation in
.opencode/plugin/superpowers.js at the calls on lines ~98 and ~111), the
function currently joins user-supplied skillName into base dirs allowing path
traversal; reject or sanitize inputs before joining: validate actualSkillName
does not contain path separators or parent‑up segments (e.g., contains '/' or
path.sep or '..') and return null if it does, or alternatively resolve the
candidate path against the base dir and ensure the resolved path starts with the
base dir's resolved path; apply the same check in the plugin before calling
resolveSkillPath so malicious names are rejected early.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants