Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.73",
"version": "0.2.74",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/__tests__/history.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,26 @@ describe("history", () => {
const { homedir } = require("os");
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("throws for dot-relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "./local/dir";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("resolves .. segments in absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/tmp/foo/../bar";
expect(getSpawnDir()).toBe("/tmp/bar");
});

it("accepts normal absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/home/user/.spawn";
expect(getSpawnDir()).toBe("/home/user/.spawn");
});
});

// ── getHistoryPath ──────────────────────────────────────────────────────
Expand Down
64 changes: 64 additions & 0 deletions cli/src/__tests__/security-encoding.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,3 +160,67 @@ describe("Security Encoding Edge Cases", () => {
});
});
});

// ── stripDangerousKeys (prototype pollution defense) ─────────────────────────

import { stripDangerousKeys } from "../manifest";

describe("stripDangerousKeys", () => {
it("strips __proto__ from parsed JSON", () => {
// JSON.parse produces an own-property __proto__ key (not inherited)
const input = JSON.parse('{"agents":{},"clouds":{},"matrix":{},"__proto__":{"polluted":true}}');
expect(Object.prototype.hasOwnProperty.call(input, "__proto__")).toBe(true);
const result = stripDangerousKeys(input);
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false);
expect(result.agents).toEqual({});
});

it("strips constructor key", () => {
const input = Object.assign(Object.create(null), { name: "test", constructor: { evil: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["name"]);
expect(result.name).toBe("test");
});

it("strips prototype key", () => {
const input = Object.assign(Object.create(null), { data: 1, prototype: { inject: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["data"]);
expect(result.data).toBe(1);
});

it("strips dangerous keys from nested objects", () => {
const input = { agents: { claude: { __proto__: { evil: true }, name: "Claude" } } };
const result = stripDangerousKeys(input);
expect(result.agents.claude.name).toBe("Claude");
expect(Object.keys(result.agents.claude)).toEqual(["name"]);
});

it("handles arrays correctly", () => {
const input = { items: [{ name: "a" }, { name: "b", __proto__: {} }] };
const result = stripDangerousKeys(input);
expect(result.items).toHaveLength(2);
expect(result.items[0].name).toBe("a");
expect(result.items[1].name).toBe("b");
});

it("passes through primitives unchanged", () => {
expect(stripDangerousKeys("hello")).toBe("hello");
expect(stripDangerousKeys(42)).toBe(42);
expect(stripDangerousKeys(true)).toBe(true);
expect(stripDangerousKeys(null)).toBe(null);
});

it("preserves normal keys", () => {
const input = { agents: { a: 1 }, clouds: { b: 2 }, matrix: { c: 3 } };
const result = stripDangerousKeys(input);
expect(result).toEqual(input);
});

it("handles deeply nested dangerous keys", () => {
const input = { a: { b: { c: { constructor: "bad", value: "good" } } } };
const result = stripDangerousKeys(input);
expect(result.a.b.c.value).toBe("good");
expect(Object.keys(result.a.b.c)).toEqual(["value"]);
});
});
18 changes: 15 additions & 3 deletions cli/src/history.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import { join, resolve, isAbsolute } from "path";
import { homedir } from "os";

export interface SpawnRecord {
Expand All@@ -9,9 +9,21 @@ export interface SpawnRecord {
prompt?: string;
}

/** Returns the directory for spawn data, respecting SPAWN_HOME env var */
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
return process.env.SPAWN_HOME || join(homedir(), ".spawn");
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) return join(homedir(), ".spawn");
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` +
`Example: export SPAWN_HOME=/home/user/.spawn`
);
}
// Resolve to canonical form (collapses .. segments)
return resolve(spawnHome);
}

export function getHistoryPath(): string {
Expand Down
27 changes: 22 additions & 5 deletions cli/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,8 @@ function logError(message: string, err?: unknown): void {

function readCache(): Manifest | null {
try {
return JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as Manifest;
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
return stripDangerousKeys(raw) as Manifest;
} catch (err) {
// Cache file missing, corrupted, or unreadable
logError(`Failed to read cache from ${CACHE_FILE}`, err);
Expand All@@ -81,8 +82,22 @@ function writeCache(data: Manifest): void {

// ── Fetching ───────────────────────────────────────────────────────────────────

/** Recursively strip __proto__, constructor, and prototype keys from parsed JSON
* to prevent prototype pollution attacks (defense in depth). */
function stripDangerousKeys(obj: any): any {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
const clean: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clean[key] = stripDangerousKeys(obj[key]);
}
return clean;
}

function isValidManifest(data: any): data is Manifest {
return data && data.agents && data.clouds && data.matrix;
return data && typeof data === "object" && !Array.isArray(data) &&
data.agents && data.clouds && data.matrix;
}

async function fetchManifestFromGitHub(): Promise<Manifest | null> {
Expand All@@ -94,7 +109,8 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
logError(`Failed to fetch manifest from GitHub: HTTP ${res.status} ${res.statusText}`);
return null;
}
const data = (await res.json()) as Manifest;
const raw = await res.json();
const data = stripDangerousKeys(raw) as Manifest;
if (!isValidManifest(data)) {
logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)");
return null;
Expand DownExpand Up@@ -132,7 +148,8 @@ function tryLoadLocalManifest(): Manifest | null {
// Try loading manifest.json from current directory (development mode)
const localPath = join(process.cwd(), "manifest.json");
if (existsSync(localPath)) {
const data = JSON.parse(readFileSync(localPath, "utf-8"));
const raw = JSON.parse(readFileSync(localPath, "utf-8"));
const data = stripDangerousKeys(raw);
if (isValidManifest(data)) {
return data as Manifest;
}
Expand DownExpand Up@@ -226,4 +243,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}

export { RAW_BASE, REPO, CACHE_DIR };
export { RAW_BASE, REPO, CACHE_DIR, stripDangerousKeys };
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.73",
"version": "0.2.74",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/__tests__/history.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,26 @@ describe("history", () => {
const { homedir } = require("os");
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("throws for dot-relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "./local/dir";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("resolves .. segments in absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/tmp/foo/../bar";
expect(getSpawnDir()).toBe("/tmp/bar");
});

it("accepts normal absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/home/user/.spawn";
expect(getSpawnDir()).toBe("/home/user/.spawn");
});
});

// ── getHistoryPath ──────────────────────────────────────────────────────
Expand Down
64 changes: 64 additions & 0 deletions cli/src/__tests__/security-encoding.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,3 +160,67 @@ describe("Security Encoding Edge Cases", () => {
});
});
});

// ── stripDangerousKeys (prototype pollution defense) ─────────────────────────

import { stripDangerousKeys } from "../manifest";

describe("stripDangerousKeys", () => {
it("strips __proto__ from parsed JSON", () => {
// JSON.parse produces an own-property __proto__ key (not inherited)
const input = JSON.parse('{"agents":{},"clouds":{},"matrix":{},"__proto__":{"polluted":true}}');
expect(Object.prototype.hasOwnProperty.call(input, "__proto__")).toBe(true);
const result = stripDangerousKeys(input);
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false);
expect(result.agents).toEqual({});
});

it("strips constructor key", () => {
const input = Object.assign(Object.create(null), { name: "test", constructor: { evil: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["name"]);
expect(result.name).toBe("test");
});

it("strips prototype key", () => {
const input = Object.assign(Object.create(null), { data: 1, prototype: { inject: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["data"]);
expect(result.data).toBe(1);
});

it("strips dangerous keys from nested objects", () => {
const input = { agents: { claude: { __proto__: { evil: true }, name: "Claude" } } };
const result = stripDangerousKeys(input);
expect(result.agents.claude.name).toBe("Claude");
expect(Object.keys(result.agents.claude)).toEqual(["name"]);
});

it("handles arrays correctly", () => {
const input = { items: [{ name: "a" }, { name: "b", __proto__: {} }] };
const result = stripDangerousKeys(input);
expect(result.items).toHaveLength(2);
expect(result.items[0].name).toBe("a");
expect(result.items[1].name).toBe("b");
});

it("passes through primitives unchanged", () => {
expect(stripDangerousKeys("hello")).toBe("hello");
expect(stripDangerousKeys(42)).toBe(42);
expect(stripDangerousKeys(true)).toBe(true);
expect(stripDangerousKeys(null)).toBe(null);
});

it("preserves normal keys", () => {
const input = { agents: { a: 1 }, clouds: { b: 2 }, matrix: { c: 3 } };
const result = stripDangerousKeys(input);
expect(result).toEqual(input);
});

it("handles deeply nested dangerous keys", () => {
const input = { a: { b: { c: { constructor: "bad", value: "good" } } } };
const result = stripDangerousKeys(input);
expect(result.a.b.c.value).toBe("good");
expect(Object.keys(result.a.b.c)).toEqual(["value"]);
});
});
18 changes: 15 additions & 3 deletions cli/src/history.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import { join, resolve, isAbsolute } from "path";
import { homedir } from "os";

export interface SpawnRecord {
Expand All@@ -9,9 +9,21 @@ export interface SpawnRecord {
prompt?: string;
}

/** Returns the directory for spawn data, respecting SPAWN_HOME env var */
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
return process.env.SPAWN_HOME || join(homedir(), ".spawn");
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) return join(homedir(), ".spawn");
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` +
`Example: export SPAWN_HOME=/home/user/.spawn`
);
}
// Resolve to canonical form (collapses .. segments)
return resolve(spawnHome);
}

export function getHistoryPath(): string {
Expand Down
27 changes: 22 additions & 5 deletions cli/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,8 @@ function logError(message: string, err?: unknown): void {

function readCache(): Manifest | null {
try {
return JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as Manifest;
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
return stripDangerousKeys(raw) as Manifest;
} catch (err) {
// Cache file missing, corrupted, or unreadable
logError(`Failed to read cache from ${CACHE_FILE}`, err);
Expand All@@ -81,8 +82,22 @@ function writeCache(data: Manifest): void {

// ── Fetching ───────────────────────────────────────────────────────────────────

/** Recursively strip __proto__, constructor, and prototype keys from parsed JSON
* to prevent prototype pollution attacks (defense in depth). */
function stripDangerousKeys(obj: any): any {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
const clean: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clean[key] = stripDangerousKeys(obj[key]);
}
return clean;
}

function isValidManifest(data: any): data is Manifest {
return data && data.agents && data.clouds && data.matrix;
return data && typeof data === "object" && !Array.isArray(data) &&
data.agents && data.clouds && data.matrix;
}

async function fetchManifestFromGitHub(): Promise<Manifest | null> {
Expand All@@ -94,7 +109,8 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
logError(`Failed to fetch manifest from GitHub: HTTP ${res.status} ${res.statusText}`);
return null;
}
const data = (await res.json()) as Manifest;
const raw = await res.json();
const data = stripDangerousKeys(raw) as Manifest;
if (!isValidManifest(data)) {
logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)");
return null;
Expand DownExpand Up@@ -132,7 +148,8 @@ function tryLoadLocalManifest(): Manifest | null {
// Try loading manifest.json from current directory (development mode)
const localPath = join(process.cwd(), "manifest.json");
if (existsSync(localPath)) {
const data = JSON.parse(readFileSync(localPath, "utf-8"));
const raw = JSON.parse(readFileSync(localPath, "utf-8"));
const data = stripDangerousKeys(raw);
if (isValidManifest(data)) {
return data as Manifest;
}
Expand DownExpand Up@@ -226,4 +243,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}

export { RAW_BASE, REPO, CACHE_DIR };
export { RAW_BASE, REPO, CACHE_DIR, stripDangerousKeys };
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.73",
"version": "0.2.74",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/__tests__/history.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,26 @@ describe("history", () => {
const { homedir } = require("os");
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("throws for dot-relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "./local/dir";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("resolves .. segments in absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/tmp/foo/../bar";
expect(getSpawnDir()).toBe("/tmp/bar");
});

it("accepts normal absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/home/user/.spawn";
expect(getSpawnDir()).toBe("/home/user/.spawn");
});
});

// ── getHistoryPath ──────────────────────────────────────────────────────
Expand Down
64 changes: 64 additions & 0 deletions cli/src/__tests__/security-encoding.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,3 +160,67 @@ describe("Security Encoding Edge Cases", () => {
});
});
});

// ── stripDangerousKeys (prototype pollution defense) ─────────────────────────

import { stripDangerousKeys } from "../manifest";

describe("stripDangerousKeys", () => {
it("strips __proto__ from parsed JSON", () => {
// JSON.parse produces an own-property __proto__ key (not inherited)
const input = JSON.parse('{"agents":{},"clouds":{},"matrix":{},"__proto__":{"polluted":true}}');
expect(Object.prototype.hasOwnProperty.call(input, "__proto__")).toBe(true);
const result = stripDangerousKeys(input);
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false);
expect(result.agents).toEqual({});
});

it("strips constructor key", () => {
const input = Object.assign(Object.create(null), { name: "test", constructor: { evil: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["name"]);
expect(result.name).toBe("test");
});

it("strips prototype key", () => {
const input = Object.assign(Object.create(null), { data: 1, prototype: { inject: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["data"]);
expect(result.data).toBe(1);
});

it("strips dangerous keys from nested objects", () => {
const input = { agents: { claude: { __proto__: { evil: true }, name: "Claude" } } };
const result = stripDangerousKeys(input);
expect(result.agents.claude.name).toBe("Claude");
expect(Object.keys(result.agents.claude)).toEqual(["name"]);
});

it("handles arrays correctly", () => {
const input = { items: [{ name: "a" }, { name: "b", __proto__: {} }] };
const result = stripDangerousKeys(input);
expect(result.items).toHaveLength(2);
expect(result.items[0].name).toBe("a");
expect(result.items[1].name).toBe("b");
});

it("passes through primitives unchanged", () => {
expect(stripDangerousKeys("hello")).toBe("hello");
expect(stripDangerousKeys(42)).toBe(42);
expect(stripDangerousKeys(true)).toBe(true);
expect(stripDangerousKeys(null)).toBe(null);
});

it("preserves normal keys", () => {
const input = { agents: { a: 1 }, clouds: { b: 2 }, matrix: { c: 3 } };
const result = stripDangerousKeys(input);
expect(result).toEqual(input);
});

it("handles deeply nested dangerous keys", () => {
const input = { a: { b: { c: { constructor: "bad", value: "good" } } } };
const result = stripDangerousKeys(input);
expect(result.a.b.c.value).toBe("good");
expect(Object.keys(result.a.b.c)).toEqual(["value"]);
});
});
18 changes: 15 additions & 3 deletions cli/src/history.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import { join, resolve, isAbsolute } from "path";
import { homedir } from "os";

export interface SpawnRecord {
Expand All@@ -9,9 +9,21 @@ export interface SpawnRecord {
prompt?: string;
}

/** Returns the directory for spawn data, respecting SPAWN_HOME env var */
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
return process.env.SPAWN_HOME || join(homedir(), ".spawn");
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) return join(homedir(), ".spawn");
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` +
`Example: export SPAWN_HOME=/home/user/.spawn`
);
}
// Resolve to canonical form (collapses .. segments)
return resolve(spawnHome);
}

export function getHistoryPath(): string {
Expand Down
27 changes: 22 additions & 5 deletions cli/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,8 @@ function logError(message: string, err?: unknown): void {

function readCache(): Manifest | null {
try {
return JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as Manifest;
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
return stripDangerousKeys(raw) as Manifest;
} catch (err) {
// Cache file missing, corrupted, or unreadable
logError(`Failed to read cache from ${CACHE_FILE}`, err);
Expand All@@ -81,8 +82,22 @@ function writeCache(data: Manifest): void {

// ── Fetching ───────────────────────────────────────────────────────────────────

/** Recursively strip __proto__, constructor, and prototype keys from parsed JSON
* to prevent prototype pollution attacks (defense in depth). */
function stripDangerousKeys(obj: any): any {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
const clean: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clean[key] = stripDangerousKeys(obj[key]);
}
return clean;
}

function isValidManifest(data: any): data is Manifest {
return data && data.agents && data.clouds && data.matrix;
return data && typeof data === "object" && !Array.isArray(data) &&
data.agents && data.clouds && data.matrix;
}

async function fetchManifestFromGitHub(): Promise<Manifest | null> {
Expand All@@ -94,7 +109,8 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
logError(`Failed to fetch manifest from GitHub: HTTP ${res.status} ${res.statusText}`);
return null;
}
const data = (await res.json()) as Manifest;
const raw = await res.json();
const data = stripDangerousKeys(raw) as Manifest;
if (!isValidManifest(data)) {
logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)");
return null;
Expand DownExpand Up@@ -132,7 +148,8 @@ function tryLoadLocalManifest(): Manifest | null {
// Try loading manifest.json from current directory (development mode)
const localPath = join(process.cwd(), "manifest.json");
if (existsSync(localPath)) {
const data = JSON.parse(readFileSync(localPath, "utf-8"));
const raw = JSON.parse(readFileSync(localPath, "utf-8"));
const data = stripDangerousKeys(raw);
if (isValidManifest(data)) {
return data as Manifest;
}
Expand DownExpand Up@@ -226,4 +243,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}

export { RAW_BASE, REPO, CACHE_DIR };
export { RAW_BASE, REPO, CACHE_DIR, stripDangerousKeys };
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.73",
"version": "0.2.74",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/__tests__/history.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,26 @@ describe("history", () => {
const { homedir } = require("os");
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("throws for dot-relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "./local/dir";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("resolves .. segments in absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/tmp/foo/../bar";
expect(getSpawnDir()).toBe("/tmp/bar");
});

it("accepts normal absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/home/user/.spawn";
expect(getSpawnDir()).toBe("/home/user/.spawn");
});
});

// ── getHistoryPath ──────────────────────────────────────────────────────
Expand Down
64 changes: 64 additions & 0 deletions cli/src/__tests__/security-encoding.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,3 +160,67 @@ describe("Security Encoding Edge Cases", () => {
});
});
});

// ── stripDangerousKeys (prototype pollution defense) ─────────────────────────

import { stripDangerousKeys } from "../manifest";

describe("stripDangerousKeys", () => {
it("strips __proto__ from parsed JSON", () => {
// JSON.parse produces an own-property __proto__ key (not inherited)
const input = JSON.parse('{"agents":{},"clouds":{},"matrix":{},"__proto__":{"polluted":true}}');
expect(Object.prototype.hasOwnProperty.call(input, "__proto__")).toBe(true);
const result = stripDangerousKeys(input);
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false);
expect(result.agents).toEqual({});
});

it("strips constructor key", () => {
const input = Object.assign(Object.create(null), { name: "test", constructor: { evil: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["name"]);
expect(result.name).toBe("test");
});

it("strips prototype key", () => {
const input = Object.assign(Object.create(null), { data: 1, prototype: { inject: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["data"]);
expect(result.data).toBe(1);
});

it("strips dangerous keys from nested objects", () => {
const input = { agents: { claude: { __proto__: { evil: true }, name: "Claude" } } };
const result = stripDangerousKeys(input);
expect(result.agents.claude.name).toBe("Claude");
expect(Object.keys(result.agents.claude)).toEqual(["name"]);
});

it("handles arrays correctly", () => {
const input = { items: [{ name: "a" }, { name: "b", __proto__: {} }] };
const result = stripDangerousKeys(input);
expect(result.items).toHaveLength(2);
expect(result.items[0].name).toBe("a");
expect(result.items[1].name).toBe("b");
});

it("passes through primitives unchanged", () => {
expect(stripDangerousKeys("hello")).toBe("hello");
expect(stripDangerousKeys(42)).toBe(42);
expect(stripDangerousKeys(true)).toBe(true);
expect(stripDangerousKeys(null)).toBe(null);
});

it("preserves normal keys", () => {
const input = { agents: { a: 1 }, clouds: { b: 2 }, matrix: { c: 3 } };
const result = stripDangerousKeys(input);
expect(result).toEqual(input);
});

it("handles deeply nested dangerous keys", () => {
const input = { a: { b: { c: { constructor: "bad", value: "good" } } } };
const result = stripDangerousKeys(input);
expect(result.a.b.c.value).toBe("good");
expect(Object.keys(result.a.b.c)).toEqual(["value"]);
});
});
18 changes: 15 additions & 3 deletions cli/src/history.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import { join, resolve, isAbsolute } from "path";
import { homedir } from "os";

export interface SpawnRecord {
Expand All@@ -9,9 +9,21 @@ export interface SpawnRecord {
prompt?: string;
}

/** Returns the directory for spawn data, respecting SPAWN_HOME env var */
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
return process.env.SPAWN_HOME || join(homedir(), ".spawn");
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) return join(homedir(), ".spawn");
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` +
`Example: export SPAWN_HOME=/home/user/.spawn`
);
}
// Resolve to canonical form (collapses .. segments)
return resolve(spawnHome);
}

export function getHistoryPath(): string {
Expand Down
27 changes: 22 additions & 5 deletions cli/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,8 @@ function logError(message: string, err?: unknown): void {

function readCache(): Manifest | null {
try {
return JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as Manifest;
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
return stripDangerousKeys(raw) as Manifest;
} catch (err) {
// Cache file missing, corrupted, or unreadable
logError(`Failed to read cache from ${CACHE_FILE}`, err);
Expand All@@ -81,8 +82,22 @@ function writeCache(data: Manifest): void {

// ── Fetching ───────────────────────────────────────────────────────────────────

/** Recursively strip __proto__, constructor, and prototype keys from parsed JSON
* to prevent prototype pollution attacks (defense in depth). */
function stripDangerousKeys(obj: any): any {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
const clean: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clean[key] = stripDangerousKeys(obj[key]);
}
return clean;
}

function isValidManifest(data: any): data is Manifest {
return data && data.agents && data.clouds && data.matrix;
return data && typeof data === "object" && !Array.isArray(data) &&
data.agents && data.clouds && data.matrix;
}

async function fetchManifestFromGitHub(): Promise<Manifest | null> {
Expand All@@ -94,7 +109,8 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
logError(`Failed to fetch manifest from GitHub: HTTP ${res.status} ${res.statusText}`);
return null;
}
const data = (await res.json()) as Manifest;
const raw = await res.json();
const data = stripDangerousKeys(raw) as Manifest;
if (!isValidManifest(data)) {
logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)");
return null;
Expand DownExpand Up@@ -132,7 +148,8 @@ function tryLoadLocalManifest(): Manifest | null {
// Try loading manifest.json from current directory (development mode)
const localPath = join(process.cwd(), "manifest.json");
if (existsSync(localPath)) {
const data = JSON.parse(readFileSync(localPath, "utf-8"));
const raw = JSON.parse(readFileSync(localPath, "utf-8"));
const data = stripDangerousKeys(raw);
if (isValidManifest(data)) {
return data as Manifest;
}
Expand DownExpand Up@@ -226,4 +243,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}

export { RAW_BASE, REPO, CACHE_DIR };
export { RAW_BASE, REPO, CACHE_DIR, stripDangerousKeys };
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.73",
"version": "0.2.74",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/__tests__/history.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,26 @@ describe("history", () => {
const { homedir } = require("os");
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("throws for dot-relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "./local/dir";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("resolves .. segments in absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/tmp/foo/../bar";
expect(getSpawnDir()).toBe("/tmp/bar");
});

it("accepts normal absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/home/user/.spawn";
expect(getSpawnDir()).toBe("/home/user/.spawn");
});
});

// ── getHistoryPath ──────────────────────────────────────────────────────
Expand Down
64 changes: 64 additions & 0 deletions cli/src/__tests__/security-encoding.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,3 +160,67 @@ describe("Security Encoding Edge Cases", () => {
});
});
});

// ── stripDangerousKeys (prototype pollution defense) ─────────────────────────

import { stripDangerousKeys } from "../manifest";

describe("stripDangerousKeys", () => {
it("strips __proto__ from parsed JSON", () => {
// JSON.parse produces an own-property __proto__ key (not inherited)
const input = JSON.parse('{"agents":{},"clouds":{},"matrix":{},"__proto__":{"polluted":true}}');
expect(Object.prototype.hasOwnProperty.call(input, "__proto__")).toBe(true);
const result = stripDangerousKeys(input);
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false);
expect(result.agents).toEqual({});
});

it("strips constructor key", () => {
const input = Object.assign(Object.create(null), { name: "test", constructor: { evil: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["name"]);
expect(result.name).toBe("test");
});

it("strips prototype key", () => {
const input = Object.assign(Object.create(null), { data: 1, prototype: { inject: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["data"]);
expect(result.data).toBe(1);
});

it("strips dangerous keys from nested objects", () => {
const input = { agents: { claude: { __proto__: { evil: true }, name: "Claude" } } };
const result = stripDangerousKeys(input);
expect(result.agents.claude.name).toBe("Claude");
expect(Object.keys(result.agents.claude)).toEqual(["name"]);
});

it("handles arrays correctly", () => {
const input = { items: [{ name: "a" }, { name: "b", __proto__: {} }] };
const result = stripDangerousKeys(input);
expect(result.items).toHaveLength(2);
expect(result.items[0].name).toBe("a");
expect(result.items[1].name).toBe("b");
});

it("passes through primitives unchanged", () => {
expect(stripDangerousKeys("hello")).toBe("hello");
expect(stripDangerousKeys(42)).toBe(42);
expect(stripDangerousKeys(true)).toBe(true);
expect(stripDangerousKeys(null)).toBe(null);
});

it("preserves normal keys", () => {
const input = { agents: { a: 1 }, clouds: { b: 2 }, matrix: { c: 3 } };
const result = stripDangerousKeys(input);
expect(result).toEqual(input);
});

it("handles deeply nested dangerous keys", () => {
const input = { a: { b: { c: { constructor: "bad", value: "good" } } } };
const result = stripDangerousKeys(input);
expect(result.a.b.c.value).toBe("good");
expect(Object.keys(result.a.b.c)).toEqual(["value"]);
});
});
18 changes: 15 additions & 3 deletions cli/src/history.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import { join, resolve, isAbsolute } from "path";
import { homedir } from "os";

export interface SpawnRecord {
Expand All@@ -9,9 +9,21 @@ export interface SpawnRecord {
prompt?: string;
}

/** Returns the directory for spawn data, respecting SPAWN_HOME env var */
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
return process.env.SPAWN_HOME || join(homedir(), ".spawn");
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) return join(homedir(), ".spawn");
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` +
`Example: export SPAWN_HOME=/home/user/.spawn`
);
}
// Resolve to canonical form (collapses .. segments)
return resolve(spawnHome);
}

export function getHistoryPath(): string {
Expand Down
27 changes: 22 additions & 5 deletions cli/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,8 @@ function logError(message: string, err?: unknown): void {

function readCache(): Manifest | null {
try {
return JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as Manifest;
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
return stripDangerousKeys(raw) as Manifest;
} catch (err) {
// Cache file missing, corrupted, or unreadable
logError(`Failed to read cache from ${CACHE_FILE}`, err);
Expand All@@ -81,8 +82,22 @@ function writeCache(data: Manifest): void {

// ── Fetching ───────────────────────────────────────────────────────────────────

/** Recursively strip __proto__, constructor, and prototype keys from parsed JSON
* to prevent prototype pollution attacks (defense in depth). */
function stripDangerousKeys(obj: any): any {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
const clean: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clean[key] = stripDangerousKeys(obj[key]);
}
return clean;
}

function isValidManifest(data: any): data is Manifest {
return data && data.agents && data.clouds && data.matrix;
return data && typeof data === "object" && !Array.isArray(data) &&
data.agents && data.clouds && data.matrix;
}

async function fetchManifestFromGitHub(): Promise<Manifest | null> {
Expand All@@ -94,7 +109,8 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
logError(`Failed to fetch manifest from GitHub: HTTP ${res.status} ${res.statusText}`);
return null;
}
const data = (await res.json()) as Manifest;
const raw = await res.json();
const data = stripDangerousKeys(raw) as Manifest;
if (!isValidManifest(data)) {
logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)");
return null;
Expand DownExpand Up@@ -132,7 +148,8 @@ function tryLoadLocalManifest(): Manifest | null {
// Try loading manifest.json from current directory (development mode)
const localPath = join(process.cwd(), "manifest.json");
if (existsSync(localPath)) {
const data = JSON.parse(readFileSync(localPath, "utf-8"));
const raw = JSON.parse(readFileSync(localPath, "utf-8"));
const data = stripDangerousKeys(raw);
if (isValidManifest(data)) {
return data as Manifest;
}
Expand DownExpand Up@@ -226,4 +243,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}

export { RAW_BASE, REPO, CACHE_DIR };
export { RAW_BASE, REPO, CACHE_DIR, stripDangerousKeys };
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.73",
"version": "0.2.74",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/__tests__/history.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,26 @@ describe("history", () => {
const { homedir } = require("os");
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("throws for dot-relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "./local/dir";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("resolves .. segments in absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/tmp/foo/../bar";
expect(getSpawnDir()).toBe("/tmp/bar");
});

it("accepts normal absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/home/user/.spawn";
expect(getSpawnDir()).toBe("/home/user/.spawn");
});
});

// ── getHistoryPath ──────────────────────────────────────────────────────
Expand Down
64 changes: 64 additions & 0 deletions cli/src/__tests__/security-encoding.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,3 +160,67 @@ describe("Security Encoding Edge Cases", () => {
});
});
});

// ── stripDangerousKeys (prototype pollution defense) ─────────────────────────

import { stripDangerousKeys } from "../manifest";

describe("stripDangerousKeys", () => {
it("strips __proto__ from parsed JSON", () => {
// JSON.parse produces an own-property __proto__ key (not inherited)
const input = JSON.parse('{"agents":{},"clouds":{},"matrix":{},"__proto__":{"polluted":true}}');
expect(Object.prototype.hasOwnProperty.call(input, "__proto__")).toBe(true);
const result = stripDangerousKeys(input);
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false);
expect(result.agents).toEqual({});
});

it("strips constructor key", () => {
const input = Object.assign(Object.create(null), { name: "test", constructor: { evil: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["name"]);
expect(result.name).toBe("test");
});

it("strips prototype key", () => {
const input = Object.assign(Object.create(null), { data: 1, prototype: { inject: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["data"]);
expect(result.data).toBe(1);
});

it("strips dangerous keys from nested objects", () => {
const input = { agents: { claude: { __proto__: { evil: true }, name: "Claude" } } };
const result = stripDangerousKeys(input);
expect(result.agents.claude.name).toBe("Claude");
expect(Object.keys(result.agents.claude)).toEqual(["name"]);
});

it("handles arrays correctly", () => {
const input = { items: [{ name: "a" }, { name: "b", __proto__: {} }] };
const result = stripDangerousKeys(input);
expect(result.items).toHaveLength(2);
expect(result.items[0].name).toBe("a");
expect(result.items[1].name).toBe("b");
});

it("passes through primitives unchanged", () => {
expect(stripDangerousKeys("hello")).toBe("hello");
expect(stripDangerousKeys(42)).toBe(42);
expect(stripDangerousKeys(true)).toBe(true);
expect(stripDangerousKeys(null)).toBe(null);
});

it("preserves normal keys", () => {
const input = { agents: { a: 1 }, clouds: { b: 2 }, matrix: { c: 3 } };
const result = stripDangerousKeys(input);
expect(result).toEqual(input);
});

it("handles deeply nested dangerous keys", () => {
const input = { a: { b: { c: { constructor: "bad", value: "good" } } } };
const result = stripDangerousKeys(input);
expect(result.a.b.c.value).toBe("good");
expect(Object.keys(result.a.b.c)).toEqual(["value"]);
});
});
18 changes: 15 additions & 3 deletions cli/src/history.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import { join, resolve, isAbsolute } from "path";
import { homedir } from "os";

export interface SpawnRecord {
Expand All@@ -9,9 +9,21 @@ export interface SpawnRecord {
prompt?: string;
}

/** Returns the directory for spawn data, respecting SPAWN_HOME env var */
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
return process.env.SPAWN_HOME || join(homedir(), ".spawn");
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) return join(homedir(), ".spawn");
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` +
`Example: export SPAWN_HOME=/home/user/.spawn`
);
}
// Resolve to canonical form (collapses .. segments)
return resolve(spawnHome);
}

export function getHistoryPath(): string {
Expand Down
27 changes: 22 additions & 5 deletions cli/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,8 @@ function logError(message: string, err?: unknown): void {

function readCache(): Manifest | null {
try {
return JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as Manifest;
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
return stripDangerousKeys(raw) as Manifest;
} catch (err) {
// Cache file missing, corrupted, or unreadable
logError(`Failed to read cache from ${CACHE_FILE}`, err);
Expand All@@ -81,8 +82,22 @@ function writeCache(data: Manifest): void {

// ── Fetching ───────────────────────────────────────────────────────────────────

/** Recursively strip __proto__, constructor, and prototype keys from parsed JSON
* to prevent prototype pollution attacks (defense in depth). */
function stripDangerousKeys(obj: any): any {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
const clean: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clean[key] = stripDangerousKeys(obj[key]);
}
return clean;
}

function isValidManifest(data: any): data is Manifest {
return data && data.agents && data.clouds && data.matrix;
return data && typeof data === "object" && !Array.isArray(data) &&
data.agents && data.clouds && data.matrix;
}

async function fetchManifestFromGitHub(): Promise<Manifest | null> {
Expand All@@ -94,7 +109,8 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
logError(`Failed to fetch manifest from GitHub: HTTP ${res.status} ${res.statusText}`);
return null;
}
const data = (await res.json()) as Manifest;
const raw = await res.json();
const data = stripDangerousKeys(raw) as Manifest;
if (!isValidManifest(data)) {
logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)");
return null;
Expand DownExpand Up@@ -132,7 +148,8 @@ function tryLoadLocalManifest(): Manifest | null {
// Try loading manifest.json from current directory (development mode)
const localPath = join(process.cwd(), "manifest.json");
if (existsSync(localPath)) {
const data = JSON.parse(readFileSync(localPath, "utf-8"));
const raw = JSON.parse(readFileSync(localPath, "utf-8"));
const data = stripDangerousKeys(raw);
if (isValidManifest(data)) {
return data as Manifest;
}
Expand DownExpand Up@@ -226,4 +243,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}

export { RAW_BASE, REPO, CACHE_DIR };
export { RAW_BASE, REPO, CACHE_DIR, stripDangerousKeys };
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.73",
"version": "0.2.74",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/__tests__/history.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,26 @@ describe("history", () => {
const { homedir } = require("os");
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("throws for dot-relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "./local/dir";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("resolves .. segments in absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/tmp/foo/../bar";
expect(getSpawnDir()).toBe("/tmp/bar");
});

it("accepts normal absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/home/user/.spawn";
expect(getSpawnDir()).toBe("/home/user/.spawn");
});
});

// ── getHistoryPath ──────────────────────────────────────────────────────
Expand Down
64 changes: 64 additions & 0 deletions cli/src/__tests__/security-encoding.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,3 +160,67 @@ describe("Security Encoding Edge Cases", () => {
});
});
});

// ── stripDangerousKeys (prototype pollution defense) ─────────────────────────

import { stripDangerousKeys } from "../manifest";

describe("stripDangerousKeys", () => {
it("strips __proto__ from parsed JSON", () => {
// JSON.parse produces an own-property __proto__ key (not inherited)
const input = JSON.parse('{"agents":{},"clouds":{},"matrix":{},"__proto__":{"polluted":true}}');
expect(Object.prototype.hasOwnProperty.call(input, "__proto__")).toBe(true);
const result = stripDangerousKeys(input);
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false);
expect(result.agents).toEqual({});
});

it("strips constructor key", () => {
const input = Object.assign(Object.create(null), { name: "test", constructor: { evil: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["name"]);
expect(result.name).toBe("test");
});

it("strips prototype key", () => {
const input = Object.assign(Object.create(null), { data: 1, prototype: { inject: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["data"]);
expect(result.data).toBe(1);
});

it("strips dangerous keys from nested objects", () => {
const input = { agents: { claude: { __proto__: { evil: true }, name: "Claude" } } };
const result = stripDangerousKeys(input);
expect(result.agents.claude.name).toBe("Claude");
expect(Object.keys(result.agents.claude)).toEqual(["name"]);
});

it("handles arrays correctly", () => {
const input = { items: [{ name: "a" }, { name: "b", __proto__: {} }] };
const result = stripDangerousKeys(input);
expect(result.items).toHaveLength(2);
expect(result.items[0].name).toBe("a");
expect(result.items[1].name).toBe("b");
});

it("passes through primitives unchanged", () => {
expect(stripDangerousKeys("hello")).toBe("hello");
expect(stripDangerousKeys(42)).toBe(42);
expect(stripDangerousKeys(true)).toBe(true);
expect(stripDangerousKeys(null)).toBe(null);
});

it("preserves normal keys", () => {
const input = { agents: { a: 1 }, clouds: { b: 2 }, matrix: { c: 3 } };
const result = stripDangerousKeys(input);
expect(result).toEqual(input);
});

it("handles deeply nested dangerous keys", () => {
const input = { a: { b: { c: { constructor: "bad", value: "good" } } } };
const result = stripDangerousKeys(input);
expect(result.a.b.c.value).toBe("good");
expect(Object.keys(result.a.b.c)).toEqual(["value"]);
});
});
18 changes: 15 additions & 3 deletions cli/src/history.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import { join, resolve, isAbsolute } from "path";
import { homedir } from "os";

export interface SpawnRecord {
Expand All@@ -9,9 +9,21 @@ export interface SpawnRecord {
prompt?: string;
}

/** Returns the directory for spawn data, respecting SPAWN_HOME env var */
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
return process.env.SPAWN_HOME || join(homedir(), ".spawn");
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) return join(homedir(), ".spawn");
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` +
`Example: export SPAWN_HOME=/home/user/.spawn`
);
}
// Resolve to canonical form (collapses .. segments)
return resolve(spawnHome);
}

export function getHistoryPath(): string {
Expand Down
27 changes: 22 additions & 5 deletions cli/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,8 @@ function logError(message: string, err?: unknown): void {

function readCache(): Manifest | null {
try {
return JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as Manifest;
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
return stripDangerousKeys(raw) as Manifest;
} catch (err) {
// Cache file missing, corrupted, or unreadable
logError(`Failed to read cache from ${CACHE_FILE}`, err);
Expand All@@ -81,8 +82,22 @@ function writeCache(data: Manifest): void {

// ── Fetching ───────────────────────────────────────────────────────────────────

/** Recursively strip __proto__, constructor, and prototype keys from parsed JSON
* to prevent prototype pollution attacks (defense in depth). */
function stripDangerousKeys(obj: any): any {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
const clean: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clean[key] = stripDangerousKeys(obj[key]);
}
return clean;
}

function isValidManifest(data: any): data is Manifest {
return data && data.agents && data.clouds && data.matrix;
return data && typeof data === "object" && !Array.isArray(data) &&
data.agents && data.clouds && data.matrix;
}

async function fetchManifestFromGitHub(): Promise<Manifest | null> {
Expand All@@ -94,7 +109,8 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
logError(`Failed to fetch manifest from GitHub: HTTP ${res.status} ${res.statusText}`);
return null;
}
const data = (await res.json()) as Manifest;
const raw = await res.json();
const data = stripDangerousKeys(raw) as Manifest;
if (!isValidManifest(data)) {
logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)");
return null;
Expand DownExpand Up@@ -132,7 +148,8 @@ function tryLoadLocalManifest(): Manifest | null {
// Try loading manifest.json from current directory (development mode)
const localPath = join(process.cwd(), "manifest.json");
if (existsSync(localPath)) {
const data = JSON.parse(readFileSync(localPath, "utf-8"));
const raw = JSON.parse(readFileSync(localPath, "utf-8"));
const data = stripDangerousKeys(raw);
if (isValidManifest(data)) {
return data as Manifest;
}
Expand DownExpand Up@@ -226,4 +243,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}

export { RAW_BASE, REPO, CACHE_DIR };
export { RAW_BASE, REPO, CACHE_DIR, stripDangerousKeys };
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.73",
"version": "0.2.74",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/__tests__/history.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,26 @@ describe("history", () => {
const { homedir } = require("os");
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
});

it("throws for relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("throws for dot-relative SPAWN_HOME path", () => {
process.env.SPAWN_HOME = "./local/dir";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});

it("resolves .. segments in absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/tmp/foo/../bar";
expect(getSpawnDir()).toBe("/tmp/bar");
});

it("accepts normal absolute SPAWN_HOME", () => {
process.env.SPAWN_HOME = "/home/user/.spawn";
expect(getSpawnDir()).toBe("/home/user/.spawn");
});
});

// ── getHistoryPath ──────────────────────────────────────────────────────
Expand Down
64 changes: 64 additions & 0 deletions cli/src/__tests__/security-encoding.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,3 +160,67 @@ describe("Security Encoding Edge Cases", () => {
});
});
});

// ── stripDangerousKeys (prototype pollution defense) ─────────────────────────

import { stripDangerousKeys } from "../manifest";

describe("stripDangerousKeys", () => {
it("strips __proto__ from parsed JSON", () => {
// JSON.parse produces an own-property __proto__ key (not inherited)
const input = JSON.parse('{"agents":{},"clouds":{},"matrix":{},"__proto__":{"polluted":true}}');
expect(Object.prototype.hasOwnProperty.call(input, "__proto__")).toBe(true);
const result = stripDangerousKeys(input);
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(false);
expect(result.agents).toEqual({});
});

it("strips constructor key", () => {
const input = Object.assign(Object.create(null), { name: "test", constructor: { evil: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["name"]);
expect(result.name).toBe("test");
});

it("strips prototype key", () => {
const input = Object.assign(Object.create(null), { data: 1, prototype: { inject: true } });
const result = stripDangerousKeys(input);
expect(Object.keys(result)).toEqual(["data"]);
expect(result.data).toBe(1);
});

it("strips dangerous keys from nested objects", () => {
const input = { agents: { claude: { __proto__: { evil: true }, name: "Claude" } } };
const result = stripDangerousKeys(input);
expect(result.agents.claude.name).toBe("Claude");
expect(Object.keys(result.agents.claude)).toEqual(["name"]);
});

it("handles arrays correctly", () => {
const input = { items: [{ name: "a" }, { name: "b", __proto__: {} }] };
const result = stripDangerousKeys(input);
expect(result.items).toHaveLength(2);
expect(result.items[0].name).toBe("a");
expect(result.items[1].name).toBe("b");
});

it("passes through primitives unchanged", () => {
expect(stripDangerousKeys("hello")).toBe("hello");
expect(stripDangerousKeys(42)).toBe(42);
expect(stripDangerousKeys(true)).toBe(true);
expect(stripDangerousKeys(null)).toBe(null);
});

it("preserves normal keys", () => {
const input = { agents: { a: 1 }, clouds: { b: 2 }, matrix: { c: 3 } };
const result = stripDangerousKeys(input);
expect(result).toEqual(input);
});

it("handles deeply nested dangerous keys", () => {
const input = { a: { b: { c: { constructor: "bad", value: "good" } } } };
const result = stripDangerousKeys(input);
expect(result.a.b.c.value).toBe("good");
expect(Object.keys(result.a.b.c)).toEqual(["value"]);
});
});
18 changes: 15 additions & 3 deletions cli/src/history.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { join } from "path";
import { join, resolve, isAbsolute } from "path";
import { homedir } from "os";

export interface SpawnRecord {
Expand All@@ -9,9 +9,21 @@ export interface SpawnRecord {
prompt?: string;
}

/** Returns the directory for spawn data, respecting SPAWN_HOME env var */
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
return process.env.SPAWN_HOME || join(homedir(), ".spawn");
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) return join(homedir(), ".spawn");
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` +
`Example: export SPAWN_HOME=/home/user/.spawn`
);
}
// Resolve to canonical form (collapses .. segments)
return resolve(spawnHome);
}

export function getHistoryPath(): string {
Expand Down
27 changes: 22 additions & 5 deletions cli/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,8 @@ function logError(message: string, err?: unknown): void {

function readCache(): Manifest | null {
try {
return JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as Manifest;
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
return stripDangerousKeys(raw) as Manifest;
} catch (err) {
// Cache file missing, corrupted, or unreadable
logError(`Failed to read cache from ${CACHE_FILE}`, err);
Expand All@@ -81,8 +82,22 @@ function writeCache(data: Manifest): void {

// ── Fetching ───────────────────────────────────────────────────────────────────

/** Recursively strip __proto__, constructor, and prototype keys from parsed JSON
* to prevent prototype pollution attacks (defense in depth). */
function stripDangerousKeys(obj: any): any {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
const clean: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clean[key] = stripDangerousKeys(obj[key]);
}
return clean;
}

function isValidManifest(data: any): data is Manifest {
return data && data.agents && data.clouds && data.matrix;
return data && typeof data === "object" && !Array.isArray(data) &&
data.agents && data.clouds && data.matrix;
}

async function fetchManifestFromGitHub(): Promise<Manifest | null> {
Expand All@@ -94,7 +109,8 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
logError(`Failed to fetch manifest from GitHub: HTTP ${res.status} ${res.statusText}`);
return null;
}
const data = (await res.json()) as Manifest;
const raw = await res.json();
const data = stripDangerousKeys(raw) as Manifest;
if (!isValidManifest(data)) {
logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)");
return null;
Expand DownExpand Up@@ -132,7 +148,8 @@ function tryLoadLocalManifest(): Manifest | null {
// Try loading manifest.json from current directory (development mode)
const localPath = join(process.cwd(), "manifest.json");
if (existsSync(localPath)) {
const data = JSON.parse(readFileSync(localPath, "utf-8"));
const raw = JSON.parse(readFileSync(localPath, "utf-8"));
const data = stripDangerousKeys(raw);
if (isValidManifest(data)) {
return data as Manifest;
}
Expand DownExpand Up@@ -226,4 +243,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}

export { RAW_BASE, REPO, CACHE_DIR };
export { RAW_BASE, REPO, CACHE_DIR, stripDangerousKeys };