Skip to content

feat(extension): add extension registry store module#299

Open
margaretjgu wants to merge 8 commits into
mainfrom
feat/extension-store
Open

feat(extension): add extension registry store module#299
margaretjgu wants to merge 8 commits into
mainfrom
feat/extension-store

Conversation

@margaretjgu
Copy link
Copy Markdown
Member

Summary

Closes #293. Part of #222.

  • Adds src/extension/store.ts with five exports: readExtensions, writeExtensions, findExtension, upsertExtension, removeExtension
  • Registry persisted as JSON at ~/.elastic/extensions.json; parent directory is created automatically
  • Missing registry file treated as empty (no error on first use)
  • _testSetRegistryPath seam lets tests redirect to a tmp dir without touching the real home directory
  • 14 unit tests covering all public functions across empty, single, and multi-entry states

Test plan

  • npx tsc --noEmit passes clean
  • node --import tsx/esm --test test/extension/store.test.ts -- 14/14 pass

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 12, 2026

MegaLinter analysis: Success

Descriptor Linter Files Fixed Errors Warnings Elapsed time
✅ COPYPASTE jscpd yes no no 7.18s
✅ REPOSITORY gitleaks yes no no 55.34s
✅ REPOSITORY git_diff yes no no 0.7s
✅ REPOSITORY secretlint yes no no 28.04s
✅ REPOSITORY trivy yes no no 18.1s
✅ TYPESCRIPT eslint 2 0 0 3.82s

See detailed reports in MegaLinter artifacts
Set VALIDATE_ALL_CODEBASE: true in mega-linter.yml to validate all sources, not only the diff

MegaLinter is graciously provided by OX Security
Show us your support by starring ⭐ the repository

@MattDevy
Copy link
Copy Markdown
Contributor

Alternative design: filesystem as registry (no extensions.json)

After looking at how gh extension handles this, I'd suggest reconsidering the central extensions.json approach in favour of a filesystem-based registry. The current design has a security gap: any extension (running as the same user) can overwrite extensions.json and redirect other extensions' entrypoints to arbitrary paths.

gh solves this by having no central registry at all. Extensions are stored as named directories, and the directory structure encodes everything the CLI needs:

~/.elastic/extensions/
  github/
    elastic/
      my-extension/
        package.json          ← entrypoint declared here by the author
        bin/
          elastic-my-extension

How the five operations become trivial:

Current Filesystem approach
readExtensions() readdir ~/.elastic/extensions/**
writeExtensions() no-op (install creates the dir)
findExtension(name) path constructed from source, stat to check existence
upsertExtension(entry) install writes into the namespaced dir
removeExtension(name) rm -rf the dir

Entrypoint discovery: require extension authors to declare it in package.json's bin field (already standard for npm packages). The CLI reads package.json from the install directory at spawn time. No stored entrypoint field that can be tampered with.

{
  "name": "@elastic/my-extension",
  "bin": {
    "elastic-my-extension": "./bin/elastic-my-extension"
  }
}

Security improvement: tampering with extension A now requires writing into extension A's own directory. A malicious extension can still do that (same user), but it can no longer silently poison all extensions by editing one shared file. Pairing with a hash stored in the per-extension package.json at install time (and verified before spawn) closes the remaining gap.

This is a larger architectural call than what this PR is scoped to, so raising it now before the store interface is built on top of -- easier to change the shape here than after the install/remove/dispatch layers are wired in.

Comment on lines +72 to +79
it('creates the parent directory if missing', async () => {
const nested = join(tmpDir, 'subdir', 'extensions.json')
_testSetRegistryPath(nested)
await writeExtensions([ext1])
const raw = await readFile(nested, 'utf-8')
assert.deepEqual(JSON.parse(raw), [ext1])
_testSetRegistryPath(registryFile)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If this fails mid-test, the registry path won't be reset, which could leak into other tests, making them fail.

Consider using a try/finally block here to ensure that the state is always cleaned up

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

good catch, fixed with try/finally

Comment thread src/extension/store.ts
Comment on lines +198 to +199
const filtered = extensions.filter((e) => e.name !== name)
await writeExtensions(filtered)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
const filtered = extensions.filter((e) => e.name !== name)
await writeExtensions(filtered)
const filtered = extensions.filter((e) => e.name !== name)
if (filtered.length === extensions.length) return
await writeExtensions(filtered)

We should only write the extensions if one was actually removed. Currently it writes regardless instead of no-oping

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

fixed -- early return added so we skip the write when nothing changed

Comment thread src/extension/store.ts Outdated
Comment on lines +184 to +189
if (idx === -1) {
extensions.push(entry)
} else {
extensions[idx] = entry
}
await writeExtensions(extensions)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (idx === -1) {
extensions.push(entry)
} else {
extensions[idx] = entry
}
await writeExtensions(extensions)
const updated = idx === -1
? [...extensions, entry]
: extensions.map((e, i) => (i === idx ? entry : e))
await writeExtensions(updated)

Nit: prefer to treat arrays as immutable instead of mutating in-place

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

went ahead and made the change... you're right it's cleaner. the array is a fresh local read on every call so there's no correctness difference, but prefer immutable too for consistency

Comment thread src/extension/store.ts

return {
name,
source: obj['source'] as string,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

source field has no format validation. It accepts any non-empty string. If the expected formats are github:org/repo and npm:package, a regex validation here (like SAFE_NAME_RE for names) would catch invalid registry entries early and give callers better error messages.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

good point, added a loose regex check (SAFE_SOURCE_RE) that validates the prefix is a known format -- kept it intentionally loose so future source types (e.g. git:) aren't rejected. still tight enough to catch arbitrary strings like you described

margaretjgu and others added 3 commits May 14, 2026 13:52
- add try/finally in parent-dir test so registry path is always reset
- skip write in removeExtension when no entry matched (no-op as documented)
- add loose source format validation in validateEntry so tampered registry
  entries with unrecognised source strings are rejected on read
@margaretjgu
Copy link
Copy Markdown
Member Author

margaretjgu commented May 14, 2026

@MattDevy Really appreciate the detailed writeup, the gh comparison is useful context. The main reason we landed on a central json file over the directory structure is the source\ field... we need to know whether the original install was github:\ or npm:\ to decide how to upgrade (git pull vs npm update). Without it we'd need a per-extension metadata file in each install dir anyway, which ends up being a registry with extra steps.

On the security side i think both models have roughly the same exposure in the same-user threat category... in the filesystem approach a malicious extension can still overwrite its own `package.json` bin field and get re-spawned from a tampered entrypoint next time. The blast radius is narrower (one extension vs all) but the attack still works. The thing that would actually close the gap is storing an install-time hash and verifying it before spawn... that's worth a separate issue regardless of which design we go with.

The one place the filesystem model does win is concurrent writes... if two CLI processes ran simultaneously the directory approach avoids the read-modify-write race on extensions.json, though in practice the CLI is single-invocation so this probably doesn't matter.

I'll address this offline with you for further discussion about pros and cons.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(extension): extension registry store

2 participants