Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ coverage/
.bun.lockb
.memory/
opencode-plugin-template/
opencode-docs-*/
opencode-docs-*/
.worktrees/
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,12 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
- `~/.config/opencode/opencode.json` and `opencode.jsonc`
- `~/.config/opencode/AGENTS.md`
- `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/`
- `~/.config/opencode/agents/`, `instructions/`, `plugins/`, `skills/`, `superpowers/`
- `~/.local/state/opencode/model.json` (model favorites)
- Any extra paths in `extraConfigPaths` (allowlist, files or folders)

Extra path manifests store home-relative `~` paths when possible to avoid Linux/macOS/WSL path churn.

### Secrets (private repos only)

Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`:
Expand Down
166 changes: 166 additions & 0 deletions src/sync/apply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';

import { syncRepoToLocal } from './apply.js';
import type { SyncPlan } from './paths.js';
import { normalizePath } from './paths.js';

describe('syncRepoToLocal extra manifest repoPath validation', () => {
it('rejects absolute repoPath values from manifest', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-apply-'));
try {
const homeDir = path.join(root, 'home');
const repoRoot = path.join(root, 'repo');
const localPath = path.join(homeDir, 'target.txt');
const manifestPath = path.join(repoRoot, 'config', 'extra-manifest.json');

await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(
manifestPath,
JSON.stringify(
{
entries: [
{
sourcePath: localPath,
repoPath: '/etc/passwd',
type: 'file',
},
],
},
null,
2
),
'utf8'
);

const plan: SyncPlan = {
items: [],
extraConfigs: {
allowlist: [normalizePath(localPath, homeDir, 'linux')],
manifestPath,
entries: [],
},
extraSecrets: {
allowlist: [],
manifestPath: path.join(repoRoot, 'secrets', 'extra-manifest.json'),
entries: [],
},
repoRoot,
homeDir,
platform: 'linux',
};

await expect(syncRepoToLocal(plan, null)).rejects.toThrow(/absolute paths are not allowed/);
} finally {
await rm(root, { recursive: true, force: true });
}
});

it('rejects traversal repoPath values from manifest', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-apply-'));
try {
const homeDir = path.join(root, 'home');
const repoRoot = path.join(root, 'repo');
const localPath = path.join(homeDir, 'target.txt');
const manifestPath = path.join(repoRoot, 'config', 'extra-manifest.json');

await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(
manifestPath,
JSON.stringify(
{
entries: [
{
sourcePath: localPath,
repoPath: '../../etc/passwd',
type: 'file',
},
],
},
null,
2
),
'utf8'
);

const plan: SyncPlan = {
items: [],
extraConfigs: {
allowlist: [normalizePath(localPath, homeDir, 'linux')],
manifestPath,
entries: [],
},
extraSecrets: {
allowlist: [],
manifestPath: path.join(repoRoot, 'secrets', 'extra-manifest.json'),
entries: [],
},
repoRoot,
homeDir,
platform: 'linux',
};

await expect(syncRepoToLocal(plan, null)).rejects.toThrow(/path escapes repository root/);
} finally {
await rm(root, { recursive: true, force: true });
}
});

it('accepts safe relative repoPath values from manifest', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-apply-'));
try {
const homeDir = path.join(root, 'home');
const repoRoot = path.join(root, 'repo');
const localPath = path.join(homeDir, 'target.txt');
const manifestPath = path.join(repoRoot, 'config', 'extra-manifest.json');
const repoSourcePath = path.join(repoRoot, 'config', 'extra', 'safe.txt');

await mkdir(path.dirname(manifestPath), { recursive: true });
await mkdir(path.dirname(repoSourcePath), { recursive: true });
await writeFile(repoSourcePath, 'safe-data\n', 'utf8');
await writeFile(
manifestPath,
JSON.stringify(
{
entries: [
{
sourcePath: localPath,
repoPath: 'config/extra/safe.txt',
type: 'file',
},
],
},
null,
2
),
'utf8'
);

const plan: SyncPlan = {
items: [],
extraConfigs: {
allowlist: [normalizePath(localPath, homeDir, 'linux')],
manifestPath,
entries: [],
},
extraSecrets: {
allowlist: [],
manifestPath: path.join(repoRoot, 'secrets', 'extra-manifest.json'),
entries: [],
},
repoRoot,
homeDir,
platform: 'linux',
};

await syncRepoToLocal(plan, null);

const output = await readFile(localPath, 'utf8');
expect(output).toBe('safe-data\n');
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
27 changes: 22 additions & 5 deletions src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
stripOverrideKeys,
} from './mcp-secrets.js';
import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js';
import { normalizePath } from './paths.js';
import { expandHome, normalizePath } from './paths.js';

type ExtraPathType = 'file' | 'dir';

Expand Down Expand Up @@ -238,10 +238,8 @@ async function applyExtraPaths(plan: SyncPlan, extra: ExtraPathPlan): Promise<vo
const isAllowed = allowlist.includes(normalized);
if (!isAllowed) continue;

const repoPath = path.isAbsolute(entry.repoPath)
? entry.repoPath
: path.join(plan.repoRoot, entry.repoPath);
const localPath = entry.sourcePath;
const repoPath = resolveManifestRepoPath(plan.repoRoot, entry.repoPath);
const localPath = expandHome(entry.sourcePath, plan.homeDir);
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

The applyExtraPaths function trusts the repoPath provided in the manifest file from the repository. An attacker who controls the sync repository can modify the extra-manifest.json to set repoPath to an absolute path or use path traversal (e.g., ../../etc/passwd). During a syncRepoToLocal operation, this would cause the client to copy arbitrary files from the local system into the synced directories. If these directories are subsequently synced back to the repository (via syncLocalToRepo), sensitive local files could be exfiltrated to the attacker-controlled repository.

To remediate this, ensure that repoPath is a relative path and that the resolved path remains within the repository root.

Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 95a77c0. applyExtraPaths now validates manifest repoPath with resolveManifestRepoPath: absolute paths are rejected and any path that escapes plan.repoRoot is rejected. Added regression tests in src/sync/apply.test.ts for absolute and traversal cases.

const entryType: ExtraPathType = entry.type ?? 'file';

if (!(await pathExists(repoPath))) continue;
Expand Down Expand Up @@ -374,6 +372,25 @@ function resolveExtraPathItem(basePath: string, relativePath: string): string |
return resolvedPath;
}

function resolveManifestRepoPath(repoRoot: string, manifestRepoPath: string): string {
if (path.isAbsolute(manifestRepoPath)) {
throw new Error('Invalid extra manifest repoPath: absolute paths are not allowed');
}

const resolvedRepoRoot = path.resolve(repoRoot);
const resolvedRepoPath = path.resolve(repoRoot, manifestRepoPath);
const relativePath = path.relative(resolvedRepoRoot, resolvedRepoPath);
const outsideRepo =
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath);
if (outsideRepo) {
throw new Error('Invalid extra manifest repoPath: path escapes repository root');
}

return resolvedRepoPath;
}

function isDeepEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
if (typeof left !== typeof right) return false;
Expand Down
61 changes: 61 additions & 0 deletions src/sync/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ describe('resolveSyncLocations', () => {
});

describe('buildSyncPlan', () => {
it('includes modern opencode config directories by default', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');
const localPaths = new Set(plan.items.map((item) => item.localPath));

expect(localPaths.has('/home/test/.config/opencode/agents')).toBe(true);
expect(localPaths.has('/home/test/.config/opencode/instructions')).toBe(true);
expect(localPaths.has('/home/test/.config/opencode/plugins')).toBe(true);
expect(localPaths.has('/home/test/.config/opencode/skills')).toBe(true);
expect(localPaths.has('/home/test/.config/opencode/superpowers')).toBe(true);
});

it('excludes secrets when includeSecrets is false', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
Expand Down Expand Up @@ -87,6 +105,49 @@ describe('buildSyncPlan', () => {
expect(plan.extraConfigs.allowlist.length).toBe(0);
});

it('stores extra config paths as portable home-relative source paths', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['/home/test/.config/opencode/skills'],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');

expect(plan.extraConfigs.entries[0]?.sourcePath).toBe('~/.config/opencode/skills');
});

it('uses stable repo paths for home-relative extras across platforms', () => {
const linuxEnv = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const macEnv = { HOME: '/Users/test' } as NodeJS.ProcessEnv;
const linuxLocations = resolveSyncLocations(linuxEnv, 'linux');
const macLocations = resolveSyncLocations(macEnv, 'darwin');
const linuxConfig: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['/home/test/.config/opencode/skills'],
};
const macConfig: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['/Users/test/.config/opencode/skills'],
};

const linuxPlan = buildSyncPlan(
normalizeSyncConfig(linuxConfig),
linuxLocations,
'/repo',
'linux'
);
const macPlan = buildSyncPlan(normalizeSyncConfig(macConfig), macLocations, '/repo', 'darwin');

expect(linuxPlan.extraConfigs.entries[0]?.repoPath).toBe(
macPlan.extraConfigs.entries[0]?.repoPath
);
});

it('includes secrets when includeSecrets is true', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
Expand Down
49 changes: 44 additions & 5 deletions src/sync/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,19 @@ const DEFAULT_SYNC_CONFIG_NAME = 'opencode-synced.jsonc';
const DEFAULT_OVERRIDES_NAME = 'opencode-synced.overrides.jsonc';
const DEFAULT_STATE_NAME = 'sync-state.json';

const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin'];
const CONFIG_DIRS = [
'agent',
'command',
'mode',
'tool',
'themes',
'plugin',
'agents',
'instructions',
'plugins',
'skills',
'superpowers',
];
const SESSION_DIRS = ['storage/session', 'storage/message', 'storage/part', 'storage/session_diff'];
const PROMPT_STASH_FILES = ['prompt-stash.jsonl', 'prompt-history.jsonl'];
const MODEL_FAVORITES_FILE = 'model.json';
Expand Down Expand Up @@ -156,6 +168,30 @@ export function encodeExtraPath(inputPath: string): string {
return `${base}-${hash}`;
}

export function toPortableSourcePath(
inputPath: string,
homeDir: string,
platform: NodeJS.Platform = process.platform
): string {
if (!homeDir) {
return inputPath;
}

const normalizedInput = normalizePath(inputPath, homeDir, platform);
const normalizedHome = normalizePath(homeDir, homeDir, platform);
if (normalizedInput === normalizedHome) {
return '~';
}

const relativeToHome = path.relative(normalizedHome, normalizedInput);
const outsideHome = relativeToHome === '..' || relativeToHome.startsWith(`..${path.sep}`);
if (outsideHome || path.isAbsolute(relativeToHome)) {
return normalizedInput;
}

return `~/${relativeToHome.split(path.sep).join('/')}`;
}

export const encodeSecretPath = encodeExtraPath;

export function resolveRepoRoot(config: SyncConfig | null, locations: SyncLocations): string {
Expand Down Expand Up @@ -319,10 +355,13 @@ function buildExtraPathPlan(
normalizePath(entry, locations.xdg.homeDir, platform)
);

const entries = allowlist.map((sourcePath) => ({
sourcePath,
repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)),
}));
const entries = allowlist.map((sourcePath) => {
const portableSourcePath = toPortableSourcePath(sourcePath, locations.xdg.homeDir, platform);
return {
sourcePath: portableSourcePath,
repoPath: path.join(repoExtraDir, encodeExtraPath(portableSourcePath)),
};
});

return {
allowlist,
Expand Down
Loading