Security Guidelines
Every Ghosty plugin runs inside users' personal AI assistants. This privileged position demands a high security bar. This page explains the AI-powered review process, common rejection reasons, and best practices for building secure plugins.
The review process
When you upload your plugin via the Submission Portal, it enters a multi-stage review pipeline:
Stage 1: Automated static analysis
An AI model scans your source code for known vulnerability patterns:
- Injection risks -- innerHTML with unsanitised user input, eval usage, dynamic script injection.
- Data exfiltration -- network requests to undeclared domains, base64-encoded URLs, obfuscated code.
- Permission abuse -- SDK calls that require permissions not declared in the manifest.
- Dependency audit -- known CVEs in bundled third-party libraries.
- Manifest validation -- schema compliance, permission consistency, size constraints.
Stage 2: Dynamic sandbox testing
The plugin is loaded in an instrumented sandbox environment and exercised automatically:
- All lifecycle hooks are invoked and monitored for errors, excessive memory usage, and timeout violations.
- Network requests are intercepted and verified against the
allowed_domainslist. - Storage operations are monitored for quota compliance.
- The UI is rendered and screenshot-compared against accessibility guidelines.
Stage 3: Human review (conditional)
Plugins that request high-risk permissions (network.fetch,
network.websocket, notifications.system) or that trigger warnings in
stages 1-2 are escalated to a human reviewer. Human review typically completes
within 48 hours.
Most plugins that pass automated review are published within 2 hours. Plugins escalated to human review take up to 48 hours. You will receive email notifications at each stage.
Common rejection reasons
The following issues are the most frequent causes of rejection. Address them before submitting to avoid delays.
1. Unsanitised HTML injection
Rejected: Inserting user-controlled data directly into innerHTML without
escaping.
// BAD -- XSS vulnerability
container.innerHTML = `<h1>${userInput}</h1>`;
// GOOD -- escaped
container.innerHTML = `<h1>${escapeHtml(userInput)}</h1>`;
// BEST -- use textContent for plain text
const h1 = document.createElement("h1");
h1.textContent = userInput;
container.appendChild(h1);2. Excessive permissions
Rejected: Requesting permissions the plugin does not use.
# BAD -- requests network.fetch but never makes HTTP requests
permissions:
- storage.read
- storage.write
- network.fetch # unused!
- notifications.system # unused!Only request permissions that your code actually calls. The static analyser cross-references your manifest permissions against SDK usage in your source.
3. Undeclared network domains
Rejected: Making fetch requests to domains not listed in allowed_domains.
# BAD -- fetches from api.example.com but doesn't declare it
permissions:
- network.fetch
allowed_domains:
- api.weather.com// This will be flagged because api.example.com is not declared
await fetch("https://api.example.com/data");4. Use of eval or new Function
Rejected: Dynamic code execution is blocked by default.
// BAD -- will be rejected
const result = eval(userCode);
const fn = new Function("return " + expr);If you absolutely need dynamic evaluation (e.g. a calculator plugin), set
sandbox.allow_eval: true in the manifest and provide a written justification
in your submission notes. These submissions always go to human review.
5. Missing error handling
Rejected: Lifecycle hooks that throw unhandled errors or leave the plugin in a broken state.
// BAD -- unhandled rejection crashes the plugin
onInit: async () => {
const data = await fetch("https://api.example.com/data");
// If this fails, the plugin shows nothing
renderWidget(await data.json());
},
// GOOD -- fallback on error
onInit: async () => {
try {
const data = await fetch("https://api.example.com/data");
renderWidget(await data.json());
} catch {
renderErrorState("Unable to load data. Tap to retry.");
}
},6. Obfuscated or minified source
Rejected: Source code that is intentionally obfuscated to hide its behaviour. Standard minification (webpack, esbuild, terser) is fine, but obfuscation tools (javascript-obfuscator, jsfuck) are not allowed.
If you submit minified code, you must include source maps. The review pipeline uses source maps to analyse the original source. Submissions without source maps are automatically rejected.
High-risk permissions
These permissions receive extra scrutiny during review:
High-risk permissions requiring additional justification
| Permission | Description | Risk |
|---|---|---|
network.fetch | Outbound HTTP requests. Must declare all target domains. | high |
network.websocket | Persistent WebSocket connections. Must declare all target domains. | high |
notifications.system | OS-level system notifications that appear outside the app. | high |
user.id | Access to the user's anonymous unique identifier. | medium |
Best practices
Input validation
Validate and sanitise all external input, whether from user interactions, storage reads, or API responses.
function sanitiseInput(raw: unknown): string {
if (typeof raw !== "string") return "";
// Strip control characters and limit length
return raw.replace(/[\x00-\x1f]/g, "").slice(0, 500);
}Content Security Policy
The default CSP for plugins is default-src 'self'. If you need to load
external resources (fonts, images, API calls), declare them in the manifest:
sandbox:
csp: "default-src 'self'; img-src 'self' https://images.example.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com"Do not add unsafe-inline or unsafe-eval to your CSP. Both will be
flagged during review and almost certainly rejected.
Secure storage practices
- Never store sensitive credentials (API keys, tokens) in plain text in
plugin storage. If you must store secrets, use the
ghosty.storage.setSecureAPI which encrypts values at rest. - Always validate data read from storage. Treat storage as untrusted input because users can manually edit storage values through the Ghosty developer tools.
- Set reasonable TTLs on cached data and refresh when stale.
interface CachedData<T> {
value: T;
expiresAt: number;
}
async function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
const cached = await ghosty.storage.get(key) as CachedData<T> | null;
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const fresh = await fetcher();
await ghosty.storage.set(key, {
value: fresh,
expiresAt: Date.now() + ttlMs,
});
return fresh;
}Dependency management
- Pin all dependency versions in
package.json. Do not use^or~ranges. - Audit dependencies before submission:
npm audit --production. - Minimise your dependency tree. Fewer dependencies mean less attack surface.
- Avoid abandoned packages (no updates in over 12 months).
Error reporting
Return generic error messages to users. Log detailed information for your own debugging:
try {
const result = await riskyOperation();
return result;
} catch (error) {
// Generic message for the user
ghosty.notifications.toast("Something went wrong. Please try again.", {
variant: "error",
});
// Detailed log for the developer dashboard
console.error("[my-plugin] riskyOperation failed:", {
message: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date().toISOString(),
});
return null;
}Security checklist
Before submitting your plugin, verify each of the following:
- All user input is escaped before insertion into the DOM.
-
innerHTMLis never used with raw user input. - Only necessary permissions are requested in the manifest.
- All network domains are listed in
allowed_domains. - No
eval(),new Function(), ordocument.write()usage. - All lifecycle hooks have error handling with user-friendly fallbacks.
- Dependencies are pinned and audited for known vulnerabilities.
- Source maps are included if the bundle is minified.
- Storage values are validated on read (treated as untrusted input).
- No secrets, API keys, or credentials are hard-coded in source files.
- The CSP does not include
unsafe-inlineorunsafe-eval. - Error messages shown to users do not leak internal details.