Files
PaperClipAI/server/src/services/plugin-install-guard.ts
T
Devin FoleyandPaperclip b70641f23f feat(plugins): support image catalogs and persistent application overlays (#13646)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Plugins extend the application without adding each integration to
Core.
> - A downstream image needs a way to supply prebuilt plugins.
> - Some plugin UI must stay mounted as users move between pages.
> - This change adds an image catalog and a persistent application slot.
> - Operators can upgrade or remove these plugins through their image
and configuration.

## Linked Issues or Issue Description

**Subsystem affected**

Plugin packaging, activation and application UI.

**Problem or motivation**

The built-in plugin catalog is fixed in Core source. Downstream images
cannot add entries through an explicit catalog. Existing page slots also
cannot preserve a small application overlay across route changes.

**Proposed solution**

Read a bounded catalog of prebuilt plugins from the image. Verify its
files before importing manifests. Use the existing managed selection and
plugin lifecycle. Add an `appShellOverlay` slot with account and company
cleanup.

**Alternatives considered**

A downstream fork adds merge work. Script injection provides no plugin
lifecycle. A separate runtime download system adds a second distribution
channel.

**Roadmap alignment**

This extends the existing plugin system. Related PR #9006 covers runtime
install replication; this change covers immutable image contents. PR
#12555 covers CLI scaffolding. Neither provides this catalog or
application slot. The maintainer requested this work directly.

## What Changed

- Validate catalog identities, confined paths, package versions and
bundle hashes before importing code.
- Apply image selection to persisted plugin installs, including removal
and rollback. Adopt the verified image path from existing npm/local
installs and bind runtime worker/UI entrypoints to verified package
declarations.
- Mount application overlays in both UI shells. Preserve route state and
clear it on account, company and onboarding changes.
- Restrict service-worker offline storage/fallback to hashed public
assets in a separate cache namespace; exclude application HTML and
extension/API data, including after worker restart.
- Document the packaging contract, trust model and rollback
requirements.

## Verification

- Passed `pnpm -r typecheck`, `pnpm build`, and `pnpm
check:token-gates`. Affected server/UI typechecks and builds, plus token
gates, passed again after rebasing onto current master; the 124 focused
tests also passed after rebase.
- Latest focused verification: 124 tests in nine files passed for
catalog/reconciliation/loader, overlay lifecycle, Layout and
service-worker policy. The broader UI/shared/SDK run passed 7,204 tests
in 690 files with canonical `TMPDIR`.
- Real disposable Core/PostgreSQL: catalog install, selection removal,
0.1.0→0.1.1→0.1.0, same-version npm/legacy-path adoption, and
preservation of disabled status passed. Added permissions entered
`upgrade_pending`, withheld UI across restart, and activated only after
explicit operator enable.
- Real Chromium: desktop/mobile layout, route draft retention and
Escape/focus passed with mocked extension responses. A persistent
browser restart retained public hashed-asset offline fallback while
refusing seeded legacy/current private entries and legacy HTML.
- Full `pnpm test:run`: 12,539 passed; 17 failed across six existing
files, stopping later phases. macOS read-only directory renames fail in
runtime-skill-cache and company-skills-service; email tests require an
absent local AgentMail fixture. Native runner/comment-redaction passed
in isolation after temporary Rust setup; agent-conversations also passed
in isolation. No unrelated source was changed to hide failures.
- After rebase, two unchanged chat timing tests failed in CI and passed
locally in isolation. Their CI shard passed on its single retry. All
other current-head CI jobs passed on the initial run; review is 5/5 with
no unresolved threads.
- No live deployment or external plugin service was used.

## Risks

- Plugins are trusted code. The catalog detects packaging errors; it
does not authenticate an untrusted image builder.
- Invalid catalogs fail startup. Images must contain the catalog and
bundles together, with stable directories.
- A host older than this contract lacks the activation guard. Disable
added plugins and remove their configuration keys before reverting to
it.
- Offline navigation now returns 503 instead of replaying cached
application HTML. Only public build assets have offline fallback.
- Rolling back an unapproved permission change retains the approval
gate; review the current manifest and explicitly enable it. A reduced
permission set cannot establish prior approval or prior enabled status.
- Plugin data migrations need their own rollback policy. This change
retains installed records and does not reverse migrations.

## Model Used

- OpenAI GPT-6 (Codex), model ID `gpt-6`, with repository inspection,
code execution and browser verification. The runtime does not expose an
exact context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (relevant suites; broad
macOS server-run exceptions are documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (fresh run on 488b3754ae; chat
shard passed its single retry)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(fresh review on 488b3754ae)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-19 09:18:10 -07:00

110 lines
4.3 KiB
TypeScript

/**
* @fileoverview Security guard for plugin install sources.
*
* Two related protections for `POST /api/plugins/install`:
*
* 1. **Cloud install floor.** Instances managed by the Paperclip Cloud
* harness receive a `PAPERCLIP_MANAGED_CONFIG` environment document that
* only the harness can inject. On such instances, plugin installation is
* a remote-code-execution surface on shared infrastructure, so the route
* enforces a positive allowlist: only install sources that canonicalize
* to a path inside the bundled plugin catalog root may be installed.
* npm/registry installs and arbitrary `localPath` installs are rejected.
*
* 2. **`localPath` canonicalization for every instance.** The install route
* historically skipped its package-name character check when
* `isLocalPath` was set, passing the raw request string straight to the
* loader. All local install paths are now canonicalized (absolute
* resolution + symlink/`..` normalization via `realpath`) and validated
* to be readable directories before the loader sees them.
*
* The floor is enforced in code at the route, independent of any feature
* flag or of the managed-config document's *content*: a corrupted flag
* document cannot widen the install surface because this module never reads
* the document body at all (see `isCloudManagedInstance`).
*/
import { realpath, stat } from "node:fs/promises";
import path from "node:path";
import { BUNDLED_LOCAL_PLUGIN_ROOT } from "./plugin-loader.js";
import { BUNDLED_CATALOG_ROOT_ENV_VAR } from "./bundled-plugins.js";
export { isCloudManagedInstance } from "./cloud-instance.js";
/** Result of canonicalizing a requested local plugin install path. */
export type LocalPluginPathValidation =
| { ok: true; canonicalPath: string }
| { ok: false; reason: string };
/**
* Canonicalize and validate a raw `localPath` install source.
*
* Resolves the request string to an absolute path, then to its real path —
* collapsing `..` traversal segments and resolving every symlink — and
* requires the result to be an existing directory. Downstream checks (the
* cloud catalog containment test, the loader itself) must only ever see the
* canonical form so that no alias of a path can reach a different decision
* than the path itself.
*
* @param rawPath - The unsanitized `packageName` value from the request body
*/
export async function canonicalizeLocalPluginPath(
rawPath: string,
): Promise<LocalPluginPathValidation> {
if (rawPath.includes("\0")) {
return { ok: false, reason: "path contains a null byte" };
}
const absolutePath = path.resolve(rawPath);
let canonicalPath: string;
try {
canonicalPath = await realpath(absolutePath);
} catch {
return { ok: false, reason: `path does not exist: ${absolutePath}` };
}
try {
const stats = await stat(canonicalPath);
if (!stats.isDirectory()) {
return { ok: false, reason: `path is not a directory: ${canonicalPath}` };
}
} catch {
return { ok: false, reason: `path is not readable: ${canonicalPath}` };
}
return { ok: true, canonicalPath };
}
/**
* Whether a canonical path lies strictly inside the bundled plugin catalog
* root (`packages/plugins` in the application bundle).
*
* The catalog root itself is also canonicalized before comparison so a
* symlinked deployment layout cannot produce false negatives, and the
* containment test is segment-based (`path.relative`), never a string-prefix
* check. The root itself does not count as inside — an install source must
* be a package directory *within* the catalog.
*
* @param canonicalPath - A path already canonicalized by {@link canonicalizeLocalPluginPath}
* @param bundledRootOverride - Catalog root override for tests; defaults to
* {@link BUNDLED_LOCAL_PLUGIN_ROOT}
*/
export async function isWithinBundledPluginRoot(
canonicalPath: string,
bundledRootOverride?: string,
): Promise<boolean> {
const bundledRoot = bundledRootOverride ?? (process.env[BUNDLED_CATALOG_ROOT_ENV_VAR]?.trim() || BUNDLED_LOCAL_PLUGIN_ROOT);
let canonicalRoot: string;
try {
canonicalRoot = await realpath(bundledRoot);
} catch {
// No catalog root on disk means nothing is bundled; fail closed.
return false;
}
const relative = path.relative(canonicalRoot, canonicalPath);
return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
}