Skip to content

Commit 65dede8

Browse files
brookscclaude
andcommitted
feat(security): atomic writes, input validators, and static analysis tooling
Standalone security utilities with no coordinator dependencies — intended as the foundation PR before the coordinator engine lands. ### atomic.ts Crash-safe file writes via temp file + rename: - Temp file written to same directory as target (avoids EXDEV across mounts) - fsync before rename for file durability - Directory fsync after rename for directory-entry durability (platform-safe catch) - Sync variant for use in catch blocks; async variant for normal paths ### validation.ts - validateBranchName(): mirrors git check-ref-format --branch rules — rejects shell metacharacters (:, ^, ~, etc.), double dots, path components starting with ".", any path component ending in ".lock", and other git-illegal patterns - validateUUID(): enforces v4 UUID format (version nibble=4, variant nibble∈{8,9,a,b}) ### Static analysis configs - .semgrep/: rules for unsafe Electron APIs, IPC auth, and filesystem operations scoped to electron/mcp/** and electron/ipc/register.ts - .gitleaks.toml: token/secret leak detection with ASCII quote character classes - .dependency-cruiser.cjs: architecture linting with orphan exemptions for new files - knip.config.ts: dead code detection ### Tests (35 cases) - electron/mcp/validation.test.ts: accepted names, all rejection cases, UUID v4 format - electron/mcp/atomic.test.ts: write, overwrite, mode, no temp-file residue Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6496544 commit 65dede8

11 files changed

Lines changed: 620 additions & 0 deletions

.dependency-cruiser.cjs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/** @type {import('dependency-cruiser').IConfiguration} */
2+
module.exports = {
3+
forbidden: [
4+
{
5+
name: 'no-renderer-importing-main',
6+
severity: 'error',
7+
comment:
8+
'Renderer (src/) must never import Electron main-process code. ' +
9+
'Use IPC channels instead.',
10+
from: { path: '^src/' },
11+
to: {
12+
path: '^electron/',
13+
// Allow importing the shared IPC channel enum (channels.ts is a pure enum, no Node/Electron deps)
14+
pathNot: '^electron/ipc/channels\\.ts',
15+
},
16+
},
17+
{
18+
name: 'no-mcp-importing-components',
19+
severity: 'error',
20+
comment: 'MCP coordinator must not import frontend components or store.',
21+
from: { path: '^electron/mcp/' },
22+
to: { path: '^src/(components|store|lib)/' },
23+
},
24+
{
25+
name: 'no-circular',
26+
severity: 'error',
27+
comment:
28+
'Circular dependencies break tree-shaking and make reasoning about startup order impossible.',
29+
from: {},
30+
to: { circular: true },
31+
},
32+
{
33+
name: 'no-orphans',
34+
severity: 'warn',
35+
comment: 'Orphan modules have no importers and no exports used elsewhere — likely dead code.',
36+
from: {
37+
orphan: true,
38+
// Test files, config files, entry points, and pure type modules are expected orphans.
39+
// Type-only modules (types.ts, *.d.ts) are consumed by TypeScript structurally — the
40+
// import graph doesn't capture all type-level usage, so they appear orphaned.
41+
pathNot: [
42+
'\\.test\\.(ts|tsx)$',
43+
'\\.config\\.(ts|js|cjs)$',
44+
'\\.d\\.ts$',
45+
'types\\.ts$',
46+
'types\\.(ts|tsx)$',
47+
'^src/main\\.tsx$',
48+
'^src/remote/main\\.tsx$',
49+
'^electron/main\\.ts$',
50+
'^electron/preload\\.cjs$',
51+
'^electron/mcp/server\\.ts$',
52+
// New subsystem files have no importers until coordinator PRs land
53+
'^electron/mcp/atomic\\.ts$',
54+
'^electron/mcp/validation\\.ts$',
55+
// Vite ambient env declarations
56+
'^src/vite-env\\.d\\.ts$',
57+
],
58+
},
59+
to: {},
60+
},
61+
],
62+
63+
options: {
64+
doNotFollow: {
65+
path: 'node_modules',
66+
},
67+
moduleSystems: ['es6', 'cjs'],
68+
tsConfig: {
69+
fileName: 'tsconfig.json',
70+
},
71+
reporterOptions: {
72+
dot: {
73+
collapsePattern: 'node_modules/[^/]+',
74+
},
75+
archi: {
76+
collapsePattern: '^(node_modules|src/components)/[^/]+',
77+
},
78+
},
79+
},
80+
};

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ dist-electron
44
dist-remote
55
release
66
coverage
7+
.parallel-code/
78
.worktrees
89
.idea
910
.claude

.gitleaks.toml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
title = "Gitleaks config for Parallel Code"
2+
3+
[extend]
4+
# Use the default Gitleaks ruleset as the base
5+
useDefault = true
6+
7+
[[rules]]
8+
id = "parallel-code-mcp-token"
9+
description = "Parallel Code MCP bearer token"
10+
regex = '''PARALLEL_CODE_MCP_TOKEN\s*[=:]\s*['"]?[A-Za-z0-9+/_-]{20,}['"]?'''
11+
tags = ["token", "parallel-code"]
12+
13+
[[rules]]
14+
id = "bearer-token-in-url"
15+
description = "Bearer token embedded in a URL query parameter"
16+
regex = '''[?&]token=[A-Za-z0-9+/\-_]{20,}'''
17+
tags = ["token", "url"]
18+
[rules.allowlist]
19+
# Test fixture URLs and documentation are expected to have placeholder tokens
20+
regexes = [
21+
'''token=<[A-Za-z0-9_-]+>''', # placeholder like ?token=<your-token>
22+
'''token=test''', # obvious test value
23+
'''token=abc''', # obvious test value
24+
'''token=tok''', # obvious test value
25+
]
26+
27+
[[rules]]
28+
id = "anthropic-api-key"
29+
description = "Anthropic API key"
30+
regex = '''sk-ant-[A-Za-z0-9\-_]{40,}'''
31+
tags = ["api-key", "anthropic"]
32+
33+
[allowlist]
34+
description = "Global allowlist"
35+
paths = [
36+
# Lock files contain package hashes, not secrets
37+
'''package-lock\.json''',
38+
# Test fixture files with obviously fake values
39+
'''\.test\.(ts|tsx)$''',
40+
# The gitleaks config itself documents patterns
41+
'''\.gitleaks\.toml''',
42+
]
43+
commits = []

.semgrep/electron-security.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
rules:
2+
- id: no-inner-html-without-sanitize
3+
pattern: $EL.innerHTML = $VAL
4+
pattern-not: $EL.innerHTML = DOMPurify.sanitize(...)
5+
message: |
6+
Direct innerHTML assignment without DOMPurify.sanitize() risks XSS.
7+
Use the sanitizeHtml() helper or DOMPurify.sanitize() explicitly.
8+
languages: [typescript]
9+
severity: ERROR
10+
paths:
11+
include:
12+
- '**/src/**'
13+
14+
- id: no-eval
15+
pattern: eval($X)
16+
message: |
17+
eval() executes arbitrary code. Not allowed in Electron renderer or main process.
18+
languages: [typescript]
19+
severity: ERROR
20+
21+
- id: no-new-function
22+
pattern: new Function($X)
23+
message: |
24+
new Function() executes arbitrary code. Not allowed in Electron renderer or main process.
25+
languages: [typescript]
26+
severity: ERROR
27+
28+
- id: no-shell-true-in-spawn
29+
pattern: |
30+
child_process.spawn($CMD, $ARGS, {shell: true})
31+
message: |
32+
spawn() with shell:true enables shell injection. Use shell:false and
33+
pass arguments as an array.
34+
languages: [typescript]
35+
severity: ERROR
36+
paths:
37+
include:
38+
- '**/electron/**'
39+
40+
- id: pkill-dash-f-broad-kill
41+
pattern: $EXEC("pkill", ["-f", ...], ...)
42+
message: |
43+
pkill -f matches any process whose full command line contains the pattern,
44+
which can accidentally kill unrelated processes. Use kill by PID or docker stop.
45+
languages: [typescript]
46+
severity: WARNING
47+
paths:
48+
include:
49+
- '**/electron/**'

.semgrep/filesystem-safety.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
rules:
2+
- id: direct-writefile-in-mcp-coordinator
3+
pattern: writeFileSync($PATH, $DATA, ...)
4+
message: |
5+
Direct writeFileSync in coordinator code risks torn writes on crash.
6+
Use atomicWriteFileSync() from electron/mcp/atomic.ts instead.
7+
languages: [typescript]
8+
severity: WARNING
9+
paths:
10+
include:
11+
- '**/electron/mcp/**'
12+
- '**/electron/ipc/register.ts'
13+
14+
- id: copyfilesync-side-effect
15+
pattern: fs.copyFileSync($SRC, $DST)
16+
message: |
17+
fs.copyFileSync is a filesystem side effect. In StartMCPServer,
18+
ensure all pure computation (mcpConfig, mergedMcpJson) precedes
19+
any copyFileSync calls so validation failures don't leave residue.
20+
languages: [typescript]
21+
severity: INFO
22+
paths:
23+
include:
24+
- '**/electron/ipc/register.ts'

.semgrep/ipc-auth.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
rules:
2+
- id: token-embedded-in-url-template
3+
pattern: |
4+
`$PREFIX?token=${$TOKEN}$SUFFIX`
5+
message: |
6+
Token embedded directly in URL template literal. Mobile/shared URLs must use
7+
mobileToken, not the coordinator token. The coordinator token must never appear
8+
in any URL that reaches the renderer or network.
9+
languages: [typescript]
10+
severity: ERROR
11+
paths:
12+
include:
13+
- '**/electron/**'
14+
15+
- id: console-log-token-variable
16+
pattern-either:
17+
- pattern: console.log($A, token, $B)
18+
- pattern: console.warn($A, token, $B)
19+
- pattern: console.log(token)
20+
- pattern: console.warn(token)
21+
message: |
22+
Logging a variable named 'token' directly. Use redactServerUrl() or
23+
ensure this is not a bearer token value.
24+
languages: [typescript]
25+
severity: WARNING
26+
paths:
27+
include:
28+
- '**/electron/**'

electron/mcp/atomic.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect, afterEach } from 'vitest';
2+
import { mkdtemp, rm, readFile, stat } from 'fs/promises';
3+
import { join } from 'path';
4+
import { tmpdir } from 'os';
5+
import { atomicWriteFile, atomicWriteFileSync } from './atomic.js';
6+
7+
let dir: string;
8+
9+
afterEach(async () => {
10+
if (dir) await rm(dir, { recursive: true, force: true });
11+
});
12+
13+
async function makeDir() {
14+
dir = await mkdtemp(join(tmpdir(), 'atomic-test-'));
15+
return dir;
16+
}
17+
18+
describe('atomicWriteFile (async)', () => {
19+
it('writes content to the target path', async () => {
20+
const d = await makeDir();
21+
const target = join(d, 'out.json');
22+
await atomicWriteFile(target, '{"ok":true}');
23+
const content = await readFile(target, 'utf8');
24+
expect(content).toBe('{"ok":true}');
25+
});
26+
27+
it('overwrites existing file', async () => {
28+
const d = await makeDir();
29+
const target = join(d, 'out.json');
30+
await atomicWriteFile(target, 'first');
31+
await atomicWriteFile(target, 'second');
32+
expect(await readFile(target, 'utf8')).toBe('second');
33+
});
34+
35+
it('leaves no temp file on success', async () => {
36+
const d = await makeDir();
37+
const target = join(d, 'out.json');
38+
await atomicWriteFile(target, 'hello');
39+
const files = await import('fs/promises').then((m) => m.readdir(d));
40+
expect(files).toEqual(['out.json']);
41+
});
42+
43+
it('sets file mode when provided', async () => {
44+
const d = await makeDir();
45+
const target = join(d, 'secret.json');
46+
await atomicWriteFile(target, 'data', { mode: 0o600 });
47+
const s = await stat(target);
48+
expect(s.mode & 0o777).toBe(0o600);
49+
});
50+
});
51+
52+
describe('atomicWriteFileSync (sync)', () => {
53+
it('writes content to the target path', async () => {
54+
const d = await makeDir();
55+
const target = join(d, 'out.json');
56+
atomicWriteFileSync(target, '{"ok":true}');
57+
const content = await readFile(target, 'utf8');
58+
expect(content).toBe('{"ok":true}');
59+
});
60+
61+
it('overwrites existing file', async () => {
62+
const d = await makeDir();
63+
const target = join(d, 'out.json');
64+
atomicWriteFileSync(target, 'first');
65+
atomicWriteFileSync(target, 'second');
66+
expect(await readFile(target, 'utf8')).toBe('second');
67+
});
68+
69+
it('sets file mode when provided', async () => {
70+
const d = await makeDir();
71+
const target = join(d, 'secret.json');
72+
atomicWriteFileSync(target, 'data', { mode: 0o600 });
73+
const s = await stat(target);
74+
expect(s.mode & 0o777).toBe(0o600);
75+
});
76+
});

0 commit comments

Comments
 (0)