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.
Files changed (117) hide show
  1. package/.beads/.local_version +1 -0
  2. package/.beads/README.md +81 -0
  3. package/.beads/config.yaml +61 -0
  4. package/.beads/deletions.jsonl +1 -0
  5. package/.beads/issues.jsonl +64 -0
  6. package/.beads/metadata.json +4 -0
  7. package/.gitattributes +3 -0
  8. package/.github/CODEOWNERS +1 -0
  9. package/.github/copilot-instructions.md +78 -0
  10. package/.github/dependabot.yml +13 -0
  11. package/.github/workflows/release.yml +51 -0
  12. package/.opencode/command/test-compaction.md +9 -0
  13. package/.opencode/command/test-find-skills.md +7 -0
  14. package/.opencode/command/test-read-skill-file.md +14 -0
  15. package/.opencode/command/test-run-skill-script.md +13 -0
  16. package/.opencode/command/test-skills.md +14 -0
  17. package/.opencode/command/test-use-skill.md +10 -0
  18. package/.opencode/skills/git-helper/SKILL.md +65 -0
  19. package/.opencode/skills/test-skill/SKILL.md +43 -0
  20. package/.opencode/skills/test-skill/example-config.json +16 -0
  21. package/.opencode/skills/test-skill/helper-docs.md +29 -0
  22. package/.opencode/skills/test-skill/scripts/echo-args +14 -0
  23. package/.opencode/skills/test-skill/scripts/greet +6 -0
  24. package/AGENTS.md +43 -0
  25. package/CHANGELOG.md +178 -0
  26. package/Justfile +39 -0
  27. package/LICENSE +9 -0
  28. package/README.md +189 -0
  29. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
  30. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
  31. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
  32. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
  33. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
  34. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
  35. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
  36. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
  37. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
  38. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
  39. package/openspec/specs/core-decoupling/spec.md +110 -0
  40. package/package.json +35 -0
  41. package/packages/core/package.json +30 -0
  42. package/packages/core/src/content.d.ts +16 -0
  43. package/packages/core/src/content.ts +30 -0
  44. package/packages/core/src/debug.ts +16 -0
  45. package/packages/core/src/discovery.d.ts +86 -0
  46. package/packages/core/src/discovery.ts +257 -0
  47. package/packages/core/src/index.d.ts +20 -0
  48. package/packages/core/src/index.ts +55 -0
  49. package/packages/core/src/match.d.ts +19 -0
  50. package/packages/core/src/match.ts +75 -0
  51. package/packages/core/src/parse.d.ts +26 -0
  52. package/packages/core/src/parse.ts +141 -0
  53. package/packages/core/src/scripts.d.ts +17 -0
  54. package/packages/core/src/scripts.ts +79 -0
  55. package/packages/core/src/search.d.ts +83 -0
  56. package/packages/core/src/search.ts +188 -0
  57. package/packages/core/src/types.d.ts +82 -0
  58. package/packages/core/src/types.ts +131 -0
  59. package/packages/core/src/walk.ts +109 -0
  60. package/packages/core/tests/agnostic.test.ts +346 -0
  61. package/packages/core/tests/content.test.ts +65 -0
  62. package/packages/core/tests/discovery.test.ts +370 -0
  63. package/packages/core/tests/package-boundary.test.ts +310 -0
  64. package/packages/core/tests/parse-trigger.test.ts +282 -0
  65. package/packages/core/tests/search.test.ts +374 -0
  66. package/packages/core/tests/subpath.test.ts +87 -0
  67. package/packages/core/tsconfig.json +10 -0
  68. package/packages/opencode-agent-skills-md/package.json +42 -0
  69. package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
  70. package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
  71. package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
  72. package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
  73. package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
  74. package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
  75. package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
  76. package/packages/opencode-agent-skills-md/src/host.ts +119 -0
  77. package/packages/opencode-agent-skills-md/src/index.ts +25 -0
  78. package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
  79. package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
  80. package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
  81. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
  82. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
  83. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
  84. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
  95. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
  96. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
  97. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
  98. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
  99. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
  100. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
  101. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
  102. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
  103. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
  104. package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
  105. package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
  106. package/plans/001-ci-gate.md +177 -0
  107. package/plans/002-is-path-safe.md +243 -0
  108. package/plans/003-escape-prompts.md +310 -0
  109. package/plans/004-test-security-paths.md +228 -0
  110. package/plans/005-stop-swallowing-errors.md +246 -0
  111. package/plans/006-preserve-jsonc-commas.md +144 -0
  112. package/plans/007-write-before-purge.md +144 -0
  113. package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
  114. package/plans/README.md +43 -0
  115. package/pnpm-workspace.yaml +6 -0
  116. package/tests/workspace.test.ts +367 -0
  117. 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).