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
79 changes: 79 additions & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: 'no-renderer-importing-main',
severity: 'error',
comment:
'Renderer (src/) must never import Electron main-process code. ' +
'Use IPC channels instead.',
from: { path: '^src/' },
to: {
path: '^electron/',
// Allow importing the shared IPC channel enum (channels.ts is a pure enum, no Node/Electron deps)
pathNot: '^electron/ipc/channels\\.ts',
},
},
{
name: 'no-mcp-importing-components',
severity: 'error',
comment: 'MCP coordinator must not import frontend components or store.',
from: { path: '^electron/mcp/' },
to: { path: '^src/(components|store|lib)/' },
},
{
name: 'no-circular',
severity: 'error',
comment:
'Circular dependencies break tree-shaking and make reasoning about startup order impossible.',
from: {},
to: { circular: true },
},
{
name: 'no-orphans',
severity: 'warn',
comment: 'Orphan modules have no importers and no exports used elsewhere — likely dead code.',
from: {
orphan: true,
// Test files, config files, entry points, and pure type modules are expected orphans.
// Type-only modules (types.ts, *.d.ts) are consumed by TypeScript structurally — the
// import graph doesn't capture all type-level usage, so they appear orphaned.
pathNot: [
'\\.test\\.(ts|tsx)$',
'\\.config\\.(ts|js|cjs)$',
'\\.d\\.ts$',
'types\\.ts$',
'types\\.(ts|tsx)$',
'^src/main\\.tsx$',
'^src/remote/main\\.tsx$',
'^electron/main\\.ts$',
'^electron/preload\\.cjs$',
'^electron/mcp/server\\.ts$',
// New subsystem files have no importers until coordinator PRs land
'^electron/mcp/atomic\\.ts$',
// Vite ambient env declarations
'^src/vite-env\\.d\\.ts$',
],
},
to: {},
},
],

options: {
doNotFollow: {
path: 'node_modules',
},
moduleSystems: ['es6', 'cjs'],
tsConfig: {
fileName: 'tsconfig.json',
},
reporterOptions: {
dot: {
collapsePattern: 'node_modules/[^/]+',
},
archi: {
collapsePattern: '^(node_modules|src/components)/[^/]+',
},
},
},
};
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ dist-electron
dist-remote
release
coverage
# Runtime data dir written by the app (MCP coordinator state, etc.)
.parallel-code/
.worktrees
.idea
.claude
Expand Down
43 changes: 43 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
title = "Gitleaks config for Parallel Code"

[extend]
# Use the default Gitleaks ruleset as the base
useDefault = true

[[rules]]
id = "parallel-code-mcp-token"
description = "Parallel Code MCP bearer token"
regex = '''PARALLEL_CODE_MCP_TOKEN\s*[=:]\s*['"]?[A-Za-z0-9+/_-]{20,}['"]?'''
tags = ["token", "parallel-code"]

[[rules]]
id = "bearer-token-in-url"
description = "Bearer token embedded in a URL query parameter"
regex = '''[?&]token=[A-Za-z0-9+/\-_]{20,}'''
tags = ["token", "url"]
[rules.allowlist]
# Test fixture URLs and documentation are expected to have placeholder tokens
regexes = [
'''token=<[A-Za-z0-9_-]+>''', # placeholder like ?token=<your-token>
'''token=test''', # obvious test value
'''token=abc''', # obvious test value
'''token=tok''', # obvious test value
]

[[rules]]
id = "anthropic-api-key"
description = "Anthropic API key"
regex = '''sk-ant-[A-Za-z0-9\-_]{40,}'''
tags = ["api-key", "anthropic"]

[allowlist]
description = "Global allowlist"
paths = [
# Lock files contain package hashes, not secrets
'''package-lock\.json''',
# Test fixture files with obviously fake values
'''\.test\.(ts|tsx)$''',
# The gitleaks config itself documents patterns
'''\.gitleaks\.toml''',
]
commits = []
52 changes: 52 additions & 0 deletions .semgrep/electron-security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
rules:
- id: no-inner-html-without-sanitize
pattern: $EL.innerHTML = $VAL
pattern-not: $EL.innerHTML = DOMPurify.sanitize(...)
message: |
Direct innerHTML assignment without DOMPurify.sanitize() risks XSS.
Use the sanitizeHtml() helper or DOMPurify.sanitize() explicitly.
languages: [typescript]
severity: ERROR
paths:
include:
- '**/electron/**'

- id: no-eval
pattern: eval($X)
message: |
eval() executes arbitrary code. Not allowed in Electron renderer or main process.
languages: [typescript]
severity: ERROR

- id: no-new-function
pattern: new Function($X)
message: |
new Function() executes arbitrary code. Not allowed in Electron renderer or main process.
languages: [typescript]
severity: ERROR

- id: no-shell-true-in-spawn
pattern-either:
- pattern: |
child_process.spawn($CMD, $ARGS, {shell: true, ...})
- pattern: |
spawn($CMD, $ARGS, {shell: true, ...})
message: |
spawn() with shell:true enables shell injection. Use shell:false and
pass arguments as an array.
languages: [typescript]
severity: ERROR
paths:
include:
- '**/electron/**'

- id: pkill-dash-f-broad-kill
pattern: $EXEC("pkill", ["-f", ...], ...)
message: |
pkill -f matches any process whose full command line contains the pattern,
which can accidentally kill unrelated processes. Use kill by PID or docker stop.
languages: [typescript]
severity: WARNING
paths:
include:
- '**/electron/**'
26 changes: 26 additions & 0 deletions .semgrep/filesystem-safety.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
rules:
- id: direct-writefile-in-mcp-coordinator
pattern-either:
- pattern: writeFileSync($PATH, $DATA, ...)
- pattern: fs.writeFileSync($PATH, $DATA, ...)
message: |
Direct writeFileSync in coordinator code risks torn writes on crash.
Use atomicWriteFileSync() from electron/mcp/atomic.ts instead.
languages: [typescript]
severity: WARNING
paths:
include:
- '**/electron/mcp/**'
- '**/electron/ipc/register.ts'

- id: copyfilesync-side-effect
pattern: fs.copyFileSync($SRC, $DST)
message: |
fs.copyFileSync is a filesystem side effect. In StartMCPServer,
ensure all pure computation (mcpConfig, mergedMcpJson) precedes
any copyFileSync calls so validation failures don't leave residue.
languages: [typescript]
severity: INFO
paths:
include:
- '**/electron/ipc/register.ts'
30 changes: 30 additions & 0 deletions .semgrep/ipc-auth.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
rules:
- id: token-embedded-in-url-template
pattern: |
`$PREFIX?token=${$TOKEN}$SUFFIX`
message: |
Token embedded directly in URL template literal. Mobile/shared URLs must use
the mobile token, not the coordinator token. The coordinator token must never
appear in any URL that reaches the renderer or network.
languages: [typescript]
severity: ERROR
paths:
include:
- '**/electron/**'
exclude:
- '**/electron/remote/server.ts'

- id: console-log-token-variable
pattern-either:
- pattern: console.log($A, token, $B)
- pattern: console.warn($A, token, $B)
- pattern: console.log(token)
- pattern: console.warn(token)
message: |
Logging a variable named 'token' directly. Use redactServerUrl() or
ensure this is not a bearer token value.
languages: [typescript]
severity: WARNING
paths:
include:
- '**/electron/**'
7 changes: 1 addition & 6 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
assertOptionalString,
assertOptionalBoolean,
} from './validate.js';
import { validateBranchName } from '../mcp/validation.js';
import { warn as logWarn } from '../log.js';

function errMessage(err: unknown): string {
Expand All @@ -103,12 +104,6 @@ function validateRelativePath(p: unknown, label: string): void {
if (p.includes('..')) throw new Error(`${label} must not contain ".."`);
}

/** Reject branch names that could be misinterpreted as git flags. */
function validateBranchName(name: unknown, label: string): void {
if (typeof name !== 'string' || !name) throw new Error(`${label} must be a non-empty string`);
if (name.startsWith('-')) throw new Error(`${label} must not start with "-"`);
}

/** Reject commit hashes that are not valid hex strings. */
function validateCommitHash(hash: unknown, label: string): void {
if (typeof hash !== 'string') throw new Error(`${label} must be a string`);
Expand Down
122 changes: 122 additions & 0 deletions electron/mcp/atomic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, stat } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { atomicWriteFile, atomicWriteFileSync } from './atomic.js';

let dir: string;

afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});

async function makeDir() {
dir = await mkdtemp(join(tmpdir(), 'atomic-test-'));
return dir;
}

describe('atomicWriteFile (async)', () => {
it('writes content to the target path', async () => {
const d = await makeDir();
const target = join(d, 'out.json');
await atomicWriteFile(target, '{"ok":true}');
const content = await readFile(target, 'utf8');
expect(content).toBe('{"ok":true}');
});

it('overwrites existing file', async () => {
const d = await makeDir();
const target = join(d, 'out.json');
await atomicWriteFile(target, 'first');
await atomicWriteFile(target, 'second');
expect(await readFile(target, 'utf8')).toBe('second');
});

it('leaves no temp file on success', async () => {
const d = await makeDir();
const target = join(d, 'out.json');
await atomicWriteFile(target, 'hello');
const files = await import('fs/promises').then((m) => m.readdir(d));
expect(files).toEqual(['out.json']);
});

it('sets file mode when provided', async () => {
const d = await makeDir();
const target = join(d, 'secret.json');
await atomicWriteFile(target, 'data', { mode: 0o600 });
const s = await stat(target);
expect(s.mode & 0o777).toBe(0o600);
});

it('preserves existing 0600 mode on overwrite when no mode specified', async () => {
const d = await makeDir();
const target = join(d, 'secret.json');
await atomicWriteFile(target, 'original', { mode: 0o600 });
await atomicWriteFile(target, 'overwritten'); // no mode option
const s = await stat(target);
expect(s.mode & 0o777).toBe(0o600);
expect(await readFile(target, 'utf8')).toBe('overwritten');
});

it('sets exact mode even when umask would narrow it', async () => {
const d = await makeDir();
const target = join(d, 'wide.json');
const prev = process.umask(0o022);
try {
await atomicWriteFile(target, 'data', { mode: 0o666 });
} finally {
process.umask(prev);
}
const s = await stat(target);
expect(s.mode & 0o777).toBe(0o666);
});
});

describe('atomicWriteFileSync (sync)', () => {
it('writes content to the target path', async () => {
const d = await makeDir();
const target = join(d, 'out.json');
atomicWriteFileSync(target, '{"ok":true}');
const content = await readFile(target, 'utf8');
expect(content).toBe('{"ok":true}');
});

it('overwrites existing file', async () => {
const d = await makeDir();
const target = join(d, 'out.json');
atomicWriteFileSync(target, 'first');
atomicWriteFileSync(target, 'second');
expect(await readFile(target, 'utf8')).toBe('second');
});

it('sets file mode when provided', async () => {
const d = await makeDir();
const target = join(d, 'secret.json');
atomicWriteFileSync(target, 'data', { mode: 0o600 });
const s = await stat(target);
expect(s.mode & 0o777).toBe(0o600);
});

it('preserves existing 0600 mode on overwrite when no mode specified', async () => {
const d = await makeDir();
const target = join(d, 'secret.json');
atomicWriteFileSync(target, 'original', { mode: 0o600 });
atomicWriteFileSync(target, 'overwritten'); // no mode option
const s = await stat(target);
expect(s.mode & 0o777).toBe(0o600);
expect(await readFile(target, 'utf8')).toBe('overwritten');
});

it('sets exact mode even when umask would narrow it', async () => {
const d = await makeDir();
const target = join(d, 'wide.json');
const prev = process.umask(0o022);
try {
atomicWriteFileSync(target, 'data', { mode: 0o666 });
} finally {
process.umask(prev);
}
const s = await stat(target);
expect(s.mode & 0o777).toBe(0o666);
});
});
Loading
Loading