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
59 changes: 26 additions & 33 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,42 @@ on:
push:
branches: [main]
paths:
- ".github/workflows/**"
- "src/**"
- "scripts/**"
- "tests/**"
- "assets/**"
- "package.json"
- "bun.lock"
- "tsconfig.json"
- "tsconfig.build.json"
- "biome.json"
- "knip.ts"
- "sgconfig.yml"
- "ast-grep/**"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pull_request:
branches: [main]
paths:
- ".github/workflows/**"
- "src/**"
- "scripts/**"
- "tests/**"
- "assets/**"
- "package.json"
- "bun.lock"
- "tsconfig.json"
- "tsconfig.build.json"
- "biome.json"
- "knip.ts"
- "sgconfig.yml"
- "ast-grep/**"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
quality:
full-check:
runs-on: ubuntu-latest

steps:
Expand All @@ -31,39 +51,12 @@ jobs:
bun-version: latest

- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli"

- name: Run linter
run: bun run lint

- name: Run type checker
run: bun run typecheck

- name: Run dead code detection
run: bun run knip

- name: Run ast-grep scan
run: bun run sg:scan

test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli"

- name: Run tests with coverage
run: AGENT=1 bun test --coverage --coverage-reporter=lcov
- name: Run full checks
run: bun run check:ci

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
Expand All @@ -72,7 +65,7 @@ jobs:

build:
runs-on: ubuntu-latest
needs: [quality, test]
needs: [full-check]
permissions:
contents: write

Expand All @@ -86,7 +79,7 @@ jobs:
bun-version: latest

- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli"

Expand Down
27 changes: 5 additions & 22 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ permissions:
id-token: write

jobs:
test:
full-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -34,33 +34,16 @@ jobs:
bun-version: latest

- name: Install dependencies
run: bun install
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli"

- name: Run tests
run: AGENT=1 bun test --coverage

typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli"

- name: Type check
run: bun run typecheck
- name: Run full checks
run: bun run check:ci

publish:
runs-on: ubuntu-latest
needs: [test, typecheck]
needs: [full-check]
if: github.repository == 'kenryu42/claude-code-safety-net'
steps:
- uses: actions/checkout@v4
Expand Down
15 changes: 5 additions & 10 deletions dist/bin/cc-safety-net.js
Original file line number Diff line number Diff line change
Expand Up @@ -1580,10 +1580,6 @@ function splitShellCommands(command) {
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (token === undefined) {
i++;
continue;
}
if (isOperator(token)) {
if (current.length > 0) {
segments.push(current);
Expand Down Expand Up @@ -1714,9 +1710,6 @@ function parseEnvAssignment(token) {
return null;
}
const eqIdx = token.indexOf("=");
if (eqIdx < 0) {
return null;
}
return { name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) };
}
function stripEnvAssignmentsWithInfo(tokens) {
Expand Down Expand Up @@ -2736,6 +2729,7 @@ function parseParallelCommand(tokens) {

// src/core/analyze/tmpdir.ts
import { tmpdir as tmpdir2 } from "node:os";
import { normalize as normalize2, sep as sep2 } from "node:path";
function isTmpdirOverriddenToNonTemp(envAssignments) {
if (!envAssignments.has("TMPDIR")) {
return false;
Expand All @@ -2744,8 +2738,9 @@ function isTmpdirOverriddenToNonTemp(envAssignments) {
if (tmpdirValue === "") {
return true;
}
const sysTmpdir = tmpdir2();
if (isPathOrSubpath(tmpdirValue, "/tmp") || isPathOrSubpath(tmpdirValue, "/var/tmp") || isPathOrSubpath(tmpdirValue, sysTmpdir)) {
const normalizedTmpdirValue = normalize2(tmpdirValue);
const sysTmpdir = normalize2(tmpdir2());
if (isPathOrSubpath(normalizedTmpdirValue, normalize2("/tmp")) || isPathOrSubpath(normalizedTmpdirValue, normalize2("/var/tmp")) || isPathOrSubpath(normalizedTmpdirValue, sysTmpdir)) {
return false;
}
return true;
Expand All @@ -2754,7 +2749,7 @@ function isPathOrSubpath(path, basePath) {
if (path === basePath) {
return true;
}
const baseWithSlash = basePath.endsWith("/") ? basePath : `${basePath}/`;
const baseWithSlash = basePath.endsWith(sep2) ? basePath : `${basePath}${sep2}`;
return path.startsWith(baseWithSlash);
}

Expand Down
15 changes: 5 additions & 10 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,10 +441,6 @@ function splitShellCommands(command) {
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (token === undefined) {
i++;
continue;
}
if (isOperator(token)) {
if (current.length > 0) {
segments.push(current);
Expand Down Expand Up @@ -575,9 +571,6 @@ function parseEnvAssignment(token) {
return null;
}
const eqIdx = token.indexOf("=");
if (eqIdx < 0) {
return null;
}
return { name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) };
}
function stripEnvAssignmentsWithInfo(tokens) {
Expand Down Expand Up @@ -1597,6 +1590,7 @@ function parseParallelCommand(tokens) {

// src/core/analyze/tmpdir.ts
import { tmpdir as tmpdir2 } from "node:os";
import { normalize as normalize2, sep as sep2 } from "node:path";
function isTmpdirOverriddenToNonTemp(envAssignments) {
if (!envAssignments.has("TMPDIR")) {
return false;
Expand All @@ -1605,8 +1599,9 @@ function isTmpdirOverriddenToNonTemp(envAssignments) {
if (tmpdirValue === "") {
return true;
}
const sysTmpdir = tmpdir2();
if (isPathOrSubpath(tmpdirValue, "/tmp") || isPathOrSubpath(tmpdirValue, "/var/tmp") || isPathOrSubpath(tmpdirValue, sysTmpdir)) {
const normalizedTmpdirValue = normalize2(tmpdirValue);
const sysTmpdir = normalize2(tmpdir2());
if (isPathOrSubpath(normalizedTmpdirValue, normalize2("/tmp")) || isPathOrSubpath(normalizedTmpdirValue, normalize2("/var/tmp")) || isPathOrSubpath(normalizedTmpdirValue, sysTmpdir)) {
return false;
}
return true;
Expand All @@ -1615,7 +1610,7 @@ function isPathOrSubpath(path, basePath) {
if (path === basePath) {
return true;
}
const baseWithSlash = basePath.endsWith("/") ? basePath : `${basePath}/`;
const baseWithSlash = basePath.endsWith(sep2) ? basePath : `${basePath}${sep2}`;
return path.startsWith(baseWithSlash);
}

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
"build:schema": "bun run scripts/build-schema.ts",
"clean": "rm -rf dist",
"check": "bun run lint && bun run typecheck && bun run knip && bun run sg:scan && AGENT=1 bun test --coverage",
"check:ci": "bun run lint:ci && bun run typecheck && bun run knip && bun run sg:scan && AGENT=1 bun test --coverage --coverage-reporter=lcov",
"lint": "biome check --write",
"lint:ci": "biome ci .",
"typecheck": "tsc --noEmit",
"knip": "knip --production",
"sg:scan": "ast-grep scan",
Expand Down
15 changes: 9 additions & 6 deletions src/core/analyze/tmpdir.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { tmpdir } from 'node:os';
import { normalize, sep } from 'node:path';

export function isTmpdirOverriddenToNonTemp(envAssignments: Map<string, string>): boolean {
if (!envAssignments.has('TMPDIR')) {
Expand All @@ -11,12 +12,14 @@ export function isTmpdirOverriddenToNonTemp(envAssignments: Map<string, string>)
return true;
}

const normalizedTmpdirValue = normalize(tmpdirValue);

// Check if it's a known temp path (exact match or subpath)
const sysTmpdir = tmpdir();
const sysTmpdir = normalize(tmpdir());
if (
isPathOrSubpath(tmpdirValue, '/tmp') ||
isPathOrSubpath(tmpdirValue, '/var/tmp') ||
isPathOrSubpath(tmpdirValue, sysTmpdir)
isPathOrSubpath(normalizedTmpdirValue, normalize('/tmp')) ||
isPathOrSubpath(normalizedTmpdirValue, normalize('/var/tmp')) ||
isPathOrSubpath(normalizedTmpdirValue, sysTmpdir)
) {
return false;
}
Expand All @@ -32,7 +35,7 @@ function isPathOrSubpath(path: string, basePath: string): boolean {
if (path === basePath) {
return true;
}
// Ensure basePath ends with / for proper prefix matching
const baseWithSlash = basePath.endsWith('/') ? basePath : `${basePath}/`;
// Ensure basePath ends with the platform separator for proper prefix matching.
const baseWithSlash = basePath.endsWith(sep) ? basePath : `${basePath}${sep}`;
return path.startsWith(baseWithSlash);
}
10 changes: 1 addition & 9 deletions src/core/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,7 @@ export function splitShellCommands(command: string): string[][] {
let i = 0;

while (i < tokens.length) {
const token = tokens[i];
if (token === undefined) {
i++;
continue;
}

const token = tokens[i] as ParseEntry;
if (isOperator(token)) {
if (current.length > 0) {
segments.push(current);
Expand Down Expand Up @@ -182,9 +177,6 @@ function parseEnvAssignment(token: string): { name: string; value: string } | nu
return null;
}
const eqIdx = token.indexOf('=');
if (eqIdx < 0) {
return null;
}
return { name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) };
}

Expand Down
5 changes: 5 additions & 0 deletions tests/bin/doctor/system-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ describe('defaultVersionFetcher', () => {
expect(result).toBeNull();
});

test('returns null when spawn throws synchronously for invalid command input', async () => {
const result = await defaultVersionFetcher(['\u0000']);
expect(result).toBeNull();
});

test('returns version for existing commands', async () => {
const result = await defaultVersionFetcher(['bun', '--version']);
expect(result).not.toBeNull();
Expand Down
6 changes: 6 additions & 0 deletions tests/bin/explain/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,12 @@ describe('explainCommand guard parity fixes', () => {
expect(result.result).toBe('allowed');
});

test('Fix #3: TMPDIR traversal override blocks rm', () => {
const result = explainCommand('TMPDIR=/tmp/../root rm -rf $TMPDIR/foo', { cwd: '/tmp' });
expect(result.result).toBe('blocked');
expect(result.reason).toContain('rm -rf');
});

test('Fix #4: fallback scan finds embedded git in non-head position', () => {
const result = explainCommand('nice git reset --hard');
expect(result.result).toBe('blocked');
Expand Down
8 changes: 8 additions & 0 deletions tests/core/analyze/analyze-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ describe('analyzeCommand (coverage)', () => {
expect(result).toBeNull();
});

test('TMPDIR traversal override blocks $TMPDIR', () => {
const result = analyzeCommand('TMPDIR=/tmp/../root rm -rf $TMPDIR/test-dir', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('rm -rf');
});

test('xargs child git command is analyzed', () => {
const result = analyzeCommand('xargs git reset --hard', {
cwd: '/tmp',
Expand Down
10 changes: 10 additions & 0 deletions tests/core/analyze/parsing-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ describe('shell parsing helpers', () => {
expect(splitShellCommands('echo "unterminated')).toEqual([['echo "unterminated']]);
});

test('ignores trailing shell comments without creating extra segments', () => {
expect(splitShellCommands('echo hi # comment')).toEqual([['echo', 'hi']]);
});

test('extracts arithmetic substitution segments (nested parens)', () => {
expect(splitShellCommands('echo $((1+2))')).toEqual([['echo'], ['1+2']]);
});
Expand Down Expand Up @@ -194,6 +198,12 @@ describe('shell parsing helpers', () => {
expect(result.tokens).toEqual(['rm', '-rf']);
expect(result.envAssignments.get('FOO')).toBeUndefined();
});

test('captures empty env assignment values', () => {
const result = stripWrappersWithInfo(['FOO=', 'rm', '-rf']);
expect(result.tokens).toEqual(['rm', '-rf']);
expect(result.envAssignments.get('FOO')).toBe('');
});
});
});

Expand Down
Loading
Loading