Skip to content

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

Merged
margaretjgu merged 35 commits into
mainfrom
feat/extension-store
May 20, 2026
Merged

feat(extension): add extension registry store module#299
margaretjgu merged 35 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
Contributor

github-actions Bot commented May 12, 2026

MegaLinter analysis: Success

Descriptor Linter Files Fixed Errors Warnings Elapsed time
✅ COPYPASTE jscpd yes no no 10.43s
✅ REPOSITORY gitleaks yes no no 58.15s
✅ REPOSITORY git_diff yes no no 0.45s
✅ REPOSITORY secretlint yes no no 28.97s
✅ REPOSITORY trivy yes no no 17.71s
✅ TYPESCRIPT eslint 11 0 0 5.51s

Notices

📣 MegaLinter 9.5.0 is out! Discover the new features and security recommendations in the release announcement. (Skip this info by defining SECURITY_SUGGESTIONS: false)

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.

@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.

Discussed with @margaretjgu on Zoom, we shall keep as-is for now. We can always migrate in the future if essential

Copy link
Copy Markdown
Contributor

@MattDevy MattDevy left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Copy Markdown
Member

@JoshMock JoshMock left a comment

Choose a reason for hiding this comment

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

LGTM. Great start. 👏

…nd handlers

installExtension, uninstallExtension, upgradeExtension, and searchExtensions
can throw on invalid input or missing extensions. The factory only converts
{ error: { code } } return values to clean user-facing messages -- unhandled
throws produce a raw Node.js stack trace.

Wrap all four handler functions in try-catch and return handlerError() so
errors are displayed as 'Error: <message>' with a non-zero exit code rather
than crashing with a full stack trace.

Fixes #312
@margaretjgu
Copy link
Copy Markdown
Member Author

Tested the extension create command end-to-end before merging. A few things found and fixed in the latest commit (0dae1ae).

What was tested

elastic extension create my-tool --json
elastic my-tool --json

Outputs { name, esUrl, kibanaUrl, args } from the scaffolded index.js.

elastic extension create demo --path ./demo/elastic-demo --json
elastic demo --json

Outputs full context JSON from the existing demo/elastic-demo/index.js without overwriting it.

elastic extension upgrade demo --json

Returns { "error": { "code": "upgrade_failed", "message": "Extension \"demo\" is a local extension. Edit the files in ... directly." } } -- clean error instead of a stack trace.

elastic extension remove demo --json

Fixes made

create --path on an existing directory was clobbering the existing index.js and package.json. Fixed: scaffold files are now only written if they don't already exist. When --path points to a live directory, the command just registers it and resolves the entrypoint from the existing package.json bin field.

upgrade on a local extension was falling through to parseSource, which threw an unhelpful "Invalid GitHub source" error. Fixed: upgradeExtension now checks for the local: prefix up front and returns a clear message.

Missing SPDX header added to demo/elastic-demo/index.js.

Security notes

  • name is validated against /^[a-z0-9-]+$/ before any filesystem operation
  • --path is resolved via path.resolve() -- no path traversal possible in the stored source field
  • The generated index.js is user-editable code, not a trust boundary -- same threat model as gh extension create (user owns the file, CLI spawns it as the same user)
  • upgradeExtension skips local extensions rather than trying to git pull or npm update on them

Comparison to gh extension create

gh scaffolds a git repo with a shell/Go entrypoint. We scaffold a Node.js script instead (fits the JS-native CLI). The key difference is --path, which lets you point at an existing directory (useful for the demo/ in-repo example). gh doesn't have an equivalent -- it always creates a new directory. No git init on our end either, which keeps it lightweight.

Comment thread src/extension/context.ts Outdated
* ELASTIC_ES_USERNAME Elasticsearch username (basic auth)
* ELASTIC_ES_PASSWORD Elasticsearch password (basic auth)
* ELASTIC_KIBANA_URL Kibana URL
* ELASTIC_KIBANA_API_KEY Kibana API key
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.

Kibana only supports username and password and not API key AFAIK

Copy link
Copy Markdown
Member Author

@margaretjgu margaretjgu May 20, 2026

Choose a reason for hiding this comment

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

Good catch to double-check. Kibana's REST API does support API key authentication via the standard Authorization: ApiKey <base64> header (same mechanism as ES, documented since v7.x). The ServiceBlockSchema is shared across all three services, so if a user configures Kibana with api_key in their CLI config the var gets set correctly. Added a clarifying note to the docstring.

Copy link
Copy Markdown
Contributor

@MattDevy MattDevy left a comment

Choose a reason for hiding this comment

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

LGTM, nice work!

Added a small nit on kibana auth that might need verifying

Comment thread examples/extensions/basic/index.js
Comment thread src/cli.ts Outdated
Comment thread src/cli.ts Outdated
Comment thread src/extension/store.ts Outdated
- move demo/elastic-demo to examples/extensions/basic per JoshMock nit
  (sets up an examples/ tree for future extensions, skills, scripts)
- replace long &&-chain with EARLY_CONFIG_COMMANDS Set in cli.ts (JoshMock)
- derive BUILT_IN_COMMANDS from program.commands so it never goes stale (JoshMock)
- upsertExtension now returns boolean (overwritten); installExtension
  propagates it so handleInstall emits a warning when reinstalling a
  name that already exists (JoshMock)
- clarify in context.ts that Kibana REST API does support ApiKey auth
  via Authorization header (MattDevy nit)
@margaretjgu margaretjgu merged commit f42593e into main May 20, 2026
19 checks passed
@margaretjgu margaretjgu deleted the feat/extension-store branch May 20, 2026 19:50
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

3 participants