/**
 * Security Module — Pattern Matching Tests
 *
 * Tests for parseBashPattern, globToRegex, matchesAnyPattern,
 * chained command splitting, shell-escape scanning, and file path evaluation.
 */

import { describe, test, beforeAll, afterAll } from "vitest";
import { strict as assert } from "node:assert";
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir, homedir } from "node:os";

import {
  parseBashPattern,
  globToRegex,
  matchesAnyPattern,
  splitChainedCommands,
  readBashPolicies,
  evaluateCommand,
  evaluateCommandDenyOnly,
  parseToolPattern,
  readToolDenyPatterns,
  fileGlobToRegex,
  evaluateFilePath,
  extractShellCommands,
} from "../build/security.js";

describe("parseBashPattern", () => {
  test("parseBashPattern: extracts glob from Bash(glob)", () => {
    assert.equal(parseBashPattern("Bash(sudo *)"), "sudo *");
  });

  test("parseBashPattern: handles colon format", () => {
    assert.equal(parseBashPattern("Bash(tree:*)"), "tree:*");
  });

  test("parseBashPattern: returns null for non-Bash", () => {
    assert.equal(parseBashPattern("Read(.env)"), null);
  });

  test("parseBashPattern: returns null for malformed", () => {
    assert.equal(parseBashPattern("Bash("), null);
    assert.equal(parseBashPattern("notapattern"), null);
  });
});

describe("globToRegex: word boundary tests from SECURITY.md", () => {
  test("glob: 'ls *' matches 'ls -la'", () => {
    assert.ok(globToRegex("ls *").test("ls -la"));
  });

  test("glob: 'ls *' does NOT match 'lsof -i'", () => {
    assert.ok(!globToRegex("ls *").test("lsof -i"));
  });

  test("glob: 'ls*' matches 'lsof -i' (prefix)", () => {
    assert.ok(globToRegex("ls*").test("lsof -i"));
  });

  test("glob: 'ls*' matches 'ls -la'", () => {
    assert.ok(globToRegex("ls*").test("ls -la"));
  });

  test("glob: 'git *' matches 'git commit -m msg'", () => {
    assert.ok(globToRegex("git *").test('git commit -m "msg"'));
  });

  test("glob: '* commit *' matches 'git commit -m msg'", () => {
    assert.ok(globToRegex("* commit *").test('git commit -m "msg"'));
  });
});

describe("globToRegex: colon separator", () => {
  test("glob: 'tree:*' matches 'tree' (no args)", () => {
    assert.ok(globToRegex("tree:*").test("tree"));
  });

  test("glob: 'tree:*' matches 'tree -a'", () => {
    assert.ok(globToRegex("tree:*").test("tree -a"));
  });

  test("glob: 'tree:*' does NOT match 'treemap'", () => {
    assert.ok(!globToRegex("tree:*").test("treemap"));
  });
});

describe("globToRegex: real-world deny patterns", () => {
  test("glob: 'sudo *' matches 'sudo apt install'", () => {
    assert.ok(globToRegex("sudo *").test("sudo apt install"));
  });

  test("glob: 'sudo *' does NOT match 'sudoedit'", () => {
    assert.ok(!globToRegex("sudo *").test("sudoedit"));
  });

  test("glob: 'rm -rf /*' matches 'rm -rf /etc'", () => {
    assert.ok(globToRegex("rm -rf /*").test("rm -rf /etc"));
  });

  test("glob: 'chmod -R 777 *' matches 'chmod -R 777 /tmp'", () => {
    assert.ok(globToRegex("chmod -R 777 *").test("chmod -R 777 /tmp"));
  });
});

describe("globToRegex: case sensitivity", () => {
  test("glob: case-insensitive 'dir *' matches 'DIR /W'", () => {
    assert.ok(globToRegex("dir *", true).test("DIR /W"));
  });

  test("glob: case-sensitive 'dir *' does NOT match 'DIR /W'", () => {
    assert.ok(!globToRegex("dir *", false).test("DIR /W"));
  });
});

describe("matchesAnyPattern", () => {
  test("matchesAnyPattern: returns matching pattern on hit", () => {
    const result = matchesAnyPattern(
      "sudo apt install",
      ["Bash(git:*)", "Bash(sudo *)"],
      false,
    );
    assert.equal(result, "Bash(sudo *)");
  });

  test("matchesAnyPattern: returns null on miss", () => {
    const result = matchesAnyPattern(
      "npm install",
      ["Bash(sudo *)", "Bash(rm -rf /*)"],
      false,
    );
    assert.equal(result, null);
  });
});

describe("Chained Command Splitting", () => {
  test("splitChainedCommands: simple && chain", () => {
    const parts = splitChainedCommands("echo hello && sudo rm -rf /");
    assert.deepEqual(parts, ["echo hello", "sudo rm -rf /"]);
  });

  test("splitChainedCommands: || chain", () => {
    const parts = splitChainedCommands("test -f /tmp/x || sudo reboot");
    assert.deepEqual(parts, ["test -f /tmp/x", "sudo reboot"]);
  });

  test("splitChainedCommands: semicolon chain", () => {
    const parts = splitChainedCommands("cd /tmp; sudo rm -rf /");
    assert.deepEqual(parts, ["cd /tmp", "sudo rm -rf /"]);
  });

  test("splitChainedCommands: pipe chain", () => {
    const parts = splitChainedCommands("cat /etc/passwd | sudo tee /tmp/out");
    assert.deepEqual(parts, ["cat /etc/passwd", "sudo tee /tmp/out"]);
  });

  test("splitChainedCommands: multiple operators", () => {
    const parts = splitChainedCommands("echo a && echo b; sudo rm -rf /");
    assert.deepEqual(parts, ["echo a", "echo b", "sudo rm -rf /"]);
  });

  test("splitChainedCommands: respects double quotes", () => {
    const parts = splitChainedCommands('echo "hello && world"');
    assert.deepEqual(parts, ['echo "hello && world"']);
  });

  test("splitChainedCommands: respects single quotes", () => {
    const parts = splitChainedCommands("echo 'test; value'");
    assert.deepEqual(parts, ["echo 'test; value'"]);
  });

  test("splitChainedCommands: single command unchanged", () => {
    const parts = splitChainedCommands("git status");
    assert.deepEqual(parts, ["git status"]);
  });
});

describe("Chained Command Evaluation", () => {
  let chainTmpBase: string;
  let chainGlobalPath: string;

  beforeAll(() => {
    chainTmpBase = join(tmpdir(), `chain-test-${Date.now()}`);
    const chainGlobalDir = join(chainTmpBase, "global-home", ".claude");
    chainGlobalPath = join(chainGlobalDir, "settings.json");
    mkdirSync(chainGlobalDir, { recursive: true });
    writeFileSync(
      chainGlobalPath,
      JSON.stringify({
        permissions: {
          deny: ["Bash(sudo *)", "Bash(rm -rf /*)"],
          allow: ["Bash(echo:*)", "Bash(git:*)"],
        },
      }),
    );
  });

  afterAll(() => {
    rmSync(chainTmpBase, { recursive: true, force: true });
  });

  test("evaluateCommand: detects 'sudo' in 'echo ok && sudo rm -rf /'", () => {
    const policies = readBashPolicies(undefined, chainGlobalPath);
    const result = evaluateCommand("echo ok && sudo rm -rf /", policies, false);
    assert.equal(result.decision, "deny");
    assert.equal(result.matchedPattern, "Bash(sudo *)");
  });

  test("evaluateCommand: detects 'sudo' after semicolon", () => {
    const policies = readBashPolicies(undefined, chainGlobalPath);
    const result = evaluateCommand("cd /tmp; sudo apt install vim", policies, false);
    assert.equal(result.decision, "deny");
  });

  test("evaluateCommand: detects 'rm -rf' in piped chain", () => {
    const policies = readBashPolicies(undefined, chainGlobalPath);
    const result = evaluateCommand("cat file | rm -rf /etc", policies, false);
    assert.equal(result.decision, "deny");
  });

  test("evaluateCommandDenyOnly: detects chained deny", () => {
    const policies = readBashPolicies(undefined, chainGlobalPath);
    const result = evaluateCommandDenyOnly("echo hello && sudo rm -rf /", policies, false);
    assert.equal(result.decision, "deny");
  });

  test("evaluateCommandDenyOnly: allows safe chained commands", () => {
    const policies = readBashPolicies(undefined, chainGlobalPath);
    const result = evaluateCommandDenyOnly("echo hello && git status", policies, false);
    assert.equal(result.decision, "allow");
  });
});

describe("Settings Reader", () => {
  let tmpBase: string;
  let globalSettingsPath: string;
  let projectDir: string;

  beforeAll(() => {
    tmpBase = join(tmpdir(), `security-test-${Date.now()}`);
    const globalDir = join(tmpBase, "global-home", ".claude");
    globalSettingsPath = join(globalDir, "settings.json");
    projectDir = join(tmpBase, "project");
    const projectClaudeDir = join(projectDir, ".claude");

    mkdirSync(globalDir, { recursive: true });
    mkdirSync(projectClaudeDir, { recursive: true });

    writeFileSync(
      globalSettingsPath,
      JSON.stringify({
        permissions: {
          allow: ["Bash(npm:*)", "Read(.env)"],
          deny: ["Bash(sudo *)"],
        },
      }),
    );

    writeFileSync(
      join(projectClaudeDir, "settings.json"),
      JSON.stringify({
        permissions: {
          deny: ["Bash(npm publish)"],
          allow: [],
        },
      }),
    );
  });

  afterAll(() => {
    rmSync(tmpBase, { recursive: true, force: true });
  });

  test("readBashPolicies: reads global only when no projectDir", () => {
    const policies = readBashPolicies(undefined, globalSettingsPath);
    assert.equal(policies.length, 1, "should have 1 policy (global)");
    assert.deepEqual(policies[0].allow, ["Bash(npm:*)"]);
    assert.deepEqual(policies[0].deny, ["Bash(sudo *)"]);
  });

  test("readBashPolicies: reads project + global with precedence", () => {
    const policies = readBashPolicies(projectDir, globalSettingsPath);
    assert.equal(policies.length, 2, "should have 2 policies");
    assert.deepEqual(policies[0].deny, ["Bash(npm publish)"]);
    assert.deepEqual(policies[1].allow, ["Bash(npm:*)"]);
    assert.deepEqual(policies[1].deny, ["Bash(sudo *)"]);
  });

  test("readBashPolicies: missing files produce empty policies", () => {
    const policies = readBashPolicies("/nonexistent/path", globalSettingsPath);
    assert.equal(policies.length, 1);
  });

  test("evaluateCommand: global allow matches", () => {
    const policies = readBashPolicies(undefined, globalSettingsPath);
    const result = evaluateCommand("npm install", policies, false);
    assert.equal(result.decision, "allow");
    assert.equal(result.matchedPattern, "Bash(npm:*)");
  });

  test("evaluateCommand: global deny beats allow", () => {
    const policies = readBashPolicies(undefined, globalSettingsPath);
    const result = evaluateCommand("sudo npm install", policies, false);
    assert.equal(result.decision, "deny");
    assert.equal(result.matchedPattern, "Bash(sudo *)");
  });

  test("evaluateCommand: local deny overrides global allow", () => {
    const policies = readBashPolicies(projectDir, globalSettingsPath);
    const result = evaluateCommand("npm publish", policies, false);
    assert.equal(result.decision, "deny");
    assert.equal(result.matchedPattern, "Bash(npm publish)");
  });

  test("evaluateCommand: no match returns ask", () => {
    const policies = readBashPolicies(projectDir, globalSettingsPath);
    const result = evaluateCommand("python script.py", policies, false);
    assert.equal(result.decision, "ask");
    assert.equal(result.matchedPattern, undefined);
  });

  test("evaluateCommandDenyOnly: denied command", () => {
    const policies = readBashPolicies(undefined, globalSettingsPath);
    const result = evaluateCommandDenyOnly("sudo rm -rf /", policies, false);
    assert.equal(result.decision, "deny");
    assert.equal(result.matchedPattern, "Bash(sudo *)");
  });

  test("evaluateCommandDenyOnly: non-denied returns allow", () => {
    const policies = readBashPolicies(undefined, globalSettingsPath);
    const result = evaluateCommandDenyOnly("npm install", policies, false);
    assert.equal(result.decision, "allow");
    assert.equal(result.matchedPattern, undefined);
  });
});

describe("Tool Pattern Parsing", () => {
  test("parseToolPattern: Read(.env)", () => {
    const result = parseToolPattern("Read(.env)");
    assert.deepEqual(result, { tool: "Read", glob: ".env" });
  });

  test("parseToolPattern: Grep(**/*.ts)", () => {
    const result = parseToolPattern("Grep(**/*.ts)");
    assert.deepEqual(result, { tool: "Grep", glob: "**/*.ts" });
  });

  test("parseToolPattern: Bash(sudo *)", () => {
    const result = parseToolPattern("Bash(sudo *)");
    assert.deepEqual(result, { tool: "Bash", glob: "sudo *" });
  });

  test("parseToolPattern: returns null for bare string", () => {
    assert.equal(parseToolPattern("notapattern"), null);
  });
});

describe("readToolDenyPatterns", () => {
  let toolDenyTmpBase: string;
  let toolDenyGlobalPath: string;

  beforeAll(() => {
    toolDenyTmpBase = join(tmpdir(), `tool-deny-test-${Date.now()}`);
    const toolDenyGlobalDir = join(toolDenyTmpBase, "global-home", ".claude");
    toolDenyGlobalPath = join(toolDenyGlobalDir, "settings.json");

    mkdirSync(toolDenyGlobalDir, { recursive: true });
    writeFileSync(
      toolDenyGlobalPath,
      JSON.stringify({
        permissions: {
          deny: [
            "Read(.env)",
            "Read(**/.env)",
            "Read(**/*credentials*)",
            "Bash(sudo *)",
            "Bash(rm -rf /*)",
          ],
          allow: [],
        },
      }),
    );
  });

  afterAll(() => {
    rmSync(toolDenyTmpBase, { recursive: true, force: true });
  });

  test("readToolDenyPatterns: returns only Read globs for Read", () => {
    const result = readToolDenyPatterns("Read", undefined, toolDenyGlobalPath);
    assert.equal(result.length, 1, "should have 1 settings file");
    assert.deepEqual(result[0], [".env", "**/.env", "**/*credentials*"]);
  });

  test("readToolDenyPatterns: returns only Bash globs for Bash", () => {
    const result = readToolDenyPatterns("Bash", undefined, toolDenyGlobalPath);
    assert.equal(result.length, 1);
    assert.deepEqual(result[0], ["sudo *", "rm -rf /*"]);
  });

  test("readToolDenyPatterns: returns empty for Grep (no patterns)", () => {
    const result = readToolDenyPatterns("Grep", undefined, toolDenyGlobalPath);
    assert.equal(result.length, 1);
    assert.deepEqual(result[0], []);
  });
});

describe("File Glob Matching", () => {
  test("fileGlobToRegex: '.env' matches exactly '.env'", () => {
    assert.ok(fileGlobToRegex(".env").test(".env"));
  });

  test("fileGlobToRegex: '.env' does not match 'src/.env'", () => {
    assert.ok(!fileGlobToRegex(".env").test("src/.env"));
  });

  test("fileGlobToRegex: '**/.env' matches 'deep/nested/.env'", () => {
    assert.ok(fileGlobToRegex("**/.env").test("deep/nested/.env"));
  });

  test("fileGlobToRegex: '**/.env' matches '.env' at root", () => {
    assert.ok(fileGlobToRegex("**/.env").test(".env"));
  });

  test("fileGlobToRegex: '**/*credentials*' matches nested path", () => {
    assert.ok(fileGlobToRegex("**/*credentials*").test("secrets/credentials.json"));
  });

  test("fileGlobToRegex: '**/*credentials*' does not match 'readme.md'", () => {
    assert.ok(!fileGlobToRegex("**/*credentials*").test("readme.md"));
  });
});

describe("evaluateFilePath", () => {
  test("evaluateFilePath: .env denied by ['.env']", () => {
    const result = evaluateFilePath(".env", [[".env"]], false);
    assert.equal(result.denied, true);
    assert.equal(result.matchedPattern, ".env");
  });

  test("evaluateFilePath: src/config.ts not denied by ['.env']", () => {
    const result = evaluateFilePath("src/config.ts", [[".env"]], false);
    assert.equal(result.denied, false);
    assert.equal(result.matchedPattern, undefined);
  });

  test("evaluateFilePath: deep/nested/.env denied by ['**/.env']", () => {
    const result = evaluateFilePath("deep/nested/.env", [["**/.env"]], false);
    assert.equal(result.denied, true);
    assert.equal(result.matchedPattern, "**/.env");
  });

  test("evaluateFilePath: credentials file denied by ['**/*credentials*']", () => {
    const result = evaluateFilePath(
      "secrets/credentials.json",
      [["**/*credentials*"]],
      false,
    );
    assert.equal(result.denied, true);
    assert.equal(result.matchedPattern, "**/*credentials*");
  });

  test("evaluateFilePath: readme.md not denied by ['**/*credentials*']", () => {
    const result = evaluateFilePath("readme.md", [["**/*credentials*"]], false);
    assert.equal(result.denied, false);
  });

  test("evaluateFilePath: Windows path with backslashes", () => {
    const result = evaluateFilePath(
      "C:\\Users\\.env",
      [["**/.env"]],
      true,
    );
    assert.equal(result.denied, true);
    assert.equal(result.matchedPattern, "**/.env");
  });

  test("evaluateFilePath: traversal does not bypass absolute deny glob when projectRoot is supplied", () => {
    // An absolute deny rule for ~/.ssh/** should still match when the caller
    // passes a ../-traversal relative path that resolves into ~/.ssh.
    const home = homedir();
    const projectRoot = resolve(home, "some/project");
    const denyGlob = resolve(home, ".ssh").replace(/\\/g, "/") + "/**";

    const result = evaluateFilePath(
      "../../.ssh/id_rsa",
      [[denyGlob]],
      process.platform === "win32",
      projectRoot,
    );
    assert.equal(result.denied, true);
    assert.equal(result.matchedPattern, denyGlob);
  });

  test("evaluateFilePath: without projectRoot, absolute deny glob is still bypassable (regression guard)", () => {
    // Documents the pre-fix behavior: without projectRoot, `..` is not
    // resolved, so the raw string doesn't match the absolute glob.
    // This test exists so any change in behavior is intentional.
    const absoluteSshGlob = resolve(homedir(), ".ssh").replace(/\\/g, "/") + "/**";
    const result = evaluateFilePath(
      "../../.ssh/id_rsa",
      [[absoluteSshGlob]],
      process.platform === "win32",
    );
    assert.equal(result.denied, false);
  });
});

describe("Shell-Escape Scanner", () => {
  test("extractShellCommands: Python os.system", () => {
    const result = extractShellCommands(
      'os.system("sudo rm -rf /")',
      "python",
    );
    assert.deepEqual(result, ["sudo rm -rf /"]);
  });

  test("extractShellCommands: Python subprocess.run string", () => {
    const result = extractShellCommands(
      'subprocess.run("sudo apt install vim")',
      "python",
    );
    assert.deepEqual(result, ["sudo apt install vim"]);
  });

  test("extractShellCommands: Python subprocess.run list args", () => {
    const result = extractShellCommands(
      'subprocess.run(["rm", "-rf", "/"])',
      "python",
    );
    assert.ok(result.length > 0, "should extract commands from list form");
    assert.ok(
      result.some((cmd) => cmd.includes("rm") && cmd.includes("-rf")),
      `should join list args into command string, got: ${JSON.stringify(result)}`,
    );
  });

  test("extractShellCommands: Python subprocess.call list args", () => {
    const result = extractShellCommands(
      'subprocess.call(["sudo", "reboot"])',
      "python",
    );
    assert.ok(result.some((cmd) => cmd.includes("sudo") && cmd.includes("reboot")));
  });

  test("extractShellCommands: JS execSync", () => {
    const cmds = extractShellCommands(
      'const r = execSync("sudo apt update")',
      "javascript",
    );
    assert.deepEqual(cmds, ["sudo apt update"]);
  });

  test("extractShellCommands: JS spawnSync", () => {
    const cmds = extractShellCommands(
      'spawnSync("sudo", ["rm", "-rf"])',
      "javascript",
    );
    assert.ok(cmds.length > 0, "should detect spawnSync");
    assert.ok(cmds[0].includes("sudo"));
  });

  test("extractShellCommands: Ruby system()", () => {
    const result = extractShellCommands(
      'system("sudo rm -rf /tmp")',
      "ruby",
    );
    assert.deepEqual(result, ["sudo rm -rf /tmp"]);
  });

  test("extractShellCommands: Go exec.Command", () => {
    const result = extractShellCommands(
      'exec.Command("sudo", "rm", "-rf")',
      "go",
    );
    assert.ok(result.length > 0, "should detect Go exec.Command");
    assert.ok(result[0].includes("sudo"));
  });

  test("extractShellCommands: PHP shell_exec", () => {
    const result = extractShellCommands(
      'shell_exec("sudo rm -rf /tmp")',
      "php",
    );
    assert.ok(result.length > 0, "should detect PHP shell_exec");
    assert.ok(result[0].includes("sudo"));
  });

  test("extractShellCommands: PHP system()", () => {
    const result = extractShellCommands(
      'system("sudo reboot")',
      "php",
    );
    assert.ok(result.length > 0, "should detect PHP system()");
  });

  test("extractShellCommands: Rust Command::new", () => {
    const result = extractShellCommands(
      'Command::new("sudo").arg("reboot")',
      "rust",
    );
    assert.ok(result.length > 0, "should detect Rust Command::new");
    assert.ok(result[0].includes("sudo"));
  });

  test("extractShellCommands: safe JS code returns empty", () => {
    const result = extractShellCommands(
      'console.log("hello")',
      "javascript",
    );
    assert.deepEqual(result, []);
  });

  test("extractShellCommands: unknown language returns empty", () => {
    const result = extractShellCommands(
      'os.system("rm -rf /")',
      "haskell",
    );
    assert.deepEqual(result, []);
  });
});

/**
 * Issue #460 follow-up — security policy readers MUST honor CLAUDE_CONFIG_DIR.
 *
 * The adapter layer routes settings reads through `getConfigDir()`, but
 * `readBashPolicies` / `readToolDenyPatterns` are called directly from
 * runtime/SDK code paths that do not have an adapter handle. If they hardcode
 * `~/.claude/settings.json` (the bug), users who relocate their config via
 * `CLAUDE_CONFIG_DIR` get policy drift: hooks read overridden settings, the
 * runtime reads the unset homedir copy, and deny rules silently disappear.
 *
 * Behavior under test (calling the function with NO explicit globalPath):
 *   - env unset → reads ~/.claude/settings.json
 *   - env set to a custom dir → reads <custom>/settings.json
 *   - env empty → falls back to ~/.claude/settings.json
 */
describe("CLAUDE_CONFIG_DIR honors security policy reader", () => {
  let cfgTmpBase: string;
  let customConfigDir: string;
  let savedEnv: string | undefined;

  beforeAll(() => {
    cfgTmpBase = join(tmpdir(), `security-cfg-test-${Date.now()}`);

    // Custom CLAUDE_CONFIG_DIR target — has a deny that the homedir copy lacks.
    customConfigDir = join(cfgTmpBase, "custom-cc");
    mkdirSync(customConfigDir, { recursive: true });
    writeFileSync(
      join(customConfigDir, "settings.json"),
      JSON.stringify({
        permissions: {
          allow: [],
          deny: ["Bash(custom-marker *)"],
        },
      }),
    );

    // Homedir fallback — distinct content so we can detect which file was read.
    const homeClaudeDir = join(homedir(), ".claude");
    mkdirSync(homeClaudeDir, { recursive: true });
    writeFileSync(
      join(homeClaudeDir, "settings.json"),
      JSON.stringify({
        permissions: {
          allow: [],
          deny: ["Bash(homedir-marker *)"],
        },
      }),
    );

    savedEnv = process.env.CLAUDE_CONFIG_DIR;
  });

  afterAll(() => {
    if (savedEnv === undefined) delete process.env.CLAUDE_CONFIG_DIR;
    else process.env.CLAUDE_CONFIG_DIR = savedEnv;
    rmSync(cfgTmpBase, { recursive: true, force: true });
  });

  test("readBashPolicies: env unset reads ~/.claude/settings.json", () => {
    delete process.env.CLAUDE_CONFIG_DIR;
    const policies = readBashPolicies();
    assert.equal(policies.length, 1, "should load homedir policy");
    assert.deepEqual(policies[0].deny, ["Bash(homedir-marker *)"]);
  });

  test("readBashPolicies: env=customDir reads <customDir>/settings.json", () => {
    process.env.CLAUDE_CONFIG_DIR = customConfigDir;
    const policies = readBashPolicies();
    assert.equal(policies.length, 1, "should load custom-dir policy");
    assert.deepEqual(
      policies[0].deny,
      ["Bash(custom-marker *)"],
      "must NOT fall through to ~/.claude when env is set",
    );
  });

  test("readBashPolicies: env empty string falls back to ~/.claude", () => {
    process.env.CLAUDE_CONFIG_DIR = "";
    const policies = readBashPolicies();
    assert.equal(policies.length, 1);
    assert.deepEqual(policies[0].deny, ["Bash(homedir-marker *)"]);
  });

  test("readToolDenyPatterns: env=customDir reads <customDir>/settings.json", () => {
    process.env.CLAUDE_CONFIG_DIR = customConfigDir;
    // Switch the marker to a Read(...) pattern so readToolDenyPatterns has
    // something to extract — overwrite the file we wrote in beforeAll.
    writeFileSync(
      join(customConfigDir, "settings.json"),
      JSON.stringify({
        permissions: {
          deny: ["Read(.env.custom)"],
          allow: [],
        },
      }),
    );
    const result = readToolDenyPatterns("Read");
    assert.ok(result.length > 0, "should read from custom CLAUDE_CONFIG_DIR");
    const flat = result.flat();
    assert.ok(
      flat.includes(".env.custom"),
      `expected '.env.custom' from custom dir, got ${JSON.stringify(flat)}`,
    );
  });
});

/**
 * Issue #451 round-3 — cross-adapter deny-policy parity.
 *
 * `resolveClaudeGlobalSettingsPath` hardcoded the `.claude` segment, so
 * non-Claude adapters (Cursor, Codex, Qwen, Gemini, JetBrains, VS Code, etc.)
 * received zero file-deny enforcement: their global settings.json (e.g.
 * ~/.cursor/settings.json) was never consulted by `readBashPolicies` or
 * `readToolDenyPatterns`. This is a cross-adapter security parity gap.
 *
 * Behavior under test:
 *   - When QUIET_CONTEXT_PLATFORM identifies a non-claude adapter, the security
 *     reader MUST consult <home>/<adapter-segments>/settings.json.
 *   - Union semantics (defense in depth): even when an adapter is detected,
 *     ~/.claude/settings.json is ALSO read so a rule defined there still wins.
 *
 * Each test sandboxes HOME so the home-rooted lookup hits a tmp dir.
 */
describe("cross-adapter deny-policy parity (#451 round-3)", () => {
  const ADAPTER_SEGMENTS: ReadonlyArray<readonly [string, readonly string[]]> = [
    ["cursor",            [".cursor"]],
    ["codex",             [".codex"]],
    ["qwen-code",         [".qwen"]],
    ["gemini-cli",        [".gemini"]],
    ["jetbrains-copilot", [".config", "JetBrains"]],
    ["vscode-copilot",    [".vscode"]],
  ];

  let parityTmpBase: string;
  let savedHome: string | undefined;
  let savedUserprofile: string | undefined;
  let savedPlatform: string | undefined;
  let savedClaudeConfig: string | undefined;

  beforeAll(() => {
    parityTmpBase = join(tmpdir(), `security-parity-test-${Date.now()}`);
    mkdirSync(parityTmpBase, { recursive: true });
    savedHome = process.env.HOME;
    savedUserprofile = process.env.USERPROFILE;
    savedPlatform = process.env.QUIET_CONTEXT_PLATFORM;
    savedClaudeConfig = process.env.CLAUDE_CONFIG_DIR;
  });

  afterAll(() => {
    if (savedHome === undefined) delete process.env.HOME;
    else process.env.HOME = savedHome;
    if (savedUserprofile === undefined) delete process.env.USERPROFILE;
    else process.env.USERPROFILE = savedUserprofile;
    if (savedPlatform === undefined) delete process.env.QUIET_CONTEXT_PLATFORM;
    else process.env.QUIET_CONTEXT_PLATFORM = savedPlatform;
    if (savedClaudeConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR;
    else process.env.CLAUDE_CONFIG_DIR = savedClaudeConfig;
    rmSync(parityTmpBase, { recursive: true, force: true });
  });

  for (const [adapter, segments] of ADAPTER_SEGMENTS) {
    test(`readBashPolicies: ${adapter} adapter settings.json deny is honored`, () => {
      const fakeHome = join(parityTmpBase, `${adapter}-home`);
      const adapterDir = join(fakeHome, ...segments);
      mkdirSync(adapterDir, { recursive: true });
      const denyPattern = `Bash(${adapter}-marker *)`;
      writeFileSync(
        join(adapterDir, "settings.json"),
        JSON.stringify({
          permissions: { allow: [], deny: [denyPattern] },
        }),
      );

      // Sandbox HOME so home-rooted resolution lands in our tmp tree.
      process.env.HOME = fakeHome;
      process.env.USERPROFILE = fakeHome;
      process.env.QUIET_CONTEXT_PLATFORM = adapter;
      delete process.env.CLAUDE_CONFIG_DIR;

      const policies = readBashPolicies();
      const allDeny = policies.flatMap((p) => p.deny);
      assert.ok(
        allDeny.includes(denyPattern),
        `${adapter}: expected '${denyPattern}' in deny list, got ${JSON.stringify(allDeny)}`,
      );
    });

    test(`readToolDenyPatterns: ${adapter} adapter Read deny is honored`, () => {
      const fakeHome = join(parityTmpBase, `${adapter}-home-read`);
      const adapterDir = join(fakeHome, ...segments);
      mkdirSync(adapterDir, { recursive: true });
      const fileMarker = `.env.${adapter}`;
      writeFileSync(
        join(adapterDir, "settings.json"),
        JSON.stringify({
          permissions: { allow: [], deny: [`Read(${fileMarker})`] },
        }),
      );

      process.env.HOME = fakeHome;
      process.env.USERPROFILE = fakeHome;
      process.env.QUIET_CONTEXT_PLATFORM = adapter;
      delete process.env.CLAUDE_CONFIG_DIR;

      const result = readToolDenyPatterns("Read");
      const flat = result.flat();
      assert.ok(
        flat.includes(fileMarker),
        `${adapter}: expected '${fileMarker}' in Read deny globs, got ${JSON.stringify(flat)}`,
      );
    });
  }

  test("union semantics: claude global is also read when non-claude adapter active", () => {
    const fakeHome = join(parityTmpBase, "union-home");
    const cursorDir = join(fakeHome, ".cursor");
    const claudeDir = join(fakeHome, ".claude");
    mkdirSync(cursorDir, { recursive: true });
    mkdirSync(claudeDir, { recursive: true });
    writeFileSync(
      join(cursorDir, "settings.json"),
      JSON.stringify({ permissions: { allow: [], deny: ["Bash(cursor-only *)"] } }),
    );
    writeFileSync(
      join(claudeDir, "settings.json"),
      JSON.stringify({ permissions: { allow: [], deny: ["Bash(claude-only *)"] } }),
    );

    process.env.HOME = fakeHome;
    process.env.USERPROFILE = fakeHome;
    process.env.QUIET_CONTEXT_PLATFORM = "cursor";
    delete process.env.CLAUDE_CONFIG_DIR;

    const policies = readBashPolicies();
    const allDeny = policies.flatMap((p) => p.deny);
    assert.ok(
      allDeny.includes("Bash(cursor-only *)"),
      `expected cursor deny in union, got ${JSON.stringify(allDeny)}`,
    );
    assert.ok(
      allDeny.includes("Bash(claude-only *)"),
      `expected claude deny in union (defense in depth), got ${JSON.stringify(allDeny)}`,
    );
  });
});
