/**
 * Integration tests verifying the unified checkPermission() path.
 *
 * Step 5: session rules concatenated into the composed ruleset.
 * Step 6: all five surfaces produce identical decisions to the old branching code.
 */
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it, test } from "vitest";
import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
import { BashProgram } from "#src/access-intent/bash/program";
import { getPathPolicyValues } from "#src/access-intent/path-normalization";
import { getGlobalConfigPath, getProjectAgentsDir } from "#src/config-paths";
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import {
  PermissionManager,
  type ScopedPermissionManager,
} from "#src/permission-manager";
import type { Ruleset } from "#src/rule";
import {
  createAgentDirHarness,
  createInMemoryManager,
  createManager,
  createManagerWithConfig,
  createManagerWithProject,
  createManagerWithScopes,
  createMissingConfigManager,
  sessionRule,
} from "#test/helpers/manager-harness";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

describe("PermissionManager — injected platform (#510)", () => {
  const winAllow: Ruleset = [
    sessionRule("external_directory", "C:\\Users\\Foo\\pi\\*"),
  ];

  it("win32 manager folds case for path-surface matching", () => {
    const manager = new PermissionManager({
      globalConfigPath: "/nonexistent/config.json",
      agentsDir: "/nonexistent/agents",
      flavor: win32PathFlavor,
    });
    const result = manager.check(
      {
        kind: "path-values",
        surface: "external_directory",
        values: ["c:\\users\\foo\\pi\\docs\\readme.md"],
      },
      winAllow,
    );
    expect(result.state).toBe("allow");
  });

  it("posix manager keeps path-surface matching case-sensitive", () => {
    const manager = new PermissionManager({
      globalConfigPath: "/nonexistent/config.json",
      agentsDir: "/nonexistent/agents",
      flavor: posixPathFlavor,
    });
    const result = manager.check(
      {
        kind: "path-values",
        surface: "external_directory",
        values: ["c:\\users\\foo\\pi\\docs\\readme.md"],
      },
      winAllow,
    );
    expect(result.state).not.toBe("allow");
  });

  it("win32: a /tmp* allow rule suppresses a Git Bash /tmp path (#533)", async () => {
    // End to end: parse `ls /tmp` under win32, take the token's match values,
    // and confirm a natural `/tmp*` external_directory allow rule matches them.
    // The win32 matcher folds separators on both the rule and the value (#653),
    // so the as-typed literal resolves without a backslash match alias.
    const program = await BashProgram.parse(
      "ls /tmp",
      new PathNormalizer(win32PathFlavor, "C:/projects/app"),
    );
    const values = program.externalPaths()[0].matchValues();
    const manager = new PermissionManager({
      globalConfigPath: "/nonexistent/config.json",
      agentsDir: "/nonexistent/agents",
      flavor: win32PathFlavor,
    });

    const allowed = manager.check(
      { kind: "path-values", surface: "external_directory", values },
      [sessionRule("external_directory", "/tmp*")],
    );
    expect(allowed.state).toBe("allow");

    const noRule = manager.check(
      { kind: "path-values", surface: "external_directory", values },
      [],
    );
    expect(noRule.state).not.toBe("allow");
  });

  it("win32: a /dev/null path allow rule suppresses the Git Bash device prompt (#653)", async () => {
    // The reported repro: `echo hi > /dev/null` reaches the `path` surface with
    // the device spelled as typed, so a rule written the same way must win over
    // a preceding universal ask.
    const program = await BashProgram.parse(
      "echo hi > /dev/null",
      new PathNormalizer(win32PathFlavor, "C:\\projects\\app"),
    );
    const values = program.pathRuleCandidates()[0].path.matchValues();
    expect(values).toEqual(["/dev/null"]);

    const manager = new PermissionManager({
      globalConfigPath: "/nonexistent/config.json",
      agentsDir: "/nonexistent/agents",
      flavor: win32PathFlavor,
    });

    const allowed = manager.check(
      { kind: "path-values", surface: "path", values },
      [sessionRule("path", "*", "ask"), sessionRule("path", "/dev/null")],
    );
    expect(allowed.state).toBe("allow");

    const askedWithoutRule = manager.check(
      { kind: "path-values", surface: "path", values },
      [sessionRule("path", "*", "ask")],
    );
    expect(askedWithoutRule.state).toBe("ask");
  });
});

// Adapters that build an AccessIntent and call the unified `check` entry point,
// so these tests exercise the single resolution path (#478) without a
// production-class wrapper used only by tests.
function checkTool(
  manager: PermissionManager,
  toolName: string,
  input: unknown,
  agentName?: string,
  sessionRules?: Ruleset,
): PermissionCheckResult {
  return manager.check(
    { kind: "tool", surface: toolName, input, agentName },
    sessionRules,
  );
}

function checkPathValues(
  manager: PermissionManager,
  values: readonly string[],
  agentName?: string,
  sessionRules?: Ruleset,
  surface = "path",
): PermissionCheckResult {
  return manager.check(
    { kind: "path-values", surface, values, agentName },
    sessionRules,
  );
}

function checkPath(
  manager: PermissionManager,
  path: string,
  opts: { cwd?: string } = {},
  surface = "path",
  agentName?: string,
  sessionRules?: Ruleset,
): PermissionCheckResult {
  return checkPathValues(
    manager,
    getPathPolicyValues(
      path,
      opts.cwd ? { cwd: opts.cwd } : {},
      posixPathFlavor,
    ),
    agentName,
    sessionRules,
    surface,
  );
}

// ---------------------------------------------------------------------------
// Step 5: session rules concatenated — wins over config/default
// ---------------------------------------------------------------------------

describe("checkPermission — session rules", () => {
  it("session rule wins over the universal default (external_directory)", () => {
    const manager = createMissingConfigManager();
    const sessionRules: Ruleset = [
      sessionRule("external_directory", "/other/project"),
    ];
    const result = checkPath(
      manager,
      "/other/project",
      {},
      "external_directory",
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
    expect(result.matchedPattern).toBe("/other/project");
  });

  it("session rule wins over the universal default (skill)", () => {
    const manager = createMissingConfigManager();
    const sessionRules: Ruleset = [sessionRule("skill", "librarian")];
    const result = checkTool(
      manager,
      "skill",
      { name: "librarian" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
    expect(result.matchedPattern).toBe("librarian");
  });

  it("session rule wins over the universal default (bash)", () => {
    const manager = createMissingConfigManager();
    const sessionRules: Ruleset = [sessionRule("bash", "git status")];
    const result = checkTool(
      manager,
      "bash",
      { command: "git status" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
    expect(result.matchedPattern).toBe("git status");
  });

  it("session rule wins over the universal default (tool — read)", () => {
    const manager = createMissingConfigManager();
    const sessionRules: Ruleset = [sessionRule("read", "*")];
    const result = checkTool(manager, "read", {}, undefined, sessionRules);
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
  });

  it("session rule wins over the universal default (mcp)", () => {
    const manager = createMissingConfigManager();
    const sessionRules: Ruleset = [sessionRule("mcp", "mcp_status")];
    const result = checkTool(manager, "mcp", {}, undefined, sessionRules);
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
  });

  it("no session rules — falls through to default (ask)", () => {
    const manager = createMissingConfigManager();
    const result = checkTool(manager, "read", {}, undefined, []);
    expect(result.state).toBe("ask");
    expect(result.source).not.toBe("session");
  });

  it("session rule with narrower pattern does not block a broader command not in session", () => {
    const manager = createMissingConfigManager();
    // Only "git status" is session-approved; "git push" should fall through to default.
    const sessionRules: Ruleset = [sessionRule("bash", "git status")];
    const result = checkTool(
      manager,
      "bash",
      { command: "git push origin main" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("ask");
    expect(result.source).not.toBe("session");
  });

  it("session wildcard pattern matches multiple commands", () => {
    const manager = createMissingConfigManager();
    const sessionRules: Ruleset = [sessionRule("bash", "git *")];
    const push = checkTool(
      manager,
      "bash",
      { command: "git push origin main" },
      undefined,
      sessionRules,
    );
    const status = checkTool(
      manager,
      "bash",
      { command: "git status" },
      undefined,
      sessionRules,
    );
    expect(push.state).toBe("allow");
    expect(push.source).toBe("session");
    expect(status.state).toBe("allow");
    expect(status.source).toBe("session");
  });
});

// ---------------------------------------------------------------------------
// Step 6: source field and matchedPattern for all five surfaces
// ---------------------------------------------------------------------------

describe("checkPermission — source derivation and matchedPattern", () => {
  describe("external_directory (special surface)", () => {
    it("source is 'special' for a config-matched path", () => {
      const { manager, cleanup } = createManagerWithConfig({
        "*": "ask",
        external_directory: { "/trusted/*": "allow" },
      });
      try {
        const result = checkPath(
          manager,
          "/trusted/repo",
          {},
          "external_directory",
        );
        expect(result.state).toBe("allow");
        expect(result.source).toBe("special");
        expect(result.matchedPattern).toBe("/trusted/*");
      } finally {
        cleanup();
      }
    });

    it("source is 'special' even for a default match (no config rule)", () => {
      const manager = createMissingConfigManager();
      const result = checkPath(manager, "/some/path", {}, "external_directory");
      expect(result.state).toBe("ask");
      expect(result.source).toBe("special");
      expect(result.matchedPattern).toBeUndefined();
    });

    it("matchedPattern is undefined for a default match", () => {
      const manager = createMissingConfigManager();
      const result = checkPath(manager, "/unknown", {}, "external_directory");
      expect(result.matchedPattern).toBeUndefined();
    });
  });

  describe("skill surface", () => {
    it("source is 'skill' for a config-matched skill name", () => {
      const { manager, cleanup } = createManagerWithConfig({
        "*": "ask",
        skill: { librarian: "allow" },
      });
      try {
        const result = checkTool(manager, "skill", { name: "librarian" });
        expect(result.state).toBe("allow");
        expect(result.source).toBe("skill");
        expect(result.matchedPattern).toBe("librarian");
      } finally {
        cleanup();
      }
    });

    it("source is 'skill' even for a default match", () => {
      const manager = createMissingConfigManager();
      const result = checkTool(manager, "skill", { name: "unknown" });
      expect(result.state).toBe("ask");
      expect(result.source).toBe("skill");
    });
  });

  describe("bash surface", () => {
    it("source is 'bash' and command is included in result", () => {
      const { manager, cleanup } = createManagerWithConfig({
        "*": "ask",
        bash: { "git *": "allow" },
      });
      try {
        const result = checkTool(manager, "bash", {
          command: "git status",
        });
        expect(result.state).toBe("allow");
        expect(result.source).toBe("bash");
        expect(result.command).toBe("git status");
        expect(result.matchedPattern).toBe("git *");
      } finally {
        cleanup();
      }
    });

    it("source is 'bash' even for a default match, command is empty string", () => {
      const manager = createMissingConfigManager();
      const result = checkTool(manager, "bash", {});
      expect(result.source).toBe("bash");
      expect(result.command).toBe("");
      expect(result.matchedPattern).toBeUndefined();
    });
  });

  describe("mcp surface", () => {
    it("source is 'mcp' for a config-matched target", () => {
      const { manager, cleanup } = createManagerWithConfig(
        { "*": "ask", mcp: { exa_search: "allow" } },
        ["exa"],
      );
      try {
        const result = checkTool(manager, "mcp", {
          tool: "exa:search",
          server: "exa",
        });
        expect(result.state).toBe("allow");
        expect(result.source).toBe("mcp");
        expect(result.matchedPattern).toBe("exa_search");
        expect(result.target).toBeDefined();
      } finally {
        cleanup();
      }
    });

    it("source is 'default' when all targets match only the synthesized default", () => {
      const manager = createMissingConfigManager();
      const result = checkTool(manager, "mcp", { tool: "exa:search" });
      expect(result.state).toBe("ask");
      expect(result.source).toBe("default");
      expect(result.matchedPattern).toBeUndefined();
    });

    it("target field is set for a matched mcp call", () => {
      const { manager, cleanup } = createManagerWithConfig(
        { "*": "ask", mcp: { mcp_status: "allow" } },
        [],
      );
      try {
        const result = checkTool(manager, "mcp", {});
        expect(result.target).toBeDefined();
        expect(result.source).toBe("mcp");
      } finally {
        cleanup();
      }
    });
  });

  describe("tool surfaces", () => {
    it("built-in tool: source is always 'tool' (config match)", () => {
      const { manager, cleanup } = createManagerWithConfig({
        "*": "ask",
        read: "allow",
      });
      try {
        const result = checkTool(manager, "read", {});
        expect(result.state).toBe("allow");
        expect(result.source).toBe("tool");
      } finally {
        cleanup();
      }
    });

    it("built-in tool: source is 'tool' even for a default match", () => {
      const manager = createMissingConfigManager();
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("ask");
      expect(result.source).toBe("tool");
    });

    it("extension tool: source is 'default' when no config rule matches", () => {
      const manager = createMissingConfigManager();
      const result = checkTool(manager, "my_custom_tool", {});
      expect(result.state).toBe("ask");
      expect(result.source).toBe("default");
    });

    it("extension tool: source is 'tool' when a config rule matches", () => {
      const { manager, cleanup } = createManagerWithConfig({
        "*": "ask",
        my_custom_tool: "allow",
      });
      try {
        const result = checkTool(manager, "my_custom_tool", {});
        expect(result.state).toBe("allow");
        expect(result.source).toBe("tool");
      } finally {
        cleanup();
      }
    });
  });

  describe("matchedPattern for session rules across surfaces", () => {
    it("matchedPattern is the session rule pattern for a session match (bash)", () => {
      const manager = createMissingConfigManager();
      const sessionRules: Ruleset = [sessionRule("bash", "git *")];
      const result = checkTool(
        manager,
        "bash",
        { command: "git status" },
        undefined,
        sessionRules,
      );
      expect(result.matchedPattern).toBe("git *");
      expect(result.source).toBe("session");
    });

    it("matchedPattern is the session rule pattern for a session match (skill)", () => {
      const manager = createMissingConfigManager();
      const sessionRules: Ruleset = [sessionRule("skill", "librarian")];
      const result = checkTool(
        manager,
        "skill",
        { name: "librarian" },
        undefined,
        sessionRules,
      );
      expect(result.matchedPattern).toBe("librarian");
    });
  });
});

// ---------------------------------------------------------------------------
// Home directory expansion in external_directory patterns
// ---------------------------------------------------------------------------

describe("checkPermission — home path expansion in external_directory rules", () => {
  it("~/glob pattern allows a path under the real home directory", () => {
    const { manager, cleanup } = createManagerWithConfig({
      "*": "ask",
      external_directory: { "~/trusted/*": "allow" },
    });
    try {
      const result = checkPath(
        manager,
        join(homedir(), "trusted/repo"),
        {},
        "external_directory",
      );
      expect(result.state).toBe("allow");
      expect(result.source).toBe("special");
      expect(result.matchedPattern).toBe("~/trusted/*");
    } finally {
      cleanup();
    }
  });

  it("$HOME/glob pattern allows a path under the real home directory", () => {
    const { manager, cleanup } = createManagerWithConfig({
      "*": "ask",
      external_directory: { "$HOME/trusted/*": "allow" },
    });
    try {
      const result = checkPath(
        manager,
        join(homedir(), "trusted/repo"),
        {},
        "external_directory",
      );
      expect(result.state).toBe("allow");
      expect(result.source).toBe("special");
      expect(result.matchedPattern).toBe("$HOME/trusted/*");
    } finally {
      cleanup();
    }
  });

  it("~/glob deny rule blocks a path under home", () => {
    const { manager, cleanup } = createManagerWithConfig({
      "*": "allow",
      external_directory: { "~/private/*": "deny" },
    });
    try {
      const result = checkPath(
        manager,
        join(homedir(), "private/secrets.txt"),
        {},
        "external_directory",
      );
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("~/private/*");
    } finally {
      cleanup();
    }
  });

  it("~/glob pattern does not match a path outside home", () => {
    const { manager, cleanup } = createManagerWithConfig({
      "*": "ask",
      external_directory: { "~/trusted/*": "allow" },
    });
    try {
      const result = checkPath(
        manager,
        "/tmp/not-home/file",
        {},
        "external_directory",
      );
      // Falls back to the "*": "ask" default — no allow from the ~/trusted/* rule.
      expect(result.state).toBe("ask");
      expect(result.matchedPattern).toBeUndefined();
    } finally {
      cleanup();
    }
  });
});

// ---------------------------------------------------------------------------
// Rule origin provenance
// ---------------------------------------------------------------------------

describe("checkPermission — rule origin provenance", () => {
  it("single-scope global: config rule has origin 'global'", () => {
    const { manager, cleanup } = createManagerWithScopes({ read: "allow" });
    try {
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("global");
    } finally {
      cleanup();
    }
  });

  it("single-scope global with pattern map: origin is 'global'", () => {
    const { manager, cleanup } = createManagerWithScopes({
      bash: { "git *": "allow" },
    });
    try {
      const result = checkTool(manager, "bash", { command: "git status" });
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("global");
    } finally {
      cleanup();
    }
  });

  it("project overrides global: winning rule has origin 'project'", () => {
    const { manager, cleanup } = createManagerWithScopes(
      { read: "ask" },
      { read: "allow" },
    );
    try {
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project");
    } finally {
      cleanup();
    }
  });

  it("both-object merge: patterns retain their own origins", () => {
    // global defines bash["git *"] = allow; project adds bash["rm *"] = deny.
    // Both patterns should survive with their own origins.
    const { manager, cleanup } = createManagerWithScopes(
      { bash: { "git *": "allow" } },
      { bash: { "rm *": "deny" } },
    );
    try {
      const gitResult = checkTool(manager, "bash", {
        command: "git status",
      });
      expect(gitResult.state).toBe("allow");
      expect(gitResult.origin).toBe("global");

      const rmResult = checkTool(manager, "bash", {
        command: "rm -rf /",
      });
      expect(rmResult.state).toBe("deny");
      expect(rmResult.origin).toBe("project");
    } finally {
      cleanup();
    }
  });

  it("both-object merge: project pattern overrides global pattern for same key", () => {
    // Both scopes define bash["git *"]; project wins for that pattern.
    const { manager, cleanup } = createManagerWithScopes(
      { bash: { "git *": "ask" } },
      { bash: { "git *": "allow" } },
    );
    try {
      const result = checkTool(manager, "bash", {
        command: "git status",
      });
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project");
    } finally {
      cleanup();
    }
  });

  it("string replaces object: all patterns from replacing scope get origin 'project'", () => {
    // global defines bash as an object; project replaces with string "allow".
    const { manager, cleanup } = createManagerWithScopes(
      { bash: { "git *": "ask", "npm *": "ask" } },
      { bash: "allow" },
    );
    try {
      // The catch-all "*" now comes from the project scope.
      const result = checkTool(manager, "bash", {
        command: "anything",
      });
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project");
    } finally {
      cleanup();
    }
  });

  it("object replaces string: all patterns from replacing scope get origin 'project'", () => {
    // global defines read as a string "ask"; project replaces with object.
    const { manager, cleanup } = createManagerWithScopes(
      { read: "ask" },
      { read: { "*": "allow" } },
    );
    try {
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project");
    } finally {
      cleanup();
    }
  });

  it("no config match: origin is 'builtin' (default layer)", () => {
    // No config — falls back to synthesized default.
    const manager = createMissingConfigManager();
    const result = checkTool(manager, "read", {});
    expect(result.state).toBe("ask");
    expect(result.origin).toBe("builtin");
  });

  it("session rule: origin is 'session'", () => {
    const manager = createMissingConfigManager();
    const sessionRules: Ruleset = [sessionRule("read", "*")];
    const result = checkTool(manager, "read", {}, undefined, sessionRules);
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
    expect(result.origin).toBe("session");
  });

  it("universal fallback (*) set in global config carries origin 'global'", () => {
    const { manager, cleanup } = createManagerWithScopes({ "*": "allow" });
    try {
      // No explicit surface rule — hits the synthesized default derived from "*".
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("global");
    } finally {
      cleanup();
    }
  });

  it("universal fallback (*) overridden by project carries origin 'project'", () => {
    const { manager, cleanup } = createManagerWithScopes(
      { "*": "ask" },
      { "*": "allow" },
    );
    try {
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project");
    } finally {
      cleanup();
    }
  });

  it("built-in fallback (no * in any config): origin is 'builtin'", () => {
    // Manager with no config file — built-in "ask" default.
    const manager = createMissingConfigManager();
    const result = checkTool(manager, "read", {});
    expect(result.state).toBe("ask");
    expect(result.origin).toBe("builtin");
  });
});

// ---------------------------------------------------------------------------
// In-memory PolicyLoader stub tests — no filesystem required
// ---------------------------------------------------------------------------

import type { PermissionCheckResult, ScopeConfig } from "#src/types";

describe("PermissionManager with in-memory PolicyLoader", () => {
  describe("universal fallback", () => {
    it("defaults to ask when no config is provided", () => {
      const manager = createInMemoryManager();
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("ask");
      expect(result.origin).toBe("builtin");
    });

    it("respects permission['*'] = 'allow' from global config", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "allow" } },
      });
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("global");
    });

    it("respects permission['*'] = 'deny' from global config", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "deny" } },
      });
      const result = checkTool(manager, "write", {});
      expect(result.state).toBe("deny");
    });
  });

  describe("surface routing", () => {
    it("bash surface routes correctly", () => {
      const manager = createInMemoryManager({
        global: {
          permission: { "*": "ask", bash: { "git *": "allow" } },
        },
      });
      const result = checkTool(manager, "bash", {
        command: "git status",
      });
      expect(result.state).toBe("allow");
      expect(result.source).toBe("bash");
      expect(result.matchedPattern).toBe("git *");
    });

    it("tool surface routes correctly for built-in tools", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "deny", read: "allow" } },
      });
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.source).toBe("tool");
    });

    it("skill surface routes correctly", () => {
      const manager = createInMemoryManager({
        global: {
          permission: { "*": "ask", skill: { librarian: "allow" } },
        },
      });
      const result = checkTool(manager, "skill", { name: "librarian" });
      expect(result.state).toBe("allow");
      expect(result.source).toBe("skill");
    });

    it("mcp surface routes correctly", () => {
      const manager = createInMemoryManager(
        {
          global: {
            permission: { "*": "ask", mcp: { exa_search: "allow" } },
          },
        },
        ["exa"],
      );
      const result = checkTool(manager, "mcp", {
        tool: "exa:search",
        server: "exa",
      });
      expect(result.state).toBe("allow");
      expect(result.source).toBe("mcp");
    });

    it("external_directory surface routes correctly", () => {
      const manager = createInMemoryManager({
        global: {
          permission: {
            "*": "ask",
            external_directory: { "/trusted/*": "allow" },
          },
        },
      });
      const result = checkPath(
        manager,
        "/trusted/repo",
        {},
        "external_directory",
      );
      expect(result.state).toBe("allow");
      expect(result.source).toBe("special");
    });

    it("extension tools use 'default' source when no config rule matches", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "ask" } },
      });
      const result = checkTool(manager, "my_custom_tool", {});
      expect(result.state).toBe("ask");
      expect(result.source).toBe("default");
    });
  });

  describe("multi-scope merge", () => {
    it("project overrides global", () => {
      const manager = createInMemoryManager({
        global: { permission: { read: "ask" } },
        project: { permission: { read: "allow" } },
      });
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project");
    });

    it("agent overrides project", () => {
      const manager = createInMemoryManager({
        global: { permission: { read: "ask" } },
        project: { permission: { read: "allow" } },
        agent: { coder: { permission: { read: "deny" } } },
      });
      const result = checkTool(manager, "read", {}, "coder");
      expect(result.state).toBe("deny");
      expect(result.origin).toBe("agent");
    });

    it("project-agent overrides agent", () => {
      const manager = createInMemoryManager({
        global: { permission: { read: "deny" } },
        agent: { coder: { permission: { read: "deny" } } },
        projectAgent: { coder: { permission: { read: "allow" } } },
      });
      const result = checkTool(manager, "read", {}, "coder");
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project-agent");
    });

    it("deep-shallow merge preserves patterns from different scopes", () => {
      const manager = createInMemoryManager({
        global: { permission: { bash: { "git *": "allow" } } },
        project: { permission: { bash: { "rm *": "deny" } } },
      });
      const gitResult = checkTool(manager, "bash", {
        command: "git status",
      });
      expect(gitResult.state).toBe("allow");
      expect(gitResult.origin).toBe("global");

      const rmResult = checkTool(manager, "bash", {
        command: "rm -rf /",
      });
      expect(rmResult.state).toBe("deny");
      expect(rmResult.origin).toBe("project");
    });

    it("string replaces object in override scope", () => {
      const manager = createInMemoryManager({
        global: {
          permission: { bash: { "git *": "ask", "npm *": "ask" } },
        },
        project: { permission: { bash: "allow" } },
      });
      const result = checkTool(manager, "bash", { command: "anything" });
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project");
    });
  });

  describe("session rule composition", () => {
    it("session rule wins over config", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "deny" } },
      });
      const sessionRules: Ruleset = [sessionRule("read", "*")];
      const result = checkTool(manager, "read", {}, undefined, sessionRules);
      expect(result.state).toBe("allow");
      expect(result.source).toBe("session");
    });

    it("session rule does not bleed across surfaces", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "ask" } },
      });
      const sessionRules: Ruleset = [sessionRule("bash", "git *")];
      const bashResult = checkTool(
        manager,
        "bash",
        { command: "git status" },
        undefined,
        sessionRules,
      );
      expect(bashResult.state).toBe("allow");

      const readResult = checkTool(
        manager,
        "read",
        {},
        undefined,
        sessionRules,
      );
      expect(readResult.state).toBe("ask");
    });
  });

  describe("origin tracking", () => {
    it("universal fallback from project carries origin 'project'", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "ask" } },
        project: { permission: { "*": "allow" } },
      });
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
      expect(result.origin).toBe("project");
    });

    it("session origin is 'session'", () => {
      const manager = createInMemoryManager();
      const sessionRules: Ruleset = [sessionRule("read", "*")];
      const result = checkTool(manager, "read", {}, undefined, sessionRules);
      expect(result.origin).toBe("session");
    });
  });

  describe("getToolPermission", () => {
    it("returns tool-level state for built-in tools", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "deny", read: "allow" } },
      });
      expect(manager.getToolPermission("read")).toBe("allow");
      expect(manager.getToolPermission("write")).toBe("deny");
    });

    it("returns tool-level state for bash surface", () => {
      const manager = createInMemoryManager({
        global: { permission: { "*": "deny", bash: "allow" } },
      });
      expect(manager.getToolPermission("bash")).toBe("allow");
    });
  });

  describe("getComposedConfigRules", () => {
    it("returns only config-layer rules", () => {
      const manager = createInMemoryManager({
        global: {
          permission: { "*": "ask", bash: { "git *": "allow" } },
        },
      });
      const rules = manager.getComposedConfigRules();
      expect(rules.every((r) => r.layer === "config")).toBe(true);
      expect(
        rules.some((r) => r.surface === "bash" && r.pattern === "git *"),
      ).toBe(true);
    });
  });
});

// ---------------------------------------------------------------------------
// Per-tool path patterns (#147)
// ---------------------------------------------------------------------------

describe("checkPermission — per-tool path patterns", () => {
  it("denies read of .env when path pattern matches", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: { "*": "allow", "*.env": "deny" },
    });
    try {
      const result = checkPath(manager, ".env", {}, "read");
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("*.env");
    } finally {
      cleanup();
    }
  });

  it("allows read of non-.env file when .env is denied", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: { "*": "allow", "*.env": "deny" },
    });
    try {
      const result = checkPath(manager, "src/main.ts", {}, "read");
      expect(result.state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("allows write to src/ when only src/ is allowed", () => {
    const { manager, cleanup } = createManagerWithConfig({
      write: { "*": "deny", "src/*": "allow" },
    });
    try {
      const result = checkPath(manager, "src/main.ts", {}, "write");
      expect(result.state).toBe("allow");
      expect(result.matchedPattern).toBe("src/*");
    } finally {
      cleanup();
    }
  });

  it("denies write outside src/ when only src/ is allowed", () => {
    const { manager, cleanup } = createManagerWithConfig({
      write: { "*": "deny", "src/*": "allow" },
    });
    try {
      const result = checkPath(manager, "vendor/lib.ts", {}, "write");
      expect(result.state).toBe("deny");
    } finally {
      cleanup();
    }
  });

  it("backward compat: 'read': 'allow' allows read of any path", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: "allow",
    });
    try {
      const result = checkPath(manager, ".env", {}, "read");
      expect(result.state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("backward compat: 'read': 'deny' denies read of any path", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: "deny",
    });
    try {
      const result = checkPath(manager, "src/main.ts", {}, "read");
      expect(result.state).toBe("deny");
    } finally {
      cleanup();
    }
  });

  it("session rule for specific path overrides config deny", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: { "*": "allow", "*.env": "deny" },
    });
    try {
      const sessionRules: Ruleset = [sessionRule("read", ".env")];
      const result = checkPath(
        manager,
        ".env",
        {},
        "read",
        undefined,
        sessionRules,
      );
      expect(result.state).toBe("allow");
      expect(result.source).toBe("session");
    } finally {
      cleanup();
    }
  });

  it("falls back to '*' when input.path is missing", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: { "*": "allow", "*.env": "deny" },
    });
    try {
      const result = checkTool(manager, "read", {});
      expect(result.state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("getToolPermission still returns surface-level state (not path-specific)", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: { "*": "allow", "*.env": "deny" },
    });
    try {
      const toolState = manager.getToolPermission("read");
      expect(toolState).toBe("allow");
    } finally {
      cleanup();
    }
  });
});

// ---------------------------------------------------------------------------
// Cross-cutting path surface (#148)
// ---------------------------------------------------------------------------

describe("cross-cutting path surface", () => {
  it("denies .env via the path surface", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "*.env": "deny" },
      read: "allow",
    });
    try {
      const result = checkPath(manager, ".env");
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("*.env");
    } finally {
      cleanup();
    }
  });

  it("allows non-matching paths via the path surface", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "*.env": "deny" },
      read: "allow",
    });
    try {
      const result = checkPath(manager, "README.md");
      expect(result.state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("path surface does not interfere with per-tool rules", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow" },
      read: { "*": "allow", "*.secret": "deny" },
    });
    try {
      // path surface allows, per-tool denies
      const readResult = checkPath(manager, "data.secret", {}, "read");
      expect(readResult.state).toBe("deny");
      // path surface also allows
      const pathResult = checkPath(manager, "data.secret");
      expect(pathResult.state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("getToolPermission('path') returns catch-all action", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "*.env": "deny" },
    });
    try {
      const toolState = manager.getToolPermission("path");
      expect(toolState).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("session approval on path surface overrides config deny", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "*.env": "deny" },
    });
    try {
      const sessionRules: Ruleset = [sessionRule("path", "/project/.env")];
      const result = checkPath(
        manager,
        "/project/.env",
        {},
        "path",
        undefined,
        sessionRules,
      );
      expect(result.state).toBe("allow");
      expect(result.source).toBe("session");
    } finally {
      cleanup();
    }
  });

  it("configs without path key behave identically (no path gate fires)", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: "allow",
    });
    try {
      // path surface falls through to universal default
      const result = checkPath(manager, ".env");
      expect(result.state).toBe("ask");
    } finally {
      cleanup();
    }
  });

  it("universal default produces undefined matchedPattern for gate skip (#58)", () => {
    const { manager, cleanup } = createManagerWithConfig({
      "*": "ask",
      read: "allow",
      find: "allow",
    });
    try {
      // No explicit "path" key → matchedPattern must be undefined so the
      // path gate skips (describePathGate returns null).
      const result = checkPath(manager, "src/main.ts");
      expect(result.state).toBe("ask");
      expect(result.matchedPattern).toBeUndefined();

      // Meanwhile the tool-level check should allow read.
      const readResult = checkPath(manager, "src/main.ts", {}, "read");
      expect(readResult.state).toBe("allow");
      expect(readResult.matchedPattern).toBe("*");
    } finally {
      cleanup();
    }
  });

  // ── Deny-with-reason ────────────────────────────────────────────────────

  it("deny-with-reason: reason threads through to PermissionCheckResult", () => {
    const { manager, cleanup } = createManagerWithConfig({
      bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
    });
    try {
      const result = checkTool(manager, "bash", {
        command: "npm install",
      });
      expect(result.state).toBe("deny");
      expect(result.reason).toBe("Use pnpm instead");
      expect(result.matchedPattern).toBe("npm *");
    } finally {
      cleanup();
    }
  });

  it("deny-without-reason: reason is undefined in PermissionCheckResult", () => {
    const { manager, cleanup } = createManagerWithConfig({
      bash: { "rm -rf *": "deny" },
    });
    try {
      const result = checkTool(manager, "bash", { command: "rm -rf /" });
      expect(result.state).toBe("deny");
      expect(result.reason).toBeUndefined();
    } finally {
      cleanup();
    }
  });

  it("deny-with-reason on a non-bash surface", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: {
        "*.env": {
          action: "deny",
          reason: "Environment files contain secrets",
        },
      },
    });
    try {
      const result = checkPath(manager, ".env", {}, "read");
      expect(result.state).toBe("deny");
      expect(result.reason).toBe("Environment files contain secrets");
      expect(result.matchedPattern).toBe("*.env");
    } finally {
      cleanup();
    }
  });

  it("non-string reason falls through to the default (malformed config)", () => {
    const { manager, cleanup } = createManagerWithConfig({
      bash: { "npm *": { action: "deny", reason: 42 } },
    });
    try {
      const result = checkTool(manager, "bash", {
        command: "npm install",
      });
      expect(result.state).toBe("ask");
      expect(result.reason).toBeUndefined();
    } finally {
      cleanup();
    }
  });

  // ── Last-match-wins ordering ────────────────────────────────────────────

  it("last-match-wins: catch-all after deny overrides the deny", () => {
    // Classic misconfiguration: deny is before allow, so allow wins.
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*.env": "deny", "*": "allow" },
    });
    try {
      const result = checkPath(manager, ".env");
      // "*" is last and matches .env → allow (the deny is shadowed)
      expect(result.state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("last-match-wins: deny after catch-all blocks the path", () => {
    // Correct ordering: catch-all first, specific deny after.
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "*.env": "deny" },
    });
    try {
      const result = checkPath(manager, ".env");
      expect(result.state).toBe("deny");
    } finally {
      cleanup();
    }
  });

  // ── .env.example override recipe ────────────────────────────────────────

  it(".env.example override: denies .env and .env.local, allows .env.example", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: {
        "*": "allow",
        "*.env": "deny",
        "*.env.*": "deny",
        "*.env.example": "allow",
      },
    });
    try {
      expect(checkPath(manager, ".env").state).toBe("deny");
      expect(checkPath(manager, ".env.local").state).toBe("deny");
      expect(checkPath(manager, ".env.production").state).toBe("deny");
      expect(checkPath(manager, "src/.env").state).toBe("deny");
      expect(checkPath(manager, ".env.example").state).toBe("allow");
      expect(checkPath(manager, "README.md").state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  // ── Universal fallback interaction ──────────────────────────────────────

  it("universal '*': 'allow' with no path key makes the path gate transparent", () => {
    const { manager, cleanup } = createManagerWithConfig({
      "*": "allow",
    });
    try {
      const result = checkPath(manager, ".env");
      expect(result.state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("universal '*': 'deny' with no path key denies via path surface too", () => {
    const { manager, cleanup } = createManagerWithConfig({
      "*": "deny",
    });
    try {
      const result = checkPath(manager, ".env");
      expect(result.state).toBe("deny");
    } finally {
      cleanup();
    }
  });

  // ── Composition: path allows, per-tool denies ──────────────────────

  it("per-tool deny still blocks even when path surface allows", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow" },
      read: "deny",
    });
    try {
      // path gate passes (allow), but tool gate denies
      const pathResult = checkPath(manager, "secret.txt");
      expect(pathResult.state).toBe("allow");
      const readResult = checkPath(manager, "secret.txt", {}, "read");
      expect(readResult.state).toBe("deny");
    } finally {
      cleanup();
    }
  });
});

// ---------------------------------------------------------------------------
// Home-expansion in path values (issue #350)
// ---------------------------------------------------------------------------

describe("cross-cutting path surface — home-expanded values", () => {
  it("~/... path value is denied by a ~/* rule (reported footgun)", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "~/.ssh/*": "deny" },
    });
    try {
      const result = checkPath(manager, "~/.ssh/config");
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("~/.ssh/*");
    } finally {
      cleanup();
    }
  });

  it("$HOME/... path value is denied by a ~/* rule", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "~/.ssh/*": "deny" },
    });
    try {
      const result = checkPath(manager, `${homedir()}/.ssh/config`);
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("~/.ssh/*");
    } finally {
      cleanup();
    }
  });

  it("$HOME/... path value matches a $HOME/* pattern rule", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "$HOME/.ssh/*": "deny" },
    });
    try {
      const result = checkPath(manager, "$HOME/.ssh/config");
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("$HOME/.ssh/*");
    } finally {
      cleanup();
    }
  });

  it("already-absolute home path is still denied by ~/* rule", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "~/.ssh/*": "deny" },
    });
    try {
      const result = checkPath(manager, `${homedir()}/.ssh/config`);
      expect(result.state).toBe("deny");
    } finally {
      cleanup();
    }
  });

  it("non-home value is unchanged — .env still matches *.env", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "allow", "*.env": "deny" },
    });
    try {
      const result = checkPath(manager, ".env");
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("*.env");
    } finally {
      cleanup();
    }
  });

  it("per-tool read surface denies ~/... path with a ~/* rule", () => {
    const { manager, cleanup } = createManagerWithConfig({
      "*": "allow",
      read: { "*": "allow", "~/.ssh/*": "deny" },
    });
    try {
      const result = checkPath(manager, "~/.ssh/config", {}, "read");
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("~/.ssh/*");
    } finally {
      cleanup();
    }
  });
});

// ---------------------------------------------------------------------------
// configureForCwd and agentDir construction
// ---------------------------------------------------------------------------

describe("PermissionManager — configureForCwd and agentDir option", () => {
  it("ScopedPermissionManager is exported and PermissionManager satisfies it", () => {
    // Type-level assertion: assigning PermissionManager to ScopedPermissionManager compiles.
    const manager = new PermissionManager({
      globalConfigPath: "/nonexistent/config.json",
      agentsDir: "/nonexistent/agents",
    });
    const scoped: ScopedPermissionManager = manager;
    expect(typeof scoped.configureForCwd).toBe("function");
    expect(typeof scoped.check).toBe("function");
    expect(typeof scoped.getToolPermission).toBe("function");
    expect(typeof scoped.getConfigIssues).toBe("function");
  });

  it("construction with { agentDir } reads global config from getGlobalConfigPath(agentDir)", () => {
    const { agentDir, cleanup } = createAgentDirHarness({
      globalPermission: { read: "deny" },
    });
    try {
      const manager = new PermissionManager({ agentDir });
      const result = checkPath(manager, "foo.txt", {}, "read");
      expect(result.state).toBe("deny");
    } finally {
      cleanup();
    }
  });

  it("configureForCwd(cwd) applies project config (project overrides global)", () => {
    const { agentDir, cwd, cleanup } = createAgentDirHarness({
      globalPermission: { read: "deny" },
      projectPermission: { read: "allow" },
    });
    try {
      const manager = new PermissionManager({ agentDir });
      // Before configureForCwd: global policy applies
      expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny");

      manager.configureForCwd(cwd);

      // After configureForCwd: project override applies (last-match-wins)
      expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow");
    } finally {
      cleanup();
    }
  });

  it("configureForCwd(undefined) reverts to global-only", () => {
    const { agentDir, cwd, cleanup } = createAgentDirHarness({
      globalPermission: { read: "deny" },
      projectPermission: { read: "allow" },
    });
    try {
      const manager = new PermissionManager({ agentDir });
      manager.configureForCwd(cwd);
      expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow");

      manager.configureForCwd(undefined);

      // After reverting: global policy applies again
      expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny");
    } finally {
      cleanup();
    }
  });

  it("configureForCwd clears the resolved-permissions cache", () => {
    const { agentDir, globalConfigPath, cleanup } = createAgentDirHarness({
      globalPermission: { read: "allow" },
    });
    try {
      const manager = new PermissionManager({ agentDir });
      // Warm the cache
      expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow");
      // Update global config on disk to deny read
      writeFileSync(
        globalConfigPath,
        JSON.stringify({ permission: { read: "deny" } }, null, 2),
      );
      // configureForCwd clears cache + rebuilds loader
      manager.configureForCwd(undefined);
      // Should pick up the changed global config
      expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny");
    } finally {
      cleanup();
    }
  });

  it("configureForCwd(cwd) derives projectAgentsDir at <cwd>/.pi/agents (regression: #428)", () => {
    // Bug: old code derived <cwd>/.pi/agent/agents instead of <cwd>/.pi/agents.
    // This test pins the correct path and verifies agentsDir is unchanged.
    const { agentDir, cwd, cleanup } = createAgentDirHarness({
      globalPermission: { read: "allow" },
    });
    try {
      const manager = new PermissionManager({ agentDir });
      manager.configureForCwd(cwd);
      const paths = manager.getResolvedPolicyPaths();
      expect(paths.projectAgentsDir).toBe(getProjectAgentsDir(cwd));
      expect(paths.agentsDir).toBe(join(agentDir, "agents"));
    } finally {
      cleanup();
    }
  });

  it("configureForCwd(cwd) enforces permission: frontmatter from <cwd>/.pi/agents/<agent>.md (regression: #428)", () => {
    // Bug: wrong directory meant project-agent frontmatter was never loaded.
    const { agentDir, cwd, cleanup } = createAgentDirHarness({
      globalPermission: { read: "allow" },
    });
    try {
      // Write a project agent definition with a deny override.
      const projectAgentsDir = getProjectAgentsDir(cwd);
      mkdirSync(projectAgentsDir, { recursive: true });
      writeFileSync(
        join(projectAgentsDir, "coder.md"),
        "---\npermission:\n  read: deny\n---\n# Coder\n",
      );

      const manager = new PermissionManager({ agentDir });
      manager.configureForCwd(cwd);

      // Without an agent name: global allow applies.
      expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow");
      // With the "coder" agent: project-agent deny overrides global allow.
      expect(checkPath(manager, "foo.txt", {}, "read", "coder").state).toBe(
        "deny",
      );
    } finally {
      cleanup();
    }
  });
});

// ---------------------------------------------------------------------------
// Project-level and per-agent config scope — moved from catch-all (#342)
// ---------------------------------------------------------------------------

test("Project-level config overrides base bash patterns", () => {
  const { manager, cleanup } = createManagerWithProject(
    {
      permission: {
        "*": "allow",
        bash: { "*": "ask", "rm -rf *": "deny" },
      },
    },
    {},
    {
      projectConfig: {
        permission: { bash: { "rm -rf build": "allow" } },
      },
    },
  );

  try {
    const allowed = checkTool(manager, "bash", {
      command: "rm -rf build",
    });
    expect(allowed.state).toBe("allow");
    expect(allowed.matchedPattern).toBe("rm -rf build");

    const denied = checkTool(manager, "bash", {
      command: "rm -rf node_modules",
    });
    expect(denied.state).toBe("deny");
    expect(denied.matchedPattern).toBe("rm -rf *");
  } finally {
    cleanup();
  }
});

test("System-agent config overrides project-level bash patterns", () => {
  const { manager, cleanup } = createManagerWithProject(
    {
      permission: { "*": "allow", bash: "ask" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  bash:
    "git log *": allow
---
`,
    },
    {
      projectConfig: {
        permission: { bash: { "git *": "deny" } },
      },
    },
  );

  try {
    const allowed = checkTool(
      manager,
      "bash",
      { command: "git log --oneline" },
      "reviewer",
    );
    expect(allowed.state).toBe("allow");
    expect(allowed.matchedPattern).toBe("git log *");

    const denied = checkTool(
      manager,
      "bash",
      { command: "git status" },
      "reviewer",
    );
    expect(denied.state).toBe("deny");
    expect(denied.matchedPattern).toBe("git *");
  } finally {
    cleanup();
  }
});

test("Project-agent config overrides system-agent tool rules", () => {
  const { manager, cleanup } = createManagerWithProject(
    {
      permission: { "*": "ask" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  read: deny
---
`,
    },
    {
      projectAgentFiles: {
        reviewer: `---
name: reviewer
permission:
  read: allow
---
`,
      },
    },
  );

  try {
    const result = checkTool(manager, "read", {}, "reviewer");
    expect(result.state).toBe("allow");
    expect(result.source).toBe("tool");
  } finally {
    cleanup();
  }
});

test("Full precedence chain base < project < system-agent < project-agent for universal default", () => {
  const { manager, cleanup } = createManagerWithProject(
    {
      permission: { "*": "deny" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  "*": ask
---
`,
    },
    {
      projectConfig: {
        permission: { "*": "allow" },
      },
      projectAgentFiles: {
        reviewer: `---
name: reviewer
permission:
  "*": deny
---
`,
      },
    },
  );

  try {
    const reviewerResult = checkTool(
      manager,
      "custom_extension_tool",
      {},
      "reviewer",
    );
    expect(reviewerResult.state).toBe("deny");
    expect(reviewerResult.source).toBe("default");

    const globalResult = checkTool(manager, "custom_extension_tool", {});
    expect(globalResult.state).toBe("allow");
    expect(globalResult.source).toBe("default");
  } finally {
    cleanup();
  }
});

test("Project-agent applies even without a matching system-agent file", () => {
  const { manager, cleanup } = createManagerWithProject(
    {
      permission: { "*": "allow" },
    },
    {},
    {
      projectAgentFiles: {
        reviewer: `---
name: reviewer
permission:
  read: deny
---
`,
      },
    },
  );

  try {
    const agentResult = checkTool(manager, "read", {}, "reviewer");
    expect(agentResult.state).toBe("deny");
    expect(agentResult.source).toBe("tool");

    const globalResult = checkTool(manager, "read", {});
    expect(globalResult.state).toBe("allow");
    expect(globalResult.source).toBe("tool");
  } finally {
    cleanup();
  }
});

// ---------------------------------------------------------------------------
// PermissionManager surface resolution — moved from catch-all (#342)
// ---------------------------------------------------------------------------

test("PermissionManager canonical built-in permission checking", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "deny", read: "allow" },
  });

  try {
    const readResult = checkTool(manager, "read", {});
    expect(readResult.state).toBe("allow");
    expect(readResult.source).toBe("tool");

    const writeResult = checkTool(manager, "write", {});
    expect(writeResult.state).toBe("deny");
    expect(writeResult.source).toBe("tool");
  } finally {
    cleanup();
  }
});

test("multiline bash command resolves to allow via universal fallback", () => {
  // Regression test for #73: node -e "..." with embedded newlines was
  // falling through to the hard-coded 'ask' default because wildcardMatch
  // used /^.*$/ (no dotAll), which does not match '\n'.
  const { manager, cleanup } = createManager({
    permission: {
      "*": "allow",
      bash: { "rm -rf *": "deny", "sudo *": "ask" },
    },
  });

  try {
    const command =
      "node -e \"\nimport('x').then(() => {\n  console.log('done');\n});\n\"";
    const result = checkTool(manager, "bash", { command });
    expect(result.state).toBe("allow");
  } finally {
    cleanup();
  }
});

test("Bash specific deny patterns override catch-all within the same config", () => {
  // In the flat format, patterns within a surface map are ordered by insertion.
  // Last-match-wins means specific patterns placed AFTER the catch-all override it.
  const { manager, cleanup } = createManager({
    permission: {
      "*": "ask",
      bash: { "*": "allow", "rm -rf *": "deny" },
    },
  });

  try {
    const denied = checkTool(manager, "bash", {
      command: "rm -rf build",
    });
    expect(denied.state).toBe("deny");
    expect(denied.source).toBe("bash");
    expect(denied.matchedPattern).toBe("rm -rf *");

    const allowed = checkTool(manager, "bash", { command: "echo hello" });
    expect(allowed.state).toBe("allow");
    expect(allowed.source).toBe("bash");
    expect(allowed.matchedPattern).toBe("*");
  } finally {
    cleanup();
  }
});

test("MCP wildcard matching uses the registered mcp tool", () => {
  const { manager, cleanup } = createManager({
    permission: {
      "*": "ask",
      mcp: { "*": "deny", "research_*": "ask", "research_query-*": "allow" },
    },
  });

  try {
    const queryDocs = checkTool(manager, "mcp", {
      tool: "research:query-docs",
    });
    expect(queryDocs.state).toBe("allow");
    expect(queryDocs.source).toBe("mcp");
    expect(queryDocs.matchedPattern).toBe("research_query-*");
    expect(queryDocs.target).toBe("research_query-docs");

    const resolve2 = checkTool(manager, "mcp", {
      tool: "research:resolve-context",
    });
    expect(resolve2.state).toBe("ask");
    expect(resolve2.matchedPattern).toBe("research_*");
    expect(resolve2.target).toBe("research_resolve-context");

    const unknown = checkTool(manager, "mcp", {
      tool: "search:provider",
    });
    expect(unknown.state).toBe("deny");
    expect(unknown.matchedPattern).toBe("*");
    expect(unknown.target).toBe("search_provider");
  } finally {
    cleanup();
  }
});

test("Arbitrary extension tools use exact-name tool permissions instead of MCP fallback", () => {
  const { manager, cleanup } = createManager({
    permission: {
      "*": "deny",
      third_party_tool: "allow",
      mcp: { "*": "deny" },
    },
  });

  try {
    const allowed = checkTool(manager, "third_party_tool", {});
    expect(allowed.state).toBe("allow");
    expect(allowed.source).toBe("tool");

    // another_extension_tool has no explicit rule — falls through to the
    // universal default (permission["*"] = "deny") with source "default".
    const fallback = checkTool(manager, "another_extension_tool", {});
    expect(fallback.state).toBe("deny");
    expect(fallback.source).toBe("default");
  } finally {
    cleanup();
  }
});

test("Skill permission matching", () => {
  const { manager, cleanup } = createManager({
    permission: {
      "*": "ask",
      skill: {
        "*": "ask",
        "web-*": "deny",
        "requesting-code-review": "allow",
      },
    },
  });

  try {
    const allowed = checkTool(manager, "skill", {
      name: "requesting-code-review",
    });
    expect(allowed.state).toBe("allow");
    expect(allowed.matchedPattern).toBe("requesting-code-review");
    expect(allowed.source).toBe("skill");

    const denied = checkTool(manager, "skill", {
      name: "web-design-guidelines",
    });
    expect(denied.state).toBe("deny");
    expect(denied.matchedPattern).toBe("web-*");

    const fallback = checkTool(manager, "skill", {
      name: "unknown-skill",
    });
    expect(fallback.state).toBe("ask");
    expect(fallback.matchedPattern).toBe("*");
  } finally {
    cleanup();
  }
});

test("MCP proxy tool infers server-prefixed aliases from configured server names", () => {
  const { manager, cleanup } = createManager(
    {
      permission: {
        "*": "ask",
        mcp: { "exa_*": "deny", exa_get_code_context_exa: "allow" },
      },
    },
    {},
    { mcpServerNames: ["exa"] },
  );

  try {
    const result = checkTool(manager, "mcp", {
      tool: "get_code_context_exa",
    });
    expect(result.state).toBe("allow");
    expect(result.source).toBe("mcp");
    expect(result.matchedPattern).toBe("exa_get_code_context_exa");
    expect(result.target).toBe("exa_get_code_context_exa");
  } finally {
    cleanup();
  }
});

test("MCP server names in settings.json are not used — only mcp.json is consulted", () => {
  const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
  const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
  const mcpConfigPath = join(baseDir, "mcp.json");
  const settingsJsonPath = join(baseDir, "settings.json");
  const agentsDir = join(baseDir, "agents");
  mkdirSync(agentsDir, { recursive: true });

  const config: ScopeConfig = {
    permission: { "*": "ask", mcp: { "legacy-server_*": "allow" } },
  };

  writeFileSync(
    globalConfigPath,
    `${JSON.stringify(config, null, 2)}\n`,
    "utf8",
  );
  writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), "utf8");
  writeFileSync(
    settingsJsonPath,
    JSON.stringify({ mcpServers: { "legacy-server": {} } }),
    "utf8",
  );

  const manager = new PermissionManager({
    globalConfigPath,
    agentsDir,
    globalMcpConfigPath: mcpConfigPath,
  });

  try {
    const result = checkTool(manager, "mcp", {
      tool: "some_tool_legacy-server",
    });
    expect(result.state).toBe("ask");
  } finally {
    rmSync(baseDir, { recursive: true, force: true });
  }
});

test("MCP describe mode normalizes qualified tool names without duplicating server prefixes", () => {
  const { manager, cleanup } = createManager(
    {
      permission: {
        "*": "ask",
        mcp: { "exa_*": "deny", exa_web_search_exa: "allow" },
      },
    },
    {},
    { mcpServerNames: ["exa"] },
  );

  try {
    const result = checkTool(manager, "mcp", {
      describe: "exa:web_search_exa",
      server: "exa",
    });
    expect(result.state).toBe("allow");
    expect(result.source).toBe("mcp");
    expect(result.matchedPattern).toBe("exa_web_search_exa");
    expect(result.target).toBe("exa_web_search_exa");
  } finally {
    cleanup();
  }
});

test("Canonical tools map directly without legacy aliases", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "ask", find: "allow", ls: "deny" },
  });

  try {
    const findResult = checkTool(manager, "find", {});
    expect(findResult.state).toBe("allow");
    expect(findResult.source).toBe("tool");

    const lsResult = checkTool(manager, "ls", {});
    expect(lsResult.state).toBe("deny");
    expect(lsResult.source).toBe("tool");
  } finally {
    cleanup();
  }
});

test("mcp catch-all acts as fallback for unmatched MCP targets", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "ask" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  mcp: allow
---
`,
    },
  );

  try {
    const result = checkTool(
      manager,
      "mcp",
      { tool: "exa:web_search_exa" },
      "reviewer",
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("mcp");
    expect(result.target).toBe("exa_web_search_exa");
  } finally {
    cleanup();
  }
});

test("specific MCP rules override mcp catch-all", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "ask" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  mcp:
    "*": allow
    exa_web_search_exa: deny
---
`,
    },
    { mcpServerNames: ["exa"] },
  );

  try {
    const result = checkTool(
      manager,
      "mcp",
      { tool: "web_search_exa" },
      "reviewer",
    );
    expect(result.state).toBe("deny");
    expect(result.source).toBe("mcp");
    expect(result.matchedPattern).toBe("exa_web_search_exa");
    expect(result.target).toBe("exa_web_search_exa");
  } finally {
    cleanup();
  }
});

test("specific MCP rules still win when mcp catch-all is deny", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "ask" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  mcp:
    "*": deny
    exa_web_search_exa: allow
---
`,
    },
    { mcpServerNames: ["exa"] },
  );

  try {
    const allowed = checkTool(
      manager,
      "mcp",
      { tool: "web_search_exa" },
      "reviewer",
    );
    expect(allowed.state).toBe("allow");
    expect(allowed.source).toBe("mcp");
    expect(allowed.matchedPattern).toBe("exa_web_search_exa");
    expect(allowed.target).toBe("exa_web_search_exa");

    const fallback = checkTool(
      manager,
      "mcp",
      { tool: "other_exa" },
      "reviewer",
    );
    expect(fallback.state).toBe("deny");
    expect(fallback.source).toBe("mcp");
    expect(fallback.target).toBe("exa_other_exa");
  } finally {
    cleanup();
  }
});

test("mcp catch-all in agent frontmatter overrides global default", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "deny" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  mcp: allow
---
`,
    },
  );

  try {
    const readResult = checkTool(manager, "read", {}, "reviewer");
    expect(readResult.state).toBe("deny");
    expect(readResult.source).toBe("tool");

    const mcpResult = checkTool(
      manager,
      "mcp",
      { tool: "exa:web_search_exa" },
      "reviewer",
    );
    expect(mcpResult.state).toBe("allow");
    expect(mcpResult.source).toBe("mcp");
  } finally {
    cleanup();
  }
});

test("Agent frontmatter canonical tools resolve correctly", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "deny" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  find: allow
  ls: deny
---
`,
    },
  );

  try {
    const findResult = checkTool(manager, "find", {}, "reviewer");
    expect(findResult.state).toBe("allow");
    expect(findResult.source).toBe("tool");

    const lsResult = checkTool(manager, "ls", {}, "reviewer");
    expect(lsResult.state).toBe("deny");
    expect(lsResult.source).toBe("tool");
  } finally {
    cleanup();
  }
});

test("All surface names work in agent frontmatter flat permission format", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "deny" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  find: allow
  task: allow
  mcp: allow
---
`,
    },
  );

  try {
    const findResult = checkTool(manager, "find", {}, "reviewer");
    expect(findResult.state).toBe("allow");
    expect(findResult.source).toBe("tool");

    const taskResult = checkTool(manager, "task", {}, "reviewer");
    expect(taskResult.state).toBe("allow");
    expect(taskResult.source).toBe("tool");

    const mcpResult = checkTool(
      manager,
      "mcp",
      { tool: "exa:web_search_exa" },
      "reviewer",
    );
    expect(mcpResult.state).toBe("allow");
  } finally {
    cleanup();
  }
});

test("task uses exact-name tool permissions like any registered extension tool", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "deny", task: "allow" },
  });

  try {
    const taskResult = checkTool(manager, "task", {});
    expect(taskResult.state).toBe("allow");
    expect(taskResult.source).toBe("tool");
  } finally {
    cleanup();
  }
});

test("getToolPermission returns tool-level policy for canonical and extension tools", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "ask" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  bash: deny
  read: deny
  task: allow
---
`,
    },
  );

  try {
    const bashPermission = manager.getToolPermission("bash", "reviewer");
    expect(bashPermission).toBe("deny");

    const taskPermission = manager.getToolPermission("task", "reviewer");
    expect(taskPermission).toBe("allow");

    const readPermission = manager.getToolPermission("read", "reviewer");
    expect(readPermission).toBe("deny");

    const defaultBashPermission = manager.getToolPermission("bash");
    expect(defaultBashPermission).toBe("ask");

    const { manager: manager2, cleanup: cleanup2 } = createManager({
      permission: { "*": "deny", bash: "allow" },
    });

    try {
      const globalBashPermission = manager2.getToolPermission("bash");
      expect(globalBashPermission).toBe("allow");
    } finally {
      cleanup2();
    }
  } finally {
    cleanup();
  }
});

test("getToolPermission supports arbitrary extension tool names", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "deny", third_party_tool: "allow" },
  });

  try {
    const explicitPermission = manager.getToolPermission("third_party_tool");
    expect(explicitPermission).toBe("allow");

    const fallbackPermission = manager.getToolPermission(
      "missing_extension_tool",
    );
    expect(fallbackPermission).toBe("deny");
  } finally {
    cleanup();
  }
});

// ---------------------------------------------------------------------------
// external_directory config resolution and pattern maps — moved from catch-all (#342)
// ---------------------------------------------------------------------------

test("external_directory permission falls back to universal default when not explicitly configured", () => {
  const { manager, cleanup } = createManager({ permission: {} });

  try {
    const result = checkTool(manager, "external_directory", {});
    expect(result.state).toBe("ask");
    expect(result.source).toBe("special");
    expect(result.matchedPattern).toBe(undefined);
  } finally {
    cleanup();
  }
});

test("external_directory permission respects explicit deny", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "allow", external_directory: "deny" },
  });

  try {
    const result = checkTool(manager, "external_directory", {});
    expect(result.state).toBe("deny");
    expect(result.source).toBe("special");
    expect(result.matchedPattern).toBe("*");
  } finally {
    cleanup();
  }
});

test("external_directory permission can be explicitly allowed", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "allow", external_directory: "allow" },
  });

  try {
    const result = checkTool(manager, "external_directory", {});
    expect(result.state).toBe("allow");
    expect(result.source).toBe("special");
    expect(result.matchedPattern).toBe("*");
  } finally {
    cleanup();
  }
});

test("external_directory permission respects per-agent override", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "allow", external_directory: "deny" },
    },
    {
      trusted: `---
name: trusted
permission:
  external_directory: allow
---
`,
    },
  );

  try {
    const globalResult = checkTool(manager, "external_directory", {});
    expect(globalResult.state).toBe("deny");

    const agentResult = checkTool(manager, "external_directory", {}, "trusted");
    expect(agentResult.state).toBe("allow");
    expect(agentResult.source).toBe("special");
  } finally {
    cleanup();
  }
});

test("external_directory permission is not affected by unrelated surface keys", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "allow", external_directory: "allow" },
  });

  try {
    const extResult = checkTool(manager, "external_directory", {});
    expect(extResult.state).toBe("allow");
    expect(extResult.matchedPattern).toBe("*");
  } finally {
    cleanup();
  }
});

test("skill pattern map in agent frontmatter overrides global skill policy", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "deny", skill: "deny" },
    },
    {
      reviewer: `---
name: reviewer
permission:
  skill:
    "*": ask
    "pi-*": allow
---
`,
    },
  );

  try {
    const allowed = checkTool(
      manager,
      "skill",
      { name: "pi-code-review" },
      "reviewer",
    );
    expect(allowed.state).toBe("allow");
    expect(allowed.matchedPattern).toBe("pi-*");
    expect(allowed.source).toBe("skill");

    const asked = checkTool(
      manager,
      "skill",
      { name: "other-skill" },
      "reviewer",
    );
    expect(asked.state).toBe("ask");
    expect(asked.matchedPattern).toBe("*");

    const denied = checkTool(manager, "skill", { name: "pi-code-review" });
    expect(denied.state).toBe("deny");
    expect(denied.source).toBe("skill");
  } finally {
    cleanup();
  }
});

test("external_directory pattern map in agent frontmatter overrides global policy", () => {
  const { manager, cleanup } = createManager(
    {
      permission: { "*": "allow", external_directory: "deny" },
    },
    {
      trusted: `---
name: trusted
permission:
  external_directory:
    "*": deny
    "~/Downloads/*": allow
---
`,
    },
  );

  try {
    const allowed = checkPath(
      manager,
      `${homedir()}/Downloads/file.txt`,
      {},
      "external_directory",
      "trusted",
    );
    expect(allowed.state).toBe("allow");
    expect(allowed.matchedPattern).toBe("~/Downloads/*");
    expect(allowed.source).toBe("special");

    const denied = checkPath(
      manager,
      `${homedir()}/Documents/secret.txt`,
      {},
      "external_directory",
      "trusted",
    );
    expect(denied.state).toBe("deny");
    expect(denied.matchedPattern).toBe("*");

    const globalDenied = checkTool(manager, "external_directory", {});
    expect(globalDenied.state).toBe("deny");
    expect(globalDenied.source).toBe("special");
  } finally {
    cleanup();
  }
});

test("project-agent frontmatter skill rules override global-agent frontmatter skill rules", () => {
  const { manager, cleanup } = createManagerWithProject(
    {
      permission: { "*": "deny" },
    },
    {
      analyst: `---
name: analyst
permission:
  skill:
    "*": ask
---
`,
    },
    {
      projectAgentFiles: {
        analyst: `---
name: analyst
permission:
  skill:
    "pi-*": allow
    "*": deny
---
`,
      },
    },
  );

  try {
    const allowed = checkTool(
      manager,
      "skill",
      { name: "pi-code-review" },
      "analyst",
    );
    expect(allowed.state).toBe("allow");
    expect(allowed.matchedPattern).toBe("pi-*");

    const denied = checkTool(
      manager,
      "skill",
      { name: "other-skill" },
      "analyst",
    );
    expect(denied.state).toBe("deny");
    expect(denied.matchedPattern).toBe("*");
  } finally {
    cleanup();
  }
});

test("project-agent frontmatter external_directory rules override global-agent frontmatter rules", () => {
  const { manager, cleanup } = createManagerWithProject(
    {
      permission: { "*": "allow", external_directory: "deny" },
    },
    {
      analyst: `---
name: analyst
permission:
  external_directory: ask
---
`,
    },
    {
      projectAgentFiles: {
        analyst: `---
name: analyst
permission:
  external_directory: allow
---
`,
      },
    },
  );

  try {
    const result = checkTool(manager, "external_directory", {}, "analyst");
    expect(result.state).toBe("allow");
    expect(result.source).toBe("special");

    const globalResult = checkTool(manager, "external_directory", {});
    expect(globalResult.state).toBe("deny");
  } finally {
    cleanup();
  }
});

// ---------------------------------------------------------------------------
// PI_CODING_AGENT_DIR support — moved from catch-all (#342)
// ---------------------------------------------------------------------------

test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
  const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-"));
  const agentsDir = join(baseDir, "agents");
  const newConfigPath = getGlobalConfigPath(baseDir);
  mkdirSync(agentsDir, { recursive: true });
  mkdirSync(dirname(newConfigPath), { recursive: true });

  const config: ScopeConfig = {
    permission: { "*": "deny", read: "allow" },
  };
  writeFileSync(newConfigPath, JSON.stringify(config), "utf8");

  const original = process.env.PI_CODING_AGENT_DIR;
  process.env.PI_CODING_AGENT_DIR = baseDir;
  try {
    const manager = new PermissionManager();
    const result = checkTool(manager, "read", {});
    expect(result.state).toBe("allow");

    const result2 = checkTool(manager, "write", {});
    expect(result2.state).toBe("deny");
  } finally {
    if (original !== undefined) {
      process.env.PI_CODING_AGENT_DIR = original;
    } else {
      delete process.env.PI_CODING_AGENT_DIR;
    }
    rmSync(baseDir, { recursive: true, force: true });
  }
});

// ---------------------------------------------------------------------------
// getConfigIssues — moved from catch-all (#342)
// ---------------------------------------------------------------------------

test("PermissionManager.getConfigIssues returns empty array for clean config", () => {
  const config: ScopeConfig = {
    permission: { "*": "ask", external_directory: "ask" },
  };
  const { manager, cleanup } = createManager(config);
  try {
    const issues = manager.getConfigIssues();
    expect(issues.length).toBe(0);
  } finally {
    cleanup();
  }
});

test("PermissionManager.getConfigIssues returns empty array for empty config", () => {
  const { manager, cleanup } = createManager({});
  try {
    const issues = manager.getConfigIssues();
    expect(issues.length).toBe(0);
  } finally {
    cleanup();
  }
});

// ---------------------------------------------------------------------------
// Session-aware checkPermission() — moved from catch-all (#342)
// ---------------------------------------------------------------------------

test("checkPermission returns source 'session' when session rules cover the external_directory path", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "allow" },
  });

  try {
    const sessionRules = [
      sessionRule("external_directory", "/other/project/*"),
    ];

    const result = checkPath(
      manager,
      "/other/project/src/foo.ts",
      {},
      "external_directory",
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
    expect(result.matchedPattern).toBe("/other/project/*");
  } finally {
    cleanup();
  }
});

test("checkPermission falls back to config policy when session rules do not cover the path", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "allow", external_directory: "deny" },
  });

  try {
    const sessionRules = [
      sessionRule("external_directory", "/other/project/*"),
    ];

    const result = checkTool(
      manager,
      "external_directory",
      { path: "/completely/different/path.ts" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("deny");
    expect(result.source).toBe("special");
  } finally {
    cleanup();
  }
});

test("checkPermission with empty session rules is identical to call without sessionRules arg", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "allow", external_directory: "deny" },
  });

  try {
    const withEmpty = checkPath(
      manager,
      "/other/project/foo.ts",
      {},
      "external_directory",
      undefined,
      [],
    );
    const withoutArg = checkPath(
      manager,
      "/other/project/foo.ts",
      {},
      "external_directory",
    );
    const expected: PermissionCheckResult = {
      toolName: "external_directory",
      state: "deny",
      matchedPattern: "*",
      source: "special",
      origin: "global",
    };
    expect(withEmpty).toEqual(expected);
    expect(withoutArg).toEqual(expected);
  } finally {
    cleanup();
  }
});

test("session rules for one surface do not affect checks on other surfaces", () => {
  const { manager, cleanup } = createManager({ permission: {} });

  try {
    const sessionRules = [
      sessionRule("external_directory", "/other/project/*"),
    ];

    const bashResult = checkTool(
      manager,
      "bash",
      { command: "git status" },
      undefined,
      sessionRules,
    );
    expect(bashResult.state).toBe("ask");
    expect(bashResult.source).toBe("bash");

    const mcpResult = checkTool(
      manager,
      "mcp",
      { tool: "exa:search" },
      undefined,
      sessionRules,
    );
    expect(mcpResult.state).toBe("ask");
    expect(mcpResult.source).toBe("default");
  } finally {
    cleanup();
  }
});

test("session rules override config deny for external_directory", () => {
  const { manager, cleanup } = createManager({
    permission: { "*": "allow", external_directory: "deny" },
  });

  try {
    const sessionRules = [
      sessionRule("external_directory", "/other/project/*"),
    ];

    const result = checkPath(
      manager,
      "/other/project/src/foo.ts",
      {},
      "external_directory",
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
  } finally {
    cleanup();
  }
});

test("checkPermission returns source 'session' for bash when session rules match", () => {
  const { manager, cleanup } = createManager({ permission: {} });

  try {
    const sessionRules = [sessionRule("bash", "git *")];

    const result = checkTool(
      manager,
      "bash",
      { command: "git status --short" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
    expect(result.matchedPattern).toBe("git *");
  } finally {
    cleanup();
  }
});

test("checkPermission returns source 'session' for bash when session rule is exact match", () => {
  const { manager, cleanup } = createManager({ permission: {} });

  try {
    const sessionRules = [sessionRule("bash", "ls")];

    const result = checkTool(
      manager,
      "bash",
      { command: "ls" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
  } finally {
    cleanup();
  }
});

test("checkPermission falls back to config for bash when session rules do not match the command", () => {
  const { manager, cleanup } = createManager({ permission: { bash: "deny" } });

  try {
    const sessionRules = [sessionRule("bash", "git *")];

    const result = checkTool(
      manager,
      "bash",
      { command: "npm run build" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("deny");
    expect(result.source).toBe("bash");
  } finally {
    cleanup();
  }
});

test("checkPermission returns source 'session' for mcp when session rules match the target", () => {
  const { manager, cleanup } = createManager({ permission: {} });

  try {
    const sessionRules = [sessionRule("mcp", "exa:*")];

    const result = checkTool(
      manager,
      "mcp",
      { tool: "exa:search" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
  } finally {
    cleanup();
  }
});

test("checkPermission returns source 'session' for skill when session rules match", () => {
  const { manager, cleanup } = createManager({ permission: {} });

  try {
    const sessionRules = [sessionRule("skill", "librarian")];

    const result = checkTool(
      manager,
      "skill",
      { name: "librarian" },
      undefined,
      sessionRules,
    );
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
    expect(result.matchedPattern).toBe("librarian");
  } finally {
    cleanup();
  }
});

test("checkPermission returns source 'session' for tool surface when session rules match", () => {
  const { manager, cleanup } = createManager({ permission: {} });

  try {
    const sessionRules = [sessionRule("read", "*")];

    const result = checkTool(manager, "read", {}, undefined, sessionRules);
    expect(result.state).toBe("allow");
    expect(result.source).toBe("session");
  } finally {
    cleanup();
  }
});

test("bash session rules do not bleed into mcp checks", () => {
  const { manager, cleanup } = createManager({ permission: {} });

  try {
    const sessionRules = [sessionRule("bash", "git *")];

    const result = checkTool(
      manager,
      "mcp",
      { tool: "exa:search" },
      undefined,
      sessionRules,
    );
    expect(result.source).not.toBe("session");
  } finally {
    cleanup();
  }
});

// ---------------------------------------------------------------------------
// getResolvedPolicyPaths — moved from catch-all (#342)
// ---------------------------------------------------------------------------

test("getResolvedPolicyPaths returns correct paths and existence when files exist", () => {
  const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-exist-"));
  try {
    const globalConfigPath = join(tempDir, "pi-permissions.jsonc");
    const agentsDir = join(tempDir, "agents");
    const projectConfigPath = join(tempDir, "project", "pi-permissions.jsonc");
    const projectAgentsDir = join(tempDir, "project", "agents");

    writeFileSync(globalConfigPath, "{}", "utf-8");
    mkdirSync(agentsDir, { recursive: true });
    mkdirSync(join(tempDir, "project"), { recursive: true });
    writeFileSync(projectConfigPath, "{}", "utf-8");
    mkdirSync(projectAgentsDir, { recursive: true });

    const pm = new PermissionManager({
      globalConfigPath,
      agentsDir,
      projectGlobalConfigPath: projectConfigPath,
      projectAgentsDir,
    });

    const result = pm.getResolvedPolicyPaths();

    expect(result.globalConfigPath).toBe(globalConfigPath);
    expect(result.globalConfigExists).toBe(true);
    expect(result.projectConfigPath).toBe(projectConfigPath);
    expect(result.projectConfigExists).toBe(true);
    expect(result.agentsDir).toBe(agentsDir);
    expect(result.agentsDirExists).toBe(true);
    expect(result.projectAgentsDir).toBe(projectAgentsDir);
    expect(result.projectAgentsDirExists).toBe(true);
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

test("getResolvedPolicyPaths returns false for missing files and null for absent project paths", () => {
  const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-missing-"));
  try {
    const globalConfigPath = join(tempDir, "does-not-exist.jsonc");
    const agentsDir = join(tempDir, "no-agents");

    const pm = new PermissionManager({
      globalConfigPath,
      agentsDir,
    });

    const result = pm.getResolvedPolicyPaths();

    expect(result.globalConfigPath).toBe(globalConfigPath);
    expect(result.globalConfigExists).toBe(false);
    expect(result.projectConfigPath).toBe(null);
    expect(result.projectConfigExists).toBe(false);
    expect(result.agentsDir).toBe(agentsDir);
    expect(result.agentsDirExists).toBe(false);
    expect(result.projectAgentsDir).toBe(null);
    expect(result.projectAgentsDirExists).toBe(false);
  } finally {
    rmSync(tempDir, { recursive: true, force: true });
  }
});

describe("checkPermission — cwd-aware path policy values", () => {
  const cwd = "/workspace/project";

  it("matches a relative read input against an absolute allowlist", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: { "*": "ask", [`${cwd}/*`]: "allow" },
    });
    try {
      manager.configureForCwd(cwd);
      const result = checkPath(manager, "src/App.jsx", { cwd }, "read");
      expect(result.state).toBe("allow");
      expect(result.matchedPattern).toBe(`${cwd}/*`);
    } finally {
      cleanup();
    }
  });

  it("keeps legacy relative path rules working after configureForCwd", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: { "*": "allow", "src/*": "deny" },
    });
    try {
      manager.configureForCwd(cwd);
      const result = checkPath(manager, "src/App.jsx", { cwd }, "read");
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("src/*");
    } finally {
      cleanup();
    }
  });

  it("preserves last-match-wins across absolute and relative aliases", () => {
    const { manager, cleanup } = createManagerWithConfig({
      read: {
        "*": "ask",
        [`${cwd}/*`]: "allow",
        "src/*": "deny",
      },
    });
    try {
      manager.configureForCwd(cwd);
      const result = checkPath(manager, "src/App.jsx", { cwd }, "read");
      // The later "src/*" deny wins over the earlier absolute allow.
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("src/*");
    } finally {
      cleanup();
    }
  });

  it("matches the cross-cutting path surface against absolute allowlists", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "ask", [`${cwd}/*`]: "allow" },
    });
    try {
      manager.configureForCwd(cwd);
      const result = checkPath(manager, "src/App.jsx", { cwd });
      expect(result.state).toBe("allow");
      expect(result.matchedPattern).toBe(`${cwd}/*`);
    } finally {
      cleanup();
    }
  });
});

describe("checkPathPolicy", () => {
  const cwd = "/workspace/project";

  it("evaluates precomputed policy values against the path surface", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "ask", [`${cwd}/*`]: "allow" },
    });
    try {
      const result = checkPathValues(manager, [
        `${cwd}/src/App.jsx`,
        "src/App.jsx",
      ]);
      expect(result.state).toBe("allow");
      expect(result.matchedPattern).toBe(`${cwd}/*`);
      expect(result.source).toBe("special");
      expect(result.toolName).toBe("path");
    } finally {
      cleanup();
    }
  });

  it("preserves last-match-wins across the provided aliases", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" },
    });
    try {
      const result = checkPathValues(manager, [
        `${cwd}/src/App.jsx`,
        "src/App.jsx",
      ]);
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("src/*");
    } finally {
      cleanup();
    }
  });

  it("applies session rules over config", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "ask", "src/*": "deny" },
    });
    try {
      const sessionRules: Ruleset = [sessionRule("path", "src/*")];
      const result = checkPathValues(
        manager,
        ["src/App.jsx"],
        undefined,
        sessionRules,
      );
      expect(result.state).toBe("allow");
      expect(result.source).toBe("session");
    } finally {
      cleanup();
    }
  });

  it("falls back to the catch-all for an empty value list", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "deny" },
    });
    try {
      const result = checkPathValues(manager, []);
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("*");
    } finally {
      cleanup();
    }
  });

  it("evaluates against the external_directory surface when one is provided", () => {
    const { manager, cleanup } = createManagerWithConfig({
      external_directory: { "*": "ask", "/tmp/*": "allow" },
    });
    try {
      const result = checkPathValues(
        manager,
        ["/tmp/x"],
        undefined,
        undefined,
        "external_directory",
      );
      expect(result.state).toBe("allow");
      expect(result.matchedPattern).toBe("/tmp/*");
      expect(result.source).toBe("special");
      expect(result.toolName).toBe("external_directory");
    } finally {
      cleanup();
    }
  });

  it("defaults to the path surface when no surface is provided", () => {
    const { manager, cleanup } = createManagerWithConfig({
      external_directory: { "*": "ask", "/tmp/*": "allow" },
      path: { "*": "allow" },
    });
    try {
      // No path rule denies; the external_directory allow must NOT apply here.
      const result = checkPathValues(manager, ["/tmp/x"]);
      expect(result.toolName).toBe("path");
      expect(result.state).toBe("allow");
      expect(result.matchedPattern).toBe("*");
    } finally {
      cleanup();
    }
  });
});

// ---------------------------------------------------------------------------
// check(intent) — unified entry point (Step 1 of #478)
// ---------------------------------------------------------------------------

describe("check — tool intent", () => {
  it("resolves a tool call on the bash surface", () => {
    const { manager, cleanup } = createManagerWithConfig({
      bash: { "*": "allow", "git push": "deny" },
    });
    try {
      const intent: ResolvedAccessIntent = {
        kind: "tool",
        surface: "bash",
        input: { command: "git push" },
      };
      const result = manager.check(intent);
      expect(result.state).toBe("deny");
      expect(result.toolName).toBe("bash");
      expect(result.source).toBe("bash");
    } finally {
      cleanup();
    }
  });

  it("resolves a tool call on the read surface", () => {
    const { manager, cleanup } = createManagerWithConfig({ read: "deny" });
    try {
      const intent: ResolvedAccessIntent = {
        kind: "tool",
        surface: "read",
        input: { path: "/some/file.txt" },
      };
      const result = manager.check(intent);
      expect(result.state).toBe("deny");
      expect(result.toolName).toBe("read");
      expect(result.source).toBe("tool");
    } finally {
      cleanup();
    }
  });

  it("applies session rules via the tool intent", () => {
    const { manager, cleanup } = createManagerWithConfig({
      bash: { "*": "deny" },
    });
    try {
      const sessionRules: Ruleset = [sessionRule("bash", "echo *")];
      const intent: ResolvedAccessIntent = {
        kind: "tool",
        surface: "bash",
        input: { command: "echo hello" },
      };
      const result = manager.check(intent, sessionRules);
      expect(result.state).toBe("allow");
      expect(result.source).toBe("session");
    } finally {
      cleanup();
    }
  });

  it("threads agentName through the tool intent", () => {
    const { manager, cleanup } = createManager(
      { permission: { bash: "deny" } },
      {
        "agent-a": `---\nname: agent-a\npermission:\n  bash: allow\n---\n`,
      },
    );
    try {
      const intent: ResolvedAccessIntent = {
        kind: "tool",
        surface: "bash",
        input: { command: "echo hi" },
        agentName: "agent-a",
      };
      const result = manager.check(intent);
      expect(result.state).toBe("allow");
    } finally {
      cleanup();
    }
  });
});

describe("check — path-values intent", () => {
  const cwd = "/workspace/project";

  it("evaluates precomputed policy values against the path surface", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "ask", [`${cwd}/*`]: "allow" },
    });
    try {
      const intent: ResolvedAccessIntent = {
        kind: "path-values",
        surface: "path",
        values: [`${cwd}/src/App.jsx`, "src/App.jsx"],
      };
      const result = manager.check(intent);
      expect(result.state).toBe("allow");
      expect(result.matchedPattern).toBe(`${cwd}/*`);
      expect(result.source).toBe("special");
      expect(result.toolName).toBe("path");
    } finally {
      cleanup();
    }
  });

  it("evaluates against the external_directory surface", () => {
    const { manager, cleanup } = createManagerWithConfig({
      external_directory: { "*": "ask", "/tmp/*": "allow" },
    });
    try {
      const intent: ResolvedAccessIntent = {
        kind: "path-values",
        surface: "external_directory",
        values: ["/tmp/x"],
      };
      const result = manager.check(intent);
      expect(result.state).toBe("allow");
      expect(result.matchedPattern).toBe("/tmp/*");
      expect(result.source).toBe("special");
      expect(result.toolName).toBe("external_directory");
    } finally {
      cleanup();
    }
  });

  it("falls back to the catch-all for an empty value list", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "deny" },
    });
    try {
      const intent: ResolvedAccessIntent = {
        kind: "path-values",
        surface: "path",
        values: [],
      };
      const result = manager.check(intent);
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("*");
    } finally {
      cleanup();
    }
  });

  it("applies session rules via the path-values intent", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "ask", "src/*": "deny" },
    });
    try {
      const sessionRules: Ruleset = [sessionRule("path", "src/*")];
      const intent: ResolvedAccessIntent = {
        kind: "path-values",
        surface: "path",
        values: ["src/App.jsx"],
      };
      const result = manager.check(intent, sessionRules);
      expect(result.state).toBe("allow");
      expect(result.source).toBe("session");
    } finally {
      cleanup();
    }
  });

  it("last-match-wins across the provided aliases", () => {
    const { manager, cleanup } = createManagerWithConfig({
      path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" },
    });
    try {
      const intent: ResolvedAccessIntent = {
        kind: "path-values",
        surface: "path",
        values: [`${cwd}/src/App.jsx`, "src/App.jsx"],
      };
      const result = manager.check(intent);
      expect(result.state).toBe("deny");
      expect(result.matchedPattern).toBe("src/*");
    } finally {
      cleanup();
    }
  });
});
