opencode-agent-skills-md 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.beads/.local_version +1 -0
- package/.beads/README.md +81 -0
- package/.beads/config.yaml +61 -0
- package/.beads/deletions.jsonl +1 -0
- package/.beads/issues.jsonl +64 -0
- package/.beads/metadata.json +4 -0
- package/.gitattributes +3 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/copilot-instructions.md +78 -0
- package/.github/dependabot.yml +13 -0
- package/.github/workflows/release.yml +51 -0
- package/.opencode/command/test-compaction.md +9 -0
- package/.opencode/command/test-find-skills.md +7 -0
- package/.opencode/command/test-read-skill-file.md +14 -0
- package/.opencode/command/test-run-skill-script.md +13 -0
- package/.opencode/command/test-skills.md +14 -0
- package/.opencode/command/test-use-skill.md +10 -0
- package/.opencode/skills/git-helper/SKILL.md +65 -0
- package/.opencode/skills/test-skill/SKILL.md +43 -0
- package/.opencode/skills/test-skill/example-config.json +16 -0
- package/.opencode/skills/test-skill/helper-docs.md +29 -0
- package/.opencode/skills/test-skill/scripts/echo-args +14 -0
- package/.opencode/skills/test-skill/scripts/greet +6 -0
- package/AGENTS.md +43 -0
- package/CHANGELOG.md +178 -0
- package/Justfile +39 -0
- package/LICENSE +9 -0
- package/README.md +189 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
- package/openspec/specs/core-decoupling/spec.md +110 -0
- package/package.json +35 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/content.d.ts +16 -0
- package/packages/core/src/content.ts +30 -0
- package/packages/core/src/debug.ts +16 -0
- package/packages/core/src/discovery.d.ts +86 -0
- package/packages/core/src/discovery.ts +257 -0
- package/packages/core/src/index.d.ts +20 -0
- package/packages/core/src/index.ts +55 -0
- package/packages/core/src/match.d.ts +19 -0
- package/packages/core/src/match.ts +75 -0
- package/packages/core/src/parse.d.ts +26 -0
- package/packages/core/src/parse.ts +141 -0
- package/packages/core/src/scripts.d.ts +17 -0
- package/packages/core/src/scripts.ts +79 -0
- package/packages/core/src/search.d.ts +83 -0
- package/packages/core/src/search.ts +188 -0
- package/packages/core/src/types.d.ts +82 -0
- package/packages/core/src/types.ts +131 -0
- package/packages/core/src/walk.ts +109 -0
- package/packages/core/tests/agnostic.test.ts +346 -0
- package/packages/core/tests/content.test.ts +65 -0
- package/packages/core/tests/discovery.test.ts +370 -0
- package/packages/core/tests/package-boundary.test.ts +310 -0
- package/packages/core/tests/parse-trigger.test.ts +282 -0
- package/packages/core/tests/search.test.ts +374 -0
- package/packages/core/tests/subpath.test.ts +87 -0
- package/packages/core/tsconfig.json +10 -0
- package/packages/opencode-agent-skills-md/package.json +42 -0
- package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
- package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
- package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
- package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
- package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
- package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
- package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
- package/packages/opencode-agent-skills-md/src/host.ts +119 -0
- package/packages/opencode-agent-skills-md/src/index.ts +25 -0
- package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
- package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
- package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
- package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
- package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
- package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
- package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
- package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
- package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
- package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
- package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
- package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
- package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
- package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
- package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
- package/plans/001-ci-gate.md +177 -0
- package/plans/002-is-path-safe.md +243 -0
- package/plans/003-escape-prompts.md +310 -0
- package/plans/004-test-security-paths.md +228 -0
- package/plans/005-stop-swallowing-errors.md +246 -0
- package/plans/006-preserve-jsonc-commas.md +144 -0
- package/plans/007-write-before-purge.md +144 -0
- package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
- package/plans/README.md +43 -0
- package/pnpm-workspace.yaml +6 -0
- package/tests/workspace.test.ts +367 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Plan 001: Add CI gating before release
|
|
2
|
+
|
|
3
|
+
> **Executor instructions**: Follow this plan step by step. Run every
|
|
4
|
+
> verification command and confirm the expected result before moving to the
|
|
5
|
+
> next step. If anything in the "STOP conditions" section occurs, stop and
|
|
6
|
+
> report — do not improvise. When done, update the status row for this plan
|
|
7
|
+
> in `plans/README.md`.
|
|
8
|
+
>
|
|
9
|
+
> **Drift check (run first)**: `git diff --stat fb45791..HEAD -- .github/ README.md packages/opencode-agent-skills-md/package.json`
|
|
10
|
+
> If any in-scope file changed since this plan was written, compare the
|
|
11
|
+
> "Current state" excerpts against the live code before proceeding; on a
|
|
12
|
+
> mismatch, treat it as a STOP condition.
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
- **Priority**: P1
|
|
17
|
+
- **Effort**: S
|
|
18
|
+
- **Risk**: LOW
|
|
19
|
+
- **Depends on**: none
|
|
20
|
+
- **Category**: dx
|
|
21
|
+
- **Planned at**: commit `fb45791`, 2026-06-29
|
|
22
|
+
|
|
23
|
+
## Why this matters
|
|
24
|
+
|
|
25
|
+
The repo has zero CI workflows — the `release.yml` referenced in the README
|
|
26
|
+
badge and as a GitHub Actions link does not exist in the repo. The release
|
|
27
|
+
pipeline (prepublish-only, if any) never runs tests. Regressions in the
|
|
28
|
+
high-churn discovery/search/plugin cycle ship silently. This plan adds a
|
|
29
|
+
CI workflow that runs typecheck + test on push/PR, and fixes the
|
|
30
|
+
`pretest` script in the plugin package that uses `npm run build` instead of
|
|
31
|
+
`pnpm run build`.
|
|
32
|
+
|
|
33
|
+
## Current state
|
|
34
|
+
|
|
35
|
+
- `.github/workflows/` — directory does not exist in the repo.
|
|
36
|
+
- `README.md:4` — badge references `release.yml` workflow that does not exist.
|
|
37
|
+
- `packages/opencode-agent-skills-md/package.json:21` — `"pretest": "npm run build"` uses `npm` in a pnpm workspace.
|
|
38
|
+
|
|
39
|
+
Conventions:
|
|
40
|
+
- Existing scripts in `package.json` use pnpm commands. Test runner is `node --import tsx --test`.
|
|
41
|
+
- Install is `pnpm install`. Root manifest is private and delegates to packages via `pnpm -r`.
|
|
42
|
+
|
|
43
|
+
## Commands you will need
|
|
44
|
+
|
|
45
|
+
| Purpose | Command | Expected on success |
|
|
46
|
+
|-----------|--------------------------|---------------------|
|
|
47
|
+
| Install | `pnpm install` | exit 0 |
|
|
48
|
+
| Typecheck | `pnpm run typecheck` | exit 0, no errors |
|
|
49
|
+
| Tests | `pnpm test` | all pass |
|
|
50
|
+
|
|
51
|
+
## Scope
|
|
52
|
+
|
|
53
|
+
**In scope** (the only files you should modify):
|
|
54
|
+
- `packages/opencode-agent-skills-md/package.json` — fix `pretest` script
|
|
55
|
+
- `README.md` — update stale workflow badge or remove it
|
|
56
|
+
- `.github/workflows/ci.yml` — create CI workflow
|
|
57
|
+
- `.github/workflows/release.yml` — create release workflow (optional, only if you have context on the release process)
|
|
58
|
+
|
|
59
|
+
**Out of scope** (do NOT touch):
|
|
60
|
+
- Any source code in `packages/core/src/` or `packages/opencode-agent-skills-md/src/`
|
|
61
|
+
- Any test files — those are covered in other plans
|
|
62
|
+
- Any config files not listed above
|
|
63
|
+
|
|
64
|
+
## Git workflow
|
|
65
|
+
|
|
66
|
+
- Branch: `advisor/001-ci-gate`
|
|
67
|
+
- Commit per logical step; message style: conventional commits (e.g. `dx: add CI workflow`, `fix: use pnpm in pretest hook`)
|
|
68
|
+
- Do NOT push or open a PR unless instructed.
|
|
69
|
+
|
|
70
|
+
## Steps
|
|
71
|
+
|
|
72
|
+
### Step 1: Fix `pretest` script in plugin package
|
|
73
|
+
|
|
74
|
+
Change `"pretest": "npm run build"` to `"pretest": "pnpm run build"` in
|
|
75
|
+
`packages/opencode-agent-skills-md/package.json`. This ensures the build runs
|
|
76
|
+
with the pnpm workspace resolver, not npm.
|
|
77
|
+
|
|
78
|
+
**Verify**: `grep '"pretest"' packages/opencode-agent-skills-md/package.json` → `"pretest": "pnpm run build"`
|
|
79
|
+
|
|
80
|
+
### Step 2: Create CI workflow
|
|
81
|
+
|
|
82
|
+
Create `.github/workflows/ci.yml`:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
name: CI
|
|
86
|
+
|
|
87
|
+
on:
|
|
88
|
+
push:
|
|
89
|
+
branches: [main]
|
|
90
|
+
pull_request:
|
|
91
|
+
branches: [main]
|
|
92
|
+
|
|
93
|
+
jobs:
|
|
94
|
+
check:
|
|
95
|
+
runs-on: ubuntu-latest
|
|
96
|
+
strategy:
|
|
97
|
+
matrix:
|
|
98
|
+
node-version: [18, 20, 22]
|
|
99
|
+
steps:
|
|
100
|
+
- uses: actions/checkout@v4
|
|
101
|
+
- uses: pnpm/action-setup@v4
|
|
102
|
+
- uses: actions/setup-node@v4
|
|
103
|
+
with:
|
|
104
|
+
node-version: ${{ matrix.node-version }}
|
|
105
|
+
cache: pnpm
|
|
106
|
+
- run: pnpm install
|
|
107
|
+
- run: pnpm run typecheck
|
|
108
|
+
- run: pnpm test
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The workflow runs on push/PR to `main`, installs deps via pnpm, then runs
|
|
112
|
+
typecheck and test across a Node 18/20/22 matrix.
|
|
113
|
+
|
|
114
|
+
**Verify**: `ls .github/workflows/ci.yml` → file exists
|
|
115
|
+
|
|
116
|
+
### Step 3: Update README badge
|
|
117
|
+
|
|
118
|
+
The README line 4 has a badge with a `release.yml` workflow reference that
|
|
119
|
+
does not exist in the repo. Either:
|
|
120
|
+
- Replace the badge with one pointing to the new `ci.yml` workflow, OR
|
|
121
|
+
- If you also create a `release.yml` (out of scope for this plan), keep it.
|
|
122
|
+
|
|
123
|
+
Update the badge at `README.md:4`:
|
|
124
|
+
|
|
125
|
+
Old:
|
|
126
|
+
```
|
|
127
|
+
<a href="https://github.com/MetalbolicX/opencode-agent-skills-md/actions/workflows/release.yml"><img alt="release" src="..."/></a>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
New — point to the CI workflow:
|
|
131
|
+
```
|
|
132
|
+
<a href="https://github.com/MetalbolicX/opencode-agent-skills-md/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/MetalbolicX/opencode-agent-skills-md/ci.yml?style=flat-square&logo=githubactions&label=ci" /></a>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Verify**: `grep 'actions/workflows/ci.yml' README.md` → matches
|
|
136
|
+
|
|
137
|
+
### Step 4: Verify everything still works
|
|
138
|
+
|
|
139
|
+
Run the full verification suite from the repo root.
|
|
140
|
+
|
|
141
|
+
**Verify**:
|
|
142
|
+
- `pnpm install` → exit 0
|
|
143
|
+
- `pnpm run typecheck` → exit 0, no errors
|
|
144
|
+
- `pnpm test` → exit 0, all tests pass
|
|
145
|
+
- `git status` — only the three in-scope files are modified
|
|
146
|
+
|
|
147
|
+
## Test plan
|
|
148
|
+
|
|
149
|
+
No new tests needed for this plan — it only adds automation infrastructure.
|
|
150
|
+
The existing test suite must pass unchanged.
|
|
151
|
+
|
|
152
|
+
## Done criteria
|
|
153
|
+
|
|
154
|
+
Machine-checkable. ALL must hold:
|
|
155
|
+
|
|
156
|
+
- [ ] `.github/workflows/ci.yml` exists and follows the pattern above
|
|
157
|
+
- [ ] `packages/opencode-agent-skills-md/package.json` uses `pnpm run build` in pretest
|
|
158
|
+
- [ ] `README.md` references `ci.yml` badge (not a missing `release.yml`)
|
|
159
|
+
- [ ] `pnpm install` exits 0
|
|
160
|
+
- [ ] `pnpm run typecheck` exits 0
|
|
161
|
+
- [ ] `pnpm test` exits 0
|
|
162
|
+
- [ ] No files outside the in-scope list are modified (`git status`)
|
|
163
|
+
- [ ] `plans/README.md` status row updated
|
|
164
|
+
|
|
165
|
+
## STOP conditions
|
|
166
|
+
|
|
167
|
+
Stop and report back (do not improvise) if:
|
|
168
|
+
|
|
169
|
+
- The README badge URL or the GitHub org/repo name differs from the excerpts above — the badge URL format may need adjustment.
|
|
170
|
+
- Creating `.github/workflows/ci.yml` causes any existing hook or lint step to fail.
|
|
171
|
+
- A step's verification fails twice after a reasonable fix attempt.
|
|
172
|
+
- The fix appears to require touching an out-of-scope file.
|
|
173
|
+
|
|
174
|
+
## Maintenance notes
|
|
175
|
+
|
|
176
|
+
- The Node version matrix (18, 20, 22) matches the engine constraint `>=18.0.0` in the plugin `package.json`. Update if the minimum engine changes.
|
|
177
|
+
- If a `release.yml` is added later, move the test/typecheck steps into a shared composite action to avoid duplication.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Plan 002: Harden `isPathSafe` against symlink escapes
|
|
2
|
+
|
|
3
|
+
> **Executor instructions**: Follow this plan step by step. Run every
|
|
4
|
+
> verification command and confirm the expected result before moving to the
|
|
5
|
+
> next step. If anything in the "STOP conditions" section occurs, stop and
|
|
6
|
+
> report — do not improvise. When done, update the status row for this plan
|
|
7
|
+
> in `plans/README.md`.
|
|
8
|
+
>
|
|
9
|
+
> **Drift check (run first)**: `git diff --stat fb45791..HEAD -- packages/core/src/scripts.ts`
|
|
10
|
+
> If any in-scope file changed since this plan was written, compare the
|
|
11
|
+
> "Current state" excerpts against the live code before proceeding; on a
|
|
12
|
+
> mismatch, treat it as a STOP condition.
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
- **Priority**: P1
|
|
17
|
+
- **Effort**: S
|
|
18
|
+
- **Risk**: MED
|
|
19
|
+
- **Depends on**: none
|
|
20
|
+
- **Category**: security
|
|
21
|
+
- **Planned at**: commit `fb45791`, 2026-06-29
|
|
22
|
+
|
|
23
|
+
## Why this matters
|
|
24
|
+
|
|
25
|
+
`isPathSafe` in `packages/core/src/scripts.ts:64-66` guards the path check for
|
|
26
|
+
`read_skill_file` and `run_skill_script` (called at `tools.ts:196`). It uses
|
|
27
|
+
`path.resolve` + `startsWith` which does NOT resolve symlinks. A malicious
|
|
28
|
+
skill containing a symlink pointing outside its directory (e.g.,
|
|
29
|
+
`ln -s /etc/passwd secrets.txt`) would pass the guard. The real path would
|
|
30
|
+
escape the skill directory, leaking arbitrary file content to the LLM session.
|
|
31
|
+
|
|
32
|
+
## Current state
|
|
33
|
+
|
|
34
|
+
`packages/core/src/scripts.ts:64-66`:
|
|
35
|
+
```ts
|
|
36
|
+
export function isPathSafe(basePath: string, requestedPath: string): boolean {
|
|
37
|
+
const resolved = path.resolve(basePath, requestedPath);
|
|
38
|
+
return resolved.startsWith(basePath + path.sep) || resolved === basePath;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This uses `path.resolve` which does NOT canonicalize symlinks. The fix is to
|
|
43
|
+
use `fs.realpath` on both paths and compare the resolved real paths.
|
|
44
|
+
|
|
45
|
+
Testing conventions (see `packages/core/tests/agnostic.test.ts`):
|
|
46
|
+
- `import assert from "node:assert/strict"`
|
|
47
|
+
- `import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises"`
|
|
48
|
+
- Tests create temp workspaces with `mkdtemp` and clean up in `after` blocks
|
|
49
|
+
- The shared walker tests are in `packages/core/tests/discovery.test.ts`
|
|
50
|
+
|
|
51
|
+
The `symlink` function from `node:fs/promises` is available — use it to create
|
|
52
|
+
test symlinks. For the realpath call, `fs.realpath` returns the canonical path.
|
|
53
|
+
|
|
54
|
+
## Commands you will need
|
|
55
|
+
|
|
56
|
+
| Purpose | Command | Expected on success |
|
|
57
|
+
|-----------|------------------------------------|---------------------|
|
|
58
|
+
| Typecheck | `pnpm run typecheck` | exit 0, no errors |
|
|
59
|
+
| Core test | `pnpm -F opencode-agent-skills-md-core exec node --import tsx --test tests/scripts.test.ts` | all pass |
|
|
60
|
+
|
|
61
|
+
## Scope
|
|
62
|
+
|
|
63
|
+
**In scope** (the only files you should modify):
|
|
64
|
+
- `packages/core/src/scripts.ts` — add realpath-based safety check
|
|
65
|
+
- `packages/core/tests/scripts.test.ts` — create test file (if it doesn't exist) or add to existing test
|
|
66
|
+
|
|
67
|
+
**Out of scope** (do NOT touch):
|
|
68
|
+
- `packages/opencode-agent-skills-md/src/tools.ts` — the call site doesn't change
|
|
69
|
+
- Other core source files or test files
|
|
70
|
+
- The existing `isPathSafe` export and signature must stay the same
|
|
71
|
+
|
|
72
|
+
## Git workflow
|
|
73
|
+
|
|
74
|
+
- Branch: `advisor/002-is-path-safe`
|
|
75
|
+
- Commit per logical step; message style: conventional commits
|
|
76
|
+
- Do NOT push or open a PR unless instructed.
|
|
77
|
+
|
|
78
|
+
## Steps
|
|
79
|
+
|
|
80
|
+
### Step 1: Harden `isPathSafe` in `scripts.ts`
|
|
81
|
+
|
|
82
|
+
Modify `packages/core/src/scripts.ts` to import `fs.realpath` and use it
|
|
83
|
+
inside `isPathSafe`:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import * as fs from "node:fs/promises"; // already imported
|
|
87
|
+
|
|
88
|
+
export async function isPathSafe(basePath: string, requestedPath: string): Promise<boolean> {
|
|
89
|
+
const resolved = path.resolve(basePath, requestedPath);
|
|
90
|
+
try {
|
|
91
|
+
const resolvedReal = await fs.realpath(resolved);
|
|
92
|
+
const baseReal = await fs.realpath(basePath);
|
|
93
|
+
return resolvedReal.startsWith(baseReal + path.sep) || resolvedReal === baseReal;
|
|
94
|
+
} catch {
|
|
95
|
+
return false; // ENOENT on the requested path means we can't verify safety
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Key changes**:
|
|
101
|
+
1. Add `async` to the function signature — it now returns `Promise<boolean>`.
|
|
102
|
+
2. Use `fs.realpath` on both `resolved` and `basePath` to resolve symlinks.
|
|
103
|
+
3. Compare the resolved real paths instead of the logical paths.
|
|
104
|
+
4. `catch` returns `false` (can't verify safety of a missing/broken path).
|
|
105
|
+
|
|
106
|
+
**Verify**: `grep 'async function isPathSafe' packages/core/src/scripts.ts` → matches
|
|
107
|
+
|
|
108
|
+
### Step 2: Update all callers of `isPathSafe`
|
|
109
|
+
|
|
110
|
+
Find every call to `isPathSafe` in the codebase and add `await` since the
|
|
111
|
+
function is now async:
|
|
112
|
+
|
|
113
|
+
1. `packages/opencode-agent-skills-md/src/tools.ts:196`:
|
|
114
|
+
Change `if (!isPathSafe(skill.path, args.filename))` to
|
|
115
|
+
`if (!(await isPathSafe(skill.path, args.filename)))`
|
|
116
|
+
|
|
117
|
+
**Verify**: `grep -rn 'isPathSafe' packages/` — all uses should have `await`
|
|
118
|
+
|
|
119
|
+
### Step 3: Update the `index.ts` re-export if `isPathSafe` is there
|
|
120
|
+
|
|
121
|
+
Check `packages/core/src/index.ts` — if `isPathSafe` is re-exported, no change
|
|
122
|
+
needed (the export is still valid, just the return type changed to
|
|
123
|
+
`Promise<boolean>`).
|
|
124
|
+
|
|
125
|
+
**Verify**: `grep 'isPathSafe' packages/core/src/index.ts` — should still export it
|
|
126
|
+
|
|
127
|
+
### Step 4: Typecheck
|
|
128
|
+
|
|
129
|
+
**Verify**: `pnpm run typecheck` → exit 0, no errors
|
|
130
|
+
|
|
131
|
+
### Step 5: Create/update tests
|
|
132
|
+
|
|
133
|
+
Create `packages/core/tests/scripts.test.ts` following the existing test
|
|
134
|
+
pattern from `packages/core/tests/discovery.test.ts`:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import assert from "node:assert/strict";
|
|
138
|
+
import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises";
|
|
139
|
+
import { tmpdir } from "node:os";
|
|
140
|
+
import * as path from "node:path";
|
|
141
|
+
import { after, before, describe, test } from "node:test";
|
|
142
|
+
|
|
143
|
+
describe("isPathSafe", () => {
|
|
144
|
+
let workspace: string;
|
|
145
|
+
|
|
146
|
+
before(async () => {
|
|
147
|
+
workspace = await mkdtemp(path.join(tmpdir(), "ispathsafe-"));
|
|
148
|
+
await mkdir(path.join(workspace, "skill"), { recursive: true });
|
|
149
|
+
await mkdir(path.join(workspace, "other"), { recursive: true });
|
|
150
|
+
// Create a real file inside the skill directory
|
|
151
|
+
await writeFile(path.join(workspace, "skill", "real-file.txt"), "safe", "utf8");
|
|
152
|
+
// Create a symlink inside skill that points outside
|
|
153
|
+
await symlink(
|
|
154
|
+
path.join(workspace, "other", "outside.txt"),
|
|
155
|
+
path.join(workspace, "skill", "bad-link.txt")
|
|
156
|
+
);
|
|
157
|
+
// Create a file outside that the symlink targets
|
|
158
|
+
await writeFile(path.join(workspace, "other", "outside.txt"), "leaked", "utf8");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
after(async () => {
|
|
162
|
+
if (workspace) await rm(workspace, { recursive: true, force: true });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Dynamic import to get the async function
|
|
166
|
+
const importModule = async () => import("../src/scripts");
|
|
167
|
+
|
|
168
|
+
test("allows files within the skill directory", async () => {
|
|
169
|
+
const { isPathSafe } = await importModule();
|
|
170
|
+
const result = await isPathSafe(path.join(workspace, "skill"), "real-file.txt");
|
|
171
|
+
assert.equal(result, true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("allows the base path itself", async () => {
|
|
175
|
+
const { isPathSafe } = await importModule();
|
|
176
|
+
const result = await isPathSafe(path.join(workspace, "skill"), "");
|
|
177
|
+
assert.equal(result, true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("rejects path traversal with ..", async () => {
|
|
181
|
+
const { isPathSafe } = await importModule();
|
|
182
|
+
const result = await isPathSafe(path.join(workspace, "skill"), "../other/outside.txt");
|
|
183
|
+
assert.equal(result, false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("rejects symlink that points outside the skill directory", async () => {
|
|
187
|
+
const { isPathSafe } = await importModule();
|
|
188
|
+
const result = await isPathSafe(path.join(workspace, "skill"), "bad-link.txt");
|
|
189
|
+
assert.equal(result, false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("rejects non-existent paths", async () => {
|
|
193
|
+
const { isPathSafe } = await importModule();
|
|
194
|
+
const result = await isPathSafe(path.join(workspace, "skill"), "does-not-exist.txt");
|
|
195
|
+
assert.equal(result, false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Verify**: `pnpm -F opencode-agent-skills-md-core exec node --import tsx --test tests/scripts.test.ts` → all 5 tests pass
|
|
201
|
+
|
|
202
|
+
### Step 6: Run full test suite
|
|
203
|
+
|
|
204
|
+
**Verify**: `pnpm test` → exit 0, all tests pass
|
|
205
|
+
|
|
206
|
+
## Test plan
|
|
207
|
+
|
|
208
|
+
- **New file**: `packages/core/tests/scripts.test.ts` with 5 test cases:
|
|
209
|
+
1. Happy path: file within skill directory is allowed
|
|
210
|
+
2. Edge case: the base path itself is allowed
|
|
211
|
+
3. Regression: `..` traversal is blocked
|
|
212
|
+
4. Security: symlink escape is blocked (the core reason for this plan)
|
|
213
|
+
5. Edge case: non-existent paths return `false`
|
|
214
|
+
- Model after `packages/core/tests/discovery.test.ts` for structure
|
|
215
|
+
|
|
216
|
+
## Done criteria
|
|
217
|
+
|
|
218
|
+
Machine-checkable. ALL must hold:
|
|
219
|
+
|
|
220
|
+
- [ ] `isPathSafe` is async and returns `Promise<boolean>`
|
|
221
|
+
- [ ] `isPathSafe` uses `fs.realpath` to resolve symlinks before comparing
|
|
222
|
+
- [ ] All callers use `await` with `isPathSafe`
|
|
223
|
+
- [ ] `pnpm run typecheck` exits 0
|
|
224
|
+
- [ ] `pnpm test` exits 0; new tests for symlink safety exist and pass
|
|
225
|
+
- [ ] No files outside the in-scope list are modified
|
|
226
|
+
- [ ] `plans/README.md` status row updated
|
|
227
|
+
|
|
228
|
+
## STOP conditions
|
|
229
|
+
|
|
230
|
+
Stop and report back (do not improvise) if:
|
|
231
|
+
|
|
232
|
+
- The code in `scripts.ts` at the locations above doesn't match the excerpts.
|
|
233
|
+
- A step's verification fails twice after a reasonable fix attempt.
|
|
234
|
+
- You discover that `fs.realpath` is not available in Node ≥18 (it is available since Node 10).
|
|
235
|
+
- `isPathSafe` is exported from `packages/core/src/index.ts` and changing its return type breaks the plugin package's typecheck (it should not, since all callers are in async functions).
|
|
236
|
+
|
|
237
|
+
## Maintenance notes
|
|
238
|
+
|
|
239
|
+
- If `isPathSafe` is called from synchronous contexts in the future, the
|
|
240
|
+
caller will need to handle the `Promise<boolean>` return.
|
|
241
|
+
- The `fs.realpath` approach handles dangling symlinks correctly (ENOENT → `false`).
|
|
242
|
+
- Revisit if Node introduces a `fs.realpathSync` variant with better performance
|
|
243
|
+
for hot paths (currently `isPathSafe` is called on every `read_skill_file` invocation).
|