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
64 changes: 64 additions & 0 deletions .github/workflows/test-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Windows Tests

on:
workflow_dispatch:
push:
branches: ["**"]
paths:
- "src/**"
- "tests/**"
- ".github/workflows/test-windows.yml"
pull_request:
branches: [main]
paths:
- "src/**"
- "tests/**"
- ".github/workflows/test-windows.yml"

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

jobs:
test-windows:
name: Windows Tests
runs-on: windows-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
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: Build
run: bun run build

- name: Run tests
run: bun test
env:
AGENT: "1"

- name: Test rm -rf within cwd
run: |
# Create test directory structure
mkdir test-dir

# Test: rm -rf dist should be ALLOWED within cwd
$result = bun run cc-safety-net explain --cwd test-dir "rm -rf dist" 2>&1
Write-Host "Result: $result"

if ($result -match "ALLOWED") {
Write-Host "✅ PASS: rm -rf dist within cwd is correctly ALLOWED"
} else {
Write-Host "❌ FAIL: rm -rf dist within cwd should be ALLOWED but was blocked"
exit 1
}
shell: pwsh
7 changes: 5 additions & 2 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ bun run check
# Build the latest dist
bun run build

# Check if dist/ changed after build
# Auto-commit dist/ if it changed after build
if ! git diff --quiet dist/; then
echo "⚠️ dist/ is out of date! Please commit the updated dist/ folder."
echo "📦 dist/ changed, auto-committing..."
git add -A dist/
git commit -m "chore: sync dist" --no-verify
echo "⚠️ dist/ committed. Run 'git push' again to include it."
exit 1
fi
68 changes: 42 additions & 26 deletions dist/bin/cc-safety-net.js
Original file line number Diff line number Diff line change
Expand Up @@ -2305,7 +2305,23 @@ function analyzeGitWorktree(tokens) {
// src/core/rules-rm.ts
import { realpathSync } from "node:fs";
import { homedir as homedir3, tmpdir } from "node:os";
import { normalize, resolve as resolve2 } from "node:path";
import { normalize, resolve as resolve2, sep } from "node:path";
var IS_WINDOWS = process.platform === "win32";
function normalizePathForComparison(p) {
let normalized = normalize(p);
if (IS_WINDOWS) {
normalized = normalized.replace(/\//g, "\\");
normalized = normalized.toLowerCase();
if (normalized.length > 3 && normalized.endsWith("\\")) {
normalized = normalized.slice(0, -1);
}
} else {
if (normalized.length > 1 && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
}
return normalized;
}
var REASON_RM_RF = "rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually.";
var REASON_RM_RF_ROOT_HOME = "rm -rf targeting root or home directory is extremely dangerous and always blocked.";
function analyzeRm(tokens, options = {}) {
Expand Down Expand Up @@ -2430,7 +2446,9 @@ function isTempTarget(path, allowTmpdirVar) {
return true;
}
const systemTmpdir = tmpdir();
if (normalized.startsWith(`${systemTmpdir}/`) || normalized === systemTmpdir) {
const normalizedTmpdir = normalizePathForComparison(systemTmpdir);
const pathToCompare = normalizePathForComparison(normalized);
if (pathToCompare.startsWith(`${normalizedTmpdir}${sep}`) || pathToCompare === normalizedTmpdir) {
return true;
}
if (allowTmpdirVar) {
Expand All @@ -2448,27 +2466,24 @@ function getHomeDirForRmPolicy() {
}
function isCwdHomeForRmPolicy(cwd, homeDir) {
try {
const normalizedCwd = normalize(cwd);
const normalizedHome = normalize(homeDir);
return normalizedCwd === normalizedHome;
return normalizePathForComparison(cwd) === normalizePathForComparison(homeDir);
} catch {
return false;
}
}
function isCwdSelfTarget(target, cwd) {
if (target === "." || target === "./") {
if (target === "." || target === "./" || target === ".\\") {
return true;
}
try {
const resolved = resolve2(cwd, target);
const realCwd = realpathSync(cwd);
const realResolved = realpathSync(resolved);
return realResolved === realCwd;
return normalizePathForComparison(realResolved) === normalizePathForComparison(realCwd);
} catch {
try {
const resolved = resolve2(cwd, target);
const normalizedCwd = normalize(cwd);
return resolved === normalizedCwd;
return normalizePathForComparison(resolved) === normalizePathForComparison(cwd);
} catch {
return false;
}
Expand All @@ -2482,20 +2497,21 @@ function isTargetWithinCwd(target, originalCwd, effectiveCwd) {
if (target.includes("$") || target.includes("`")) {
return false;
}
if (target.startsWith("/")) {
if (target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target)) {
try {
const normalizedTarget = normalize(target);
const normalizedCwd = `${normalize(originalCwd)}/`;
const normalizedTarget = normalizePathForComparison(target);
const normalizedCwd = `${normalizePathForComparison(originalCwd)}${sep}`;
return normalizedTarget.startsWith(normalizedCwd);
} catch {
return false;
}
}
if (target.startsWith("./") || !target.includes("/")) {
if (target.startsWith("./") || target.startsWith(".\\") || !target.includes("/") && !target.includes("\\")) {
try {
const resolved = resolve2(resolveCwd, target);
const normalizedOriginalCwd = normalize(originalCwd);
return resolved.startsWith(`${normalizedOriginalCwd}/`) || resolved === normalizedOriginalCwd;
const normalizedResolved = normalizePathForComparison(resolved);
const normalizedOriginalCwd = normalizePathForComparison(originalCwd);
return normalizedResolved.startsWith(`${normalizedOriginalCwd}${sep}`) || normalizedResolved === normalizedOriginalCwd;
} catch {
return false;
}
Expand All @@ -2505,18 +2521,17 @@ function isTargetWithinCwd(target, originalCwd, effectiveCwd) {
}
try {
const resolved = resolve2(resolveCwd, target);
const normalizedCwd = normalize(originalCwd);
return resolved.startsWith(`${normalizedCwd}/`) || resolved === normalizedCwd;
const normalizedResolved = normalizePathForComparison(resolved);
const normalizedCwd = normalizePathForComparison(originalCwd);
return normalizedResolved.startsWith(`${normalizedCwd}${sep}`) || normalizedResolved === normalizedCwd;
} catch {
return false;
}
}
function isHomeDirectory(cwd) {
const home = process.env.HOME ?? homedir3();
try {
const normalizedCwd = normalize(cwd);
const normalizedHome = normalize(home);
return normalizedCwd === normalizedHome;
return normalizePathForComparison(cwd) === normalizePathForComparison(home);
} catch {
return false;
}
Expand Down Expand Up @@ -3608,6 +3623,7 @@ function printReport(report) {

// src/bin/explain/config.ts
import { existsSync as existsSync5 } from "node:fs";
import { resolve as resolve3 } from "node:path";

// src/core/env.ts
function envTruthy(name) {
Expand Down Expand Up @@ -3637,7 +3653,7 @@ function getConfigSource(options) {
return { configSource: null, configValid: true };
}
function buildAnalyzeOptions(explainOptions) {
const cwd = explainOptions?.cwd ?? process.cwd();
const cwd = resolve3(explainOptions?.cwd ?? process.cwd());
const paranoidAll = envTruthy("SAFETY_NET_PARANOID");
return {
cwd,
Expand Down Expand Up @@ -4852,18 +4868,18 @@ async function readStdinAsync() {
if (process.stdin.isTTY) {
return null;
}
return new Promise((resolve3) => {
return new Promise((resolve4) => {
let data = "";
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => {
const trimmed = data.trim();
resolve3(trimmed || null);
resolve4(trimmed || null);
});
process.stdin.on("error", () => {
resolve3(null);
resolve4(null);
});
});
}
Expand Down Expand Up @@ -4927,7 +4943,7 @@ async function printStatusline() {

// src/bin/verify-config.ts
import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync } from "node:fs";
import { resolve as resolve3 } from "node:path";
import { resolve as resolve4 } from "node:path";
var HEADER = "Safety Net Config";
var SEPARATOR = "═".repeat(HEADER.length);
var SCHEMA_URL = "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json";
Expand Down Expand Up @@ -4992,7 +5008,7 @@ function verifyConfig(options = {}) {
const result = validateConfigFile(projectConfig);
configsChecked.push({
scope: "Project",
path: resolve3(projectConfig),
path: resolve4(projectConfig),
result
});
if (result.errors.length > 0) {
Expand Down
55 changes: 35 additions & 20 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1166,7 +1166,23 @@ function analyzeGitWorktree(tokens) {
// src/core/rules-rm.ts
import { realpathSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { normalize, resolve } from "node:path";
import { normalize, resolve, sep } from "node:path";
var IS_WINDOWS = process.platform === "win32";
function normalizePathForComparison(p) {
let normalized = normalize(p);
if (IS_WINDOWS) {
normalized = normalized.replace(/\//g, "\\");
normalized = normalized.toLowerCase();
if (normalized.length > 3 && normalized.endsWith("\\")) {
normalized = normalized.slice(0, -1);
}
} else {
if (normalized.length > 1 && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
}
return normalized;
}
var REASON_RM_RF = "rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually.";
var REASON_RM_RF_ROOT_HOME = "rm -rf targeting root or home directory is extremely dangerous and always blocked.";
function analyzeRm(tokens, options = {}) {
Expand Down Expand Up @@ -1291,7 +1307,9 @@ function isTempTarget(path, allowTmpdirVar) {
return true;
}
const systemTmpdir = tmpdir();
if (normalized.startsWith(`${systemTmpdir}/`) || normalized === systemTmpdir) {
const normalizedTmpdir = normalizePathForComparison(systemTmpdir);
const pathToCompare = normalizePathForComparison(normalized);
if (pathToCompare.startsWith(`${normalizedTmpdir}${sep}`) || pathToCompare === normalizedTmpdir) {
return true;
}
if (allowTmpdirVar) {
Expand All @@ -1309,27 +1327,24 @@ function getHomeDirForRmPolicy() {
}
function isCwdHomeForRmPolicy(cwd, homeDir) {
try {
const normalizedCwd = normalize(cwd);
const normalizedHome = normalize(homeDir);
return normalizedCwd === normalizedHome;
return normalizePathForComparison(cwd) === normalizePathForComparison(homeDir);
} catch {
return false;
}
}
function isCwdSelfTarget(target, cwd) {
if (target === "." || target === "./") {
if (target === "." || target === "./" || target === ".\\") {
return true;
}
try {
const resolved = resolve(cwd, target);
const realCwd = realpathSync(cwd);
const realResolved = realpathSync(resolved);
return realResolved === realCwd;
return normalizePathForComparison(realResolved) === normalizePathForComparison(realCwd);
} catch {
try {
const resolved = resolve(cwd, target);
const normalizedCwd = normalize(cwd);
return resolved === normalizedCwd;
return normalizePathForComparison(resolved) === normalizePathForComparison(cwd);
} catch {
return false;
}
Expand All @@ -1343,20 +1358,21 @@ function isTargetWithinCwd(target, originalCwd, effectiveCwd) {
if (target.includes("$") || target.includes("`")) {
return false;
}
if (target.startsWith("/")) {
if (target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target)) {
try {
const normalizedTarget = normalize(target);
const normalizedCwd = `${normalize(originalCwd)}/`;
const normalizedTarget = normalizePathForComparison(target);
const normalizedCwd = `${normalizePathForComparison(originalCwd)}${sep}`;
return normalizedTarget.startsWith(normalizedCwd);
} catch {
return false;
}
}
if (target.startsWith("./") || !target.includes("/")) {
if (target.startsWith("./") || target.startsWith(".\\") || !target.includes("/") && !target.includes("\\")) {
try {
const resolved = resolve(resolveCwd, target);
const normalizedOriginalCwd = normalize(originalCwd);
return resolved.startsWith(`${normalizedOriginalCwd}/`) || resolved === normalizedOriginalCwd;
const normalizedResolved = normalizePathForComparison(resolved);
const normalizedOriginalCwd = normalizePathForComparison(originalCwd);
return normalizedResolved.startsWith(`${normalizedOriginalCwd}${sep}`) || normalizedResolved === normalizedOriginalCwd;
} catch {
return false;
}
Expand All @@ -1366,18 +1382,17 @@ function isTargetWithinCwd(target, originalCwd, effectiveCwd) {
}
try {
const resolved = resolve(resolveCwd, target);
const normalizedCwd = normalize(originalCwd);
return resolved.startsWith(`${normalizedCwd}/`) || resolved === normalizedCwd;
const normalizedResolved = normalizePathForComparison(resolved);
const normalizedCwd = normalizePathForComparison(originalCwd);
return normalizedResolved.startsWith(`${normalizedCwd}${sep}`) || normalizedResolved === normalizedCwd;
} catch {
return false;
}
}
function isHomeDirectory(cwd) {
const home = process.env.HOME ?? homedir();
try {
const normalizedCwd = normalize(cwd);
const normalizedHome = normalize(home);
return normalizedCwd === normalizedHome;
return normalizePathForComparison(cwd) === normalizePathForComparison(home);
} catch {
return false;
}
Expand Down
4 changes: 3 additions & 1 deletion src/bin/explain/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import {
getProjectConfigPath,
getUserConfigPath,
Expand Down Expand Up @@ -56,7 +57,8 @@ export function getConfigSource(options?: GetConfigSourceOptions): {
* Merges user options with environment variable defaults.
*/
export function buildAnalyzeOptions(explainOptions?: ExplainOptions): AnalyzeOptions {
const cwd = explainOptions?.cwd ?? process.cwd();
// Resolve to absolute path - relative paths break cwd comparison logic
const cwd = resolve(explainOptions?.cwd ?? process.cwd());
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
return {
cwd,
Expand Down
Loading