diff --git a/docs/adr/0001-provider-selection-and-analysis-profiles.md b/docs/adr/0001-provider-selection-and-analysis-profiles.md index f542eea189..8d052de47c 100644 --- a/docs/adr/0001-provider-selection-and-analysis-profiles.md +++ b/docs/adr/0001-provider-selection-and-analysis-profiles.md @@ -148,18 +148,20 @@ Candidate output is sorted by provider ID for determinism. Sort order is not selection priority. Availability and support use stable codes plus a human diagnostic: -| Code | Meaning | -| --------------------------- | -------------------------------------------------------------- | -| `not_configured` | Required provider location or configuration is absent | -| `executable_missing` | The configured provider entry point is missing or not runnable | -| `runtime_missing` | A required provider runtime, such as Java, is unavailable | -| `unsupported_host` | The host platform or architecture is unsupported | -| `unsupported_version` | The discovered provider or runtime version is unsupported | -| `target_kind_unsupported` | The provider does not accept this target kind | -| `target_format_unsupported` | The provider does not accept this target format | -| `architecture_unsupported` | The selected target architecture is unsupported | -| `open_options_invalid` | The selected adapter rejected its open options | -| `version_unresolved` | A concrete analysis-engine version could not be committed | +| Code | Meaning | +| ---------------------------- | --------------------------------------------------------------- | +| `not_configured` | Required provider location or configuration is absent | +| `executable_missing` | The configured provider entry point is missing or not runnable | +| `runtime_missing` | A required provider runtime, such as Java, is unavailable | +| `unsupported_host` | The host platform or architecture is unsupported | +| `unsupported_version` | The discovered provider or runtime version is unsupported | +| `target_kind_unsupported` | The provider does not accept this target kind | +| `target_format_unsupported` | The provider does not accept this target format | +| `architecture_unsupported` | The selected target architecture is unsupported | +| `target_role_unsupported` | The provider rejects this executable image role | +| `managed_target_unsupported` | The provider does not admit managed code through this deep path | +| `open_options_invalid` | The selected adapter rejected its open options | +| `version_unresolved` | A concrete analysis-engine version could not be committed | Diagnostics retain useful local paths, observed versions, architectures, and mismatch details. Credentials, authorization headers, license secrets, and diff --git a/package.json b/package.json index 34aceb321f..6d312c5ef5 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "build:fixtures": "node scripts/build-conformance-fixtures.mjs", "verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs", "verify:package": "npm run build && node scripts/verify-package.mjs", + "probe:windows-capabilities": "npm run build && node scripts/windows-capabilities.mjs", "verify:published": "node scripts/verify-published-package.mjs", "config:print": "node scripts/print-mcp-config.mjs", "docs:generate": "npm run build && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && typedoc && node scripts/generate-error-schema.mjs", diff --git a/scripts/windows-capabilities.mjs b/scripts/windows-capabilities.mjs new file mode 100644 index 0000000000..bba22a3298 --- /dev/null +++ b/scripts/windows-capabilities.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +import { probeWindowsCapabilities } from "../dist/application/WindowsCapabilities.js"; + +const report = await probeWindowsCapabilities(); +await new Promise((resolveOutput) => + process.stdout.write(`${JSON.stringify(report)}\n`, resolveOutput), +); + +// node-pty can retain a Windows helper handle after its probe exits. This +// standalone command has no remaining cleanup after its report is flushed. +process.exit(0); diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index e5a49527eb..b1e2b10fe4 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -175,7 +175,9 @@ export type ProviderTargetSupport = readonly code: | "target_kind_unsupported" | "target_format_unsupported" - | "architecture_unsupported"; + | "architecture_unsupported" + | "target_role_unsupported" + | "managed_target_unsupported"; readonly reason: string; readonly diagnostics: Readonly>; }; diff --git a/src/application/WindowsCapabilities.ts b/src/application/WindowsCapabilities.ts new file mode 100644 index 0000000000..6a830c8ff4 --- /dev/null +++ b/src/application/WindowsCapabilities.ts @@ -0,0 +1,111 @@ +import { constants } from "node:fs"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + probeProcessCaptureCapability, + type ProcessCaptureCapability, +} from "./ProcessCaptureCapability.js"; + +/** One named Windows host observation without inferred authority. */ +export interface WindowsCapabilityOutcome { + readonly available: boolean; + readonly reason: string | null; +} + +/** Injectable host probes used by CI and deterministic unit tests. */ +export interface WindowsCapabilityDependencies { + readonly platform: NodeJS.Platform; + readonly architecture: NodeJS.Architecture; + probeSymlinkCreation(): Promise; + probeNoFollowOpen(): WindowsCapabilityOutcome; + probePrivateAcl(): WindowsCapabilityOutcome; + probeUnixDomainSocket(): WindowsCapabilityOutcome; + probePty(): Promise; +} + +/** Machine-readable facts for features that need Windows-specific security. */ +export interface WindowsCapabilityReport { + readonly platform: NodeJS.Platform; + readonly architecture: NodeJS.Architecture; + readonly capabilities: Readonly<{ + symlink_creation: WindowsCapabilityOutcome; + no_follow_open: WindowsCapabilityOutcome; + private_acl: WindowsCapabilityOutcome; + unix_domain_socket: WindowsCapabilityOutcome; + pty: WindowsCapabilityOutcome; + }>; +} + +/** Probe named prerequisites without converting missing controls into support. */ +export const probeWindowsCapabilities = async ( + dependencies: WindowsCapabilityDependencies = systemDependencies(), +): Promise => { + const [symlinkCreation, pty] = await Promise.all([ + dependencies.probeSymlinkCreation(), + dependencies.probePty(), + ]); + return { + platform: dependencies.platform, + architecture: dependencies.architecture, + capabilities: { + symlink_creation: symlinkCreation, + no_follow_open: dependencies.probeNoFollowOpen(), + private_acl: dependencies.probePrivateAcl(), + unix_domain_socket: dependencies.probeUnixDomainSocket(), + pty: { + available: pty.available, + reason: pty.available ? null : pty.reason, + }, + }, + }; +}; + +const systemDependencies = (): WindowsCapabilityDependencies => ({ + platform: process.platform, + architecture: process.arch, + probeSymlinkCreation, + probeNoFollowOpen: () => + typeof constants.O_NOFOLLOW === "number" + ? { available: true, reason: null } + : { + available: false, + reason: "O_NOFOLLOW is unavailable in this Node runtime", + }, + probePrivateAcl: () => ({ + available: false, + reason: "Windows ACL enforcement is not implemented by this REA build", + }), + probeUnixDomainSocket: () => + process.platform === "win32" + ? { + available: false, + reason: "Node path-based IPC uses named pipes on Windows", + } + : { available: true, reason: null }, + probePty: probeProcessCaptureCapability, +}); + +const probeSymlinkCreation = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), "rea-symlink-probe-")); + const target = join(root, "target"); + const link = join(root, "link"); + try { + await writeFile(target, "probe"); + await symlink(target, link, "file"); + return { available: true, reason: null }; + } catch (cause: unknown) { + return { + available: false, + reason: + cause instanceof Error && + "code" in cause && + typeof cause.code === "string" + ? cause.code + : "symlink creation failed", + }; + } finally { + await rm(root, { recursive: true, force: true }); + } +}; diff --git a/src/contracts/providerSelection.ts b/src/contracts/providerSelection.ts index a21e172af8..9d281f3986 100644 --- a/src/contracts/providerSelection.ts +++ b/src/contracts/providerSelection.ts @@ -26,6 +26,8 @@ export const PROVIDER_REJECTION_CODES = [ "target_kind_unsupported", "target_format_unsupported", "architecture_unsupported", + "target_role_unsupported", + "managed_target_unsupported", "open_options_invalid", "version_unresolved", ] as const; diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts index 89ca82c9b3..32f41a37f3 100644 --- a/src/contracts/toolOutputSchemas.ts +++ b/src/contracts/toolOutputSchemas.ts @@ -155,6 +155,8 @@ const providerTargetSupport = z.discriminatedUnion("status", [ "target_kind_unsupported", "target_format_unsupported", "architecture_unsupported", + "target_role_unsupported", + "managed_target_unsupported", ]), reason: z.string().min(1), diagnostics: providerDiagnostics, diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 5bc699391f..cc3fb3c6fa 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -237,7 +237,7 @@ export type GhidraProviderClientFactory = ( options: GhidraClientOptions, ) => Pick; -/** Linux Ghidra candidate backed by an isolated read-only headless import. */ +/** Ghidra candidate backed by an isolated read-only headless import. */ export class GhidraProvider implements AnalysisProviderCandidate { #installation: GhidraInstallationInspection | undefined; @@ -277,10 +277,14 @@ export class GhidraProvider implements AnalysisProviderCandidate { } inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport { + const hostPlatform = this.installationHost?.platform ?? process.platform; const diagnostics = { + host_platform: hostPlatform, target_kind: target.kind, target_format: target.format, architecture: target.architecture ?? null, + executable_role: target.executableRole ?? null, + managed: target.managed ?? null, }; if (target.kind !== "executable") return { @@ -289,6 +293,8 @@ export class GhidraProvider implements AnalysisProviderCandidate { reason: `Ghidra v1 imports executable targets, not ${target.kind} targets.`, diagnostics, }; + if (hostPlatform === "win32") + return inspectWindowsP0TargetSupport(target, diagnostics); if (!SUPPORTED_FORMATS.has(target.format)) return { status: "unsupported", @@ -430,6 +436,48 @@ export class GhidraProvider implements AnalysisProviderCandidate { } } +const inspectWindowsP0TargetSupport = ( + target: BinaryTarget, + diagnostics: Readonly>, +): ProviderTargetSupport => { + if (target.format !== "pe") + return { + status: "unsupported", + code: "target_format_unsupported", + reason: "Windows Ghidra P0 accepts PE targets only.", + diagnostics, + }; + if (target.architecture !== "x86_64") + return { + status: "unsupported", + code: "architecture_unsupported", + reason: "Windows Ghidra P0 accepts x86-64 PE targets only.", + diagnostics, + }; + if (target.executableRole !== "application") + return { + status: "unsupported", + code: "target_role_unsupported", + reason: + "Windows Ghidra P0 accepts PE applications, not DLL or non-executable images.", + diagnostics, + }; + if (target.managed !== false) + return { + status: "unsupported", + code: "managed_target_unsupported", + reason: + "Windows Ghidra P0 accepts native PE applications; managed or unclassified PE targets are unsupported.", + diagnostics, + }; + return { + status: "supported", + code: null, + reason: null, + diagnostics, + }; +}; + interface GhidraClientCoordinates { readonly analyzeHeadlessPath: string; readonly providerVersion: string; diff --git a/tests/ghidraProvider.test.ts b/tests/ghidraProvider.test.ts index 1b32487bf1..4ad5c9409d 100644 --- a/tests/ghidraProvider.test.ts +++ b/tests/ghidraProvider.test.ts @@ -128,6 +128,54 @@ describe("Ghidra provider", () => { }); }); + it("admits only native x86-64 PE applications on Windows P0", () => { + const ghidra = provider({ ...installationHost(), platform: "win32" }); + const nativeApplication: BinaryTarget = { + ...executableTarget("pe", "x86_64"), + executableRole: "application", + managed: false, + }; + + expect(ghidra.inspectTargetSupport(nativeApplication)).toMatchObject({ + status: "supported", + diagnostics: { + host_platform: "win32", + executable_role: "application", + managed: false, + }, + }); + expect( + ghidra.inspectTargetSupport({ + ...nativeApplication, + executableRole: "shared-library", + }), + ).toMatchObject({ + status: "unsupported", + code: "target_role_unsupported", + }); + expect( + ghidra.inspectTargetSupport({ ...nativeApplication, managed: true }), + ).toMatchObject({ + status: "unsupported", + code: "managed_target_unsupported", + }); + expect( + ghidra.inspectTargetSupport({ ...nativeApplication, format: "elf" }), + ).toMatchObject({ + status: "unsupported", + code: "target_format_unsupported", + }); + expect( + ghidra.inspectTargetSupport({ + ...nativeApplication, + architecture: "arm64", + }), + ).toMatchObject({ + status: "unsupported", + code: "architecture_unsupported", + }); + }); + it("commits exact provider, isolation, and resource semantics", async () => { const callTool = vi.fn().mockResolvedValue( ok({ diff --git a/tests/windowsCapabilities.test.ts b/tests/windowsCapabilities.test.ts new file mode 100644 index 0000000000..456bd7e95d --- /dev/null +++ b/tests/windowsCapabilities.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + probeWindowsCapabilities, + type WindowsCapabilityDependencies, +} from "../src/application/WindowsCapabilities.js"; + +const dependencies = ( + overrides: Partial = {}, +): WindowsCapabilityDependencies => ({ + platform: "win32", + architecture: "x64", + probeSymlinkCreation: () => + Promise.resolve({ available: false, reason: "EPERM" }), + probeNoFollowOpen: () => ({ + available: false, + reason: "O_NOFOLLOW is unavailable in this Node runtime", + }), + probePrivateAcl: () => ({ + available: false, + reason: "Windows ACL enforcement is not implemented by this REA build", + }), + probeUnixDomainSocket: () => ({ + available: false, + reason: "Node path-based IPC uses named pipes on Windows", + }), + probePty: () => + Promise.resolve({ + available: false, + backend: "node-pty", + reason: "probe unavailable", + }), + ...overrides, +}); + +describe("Windows host capability report", () => { + it("keeps unavailable security controls explicit", async () => { + await expect(probeWindowsCapabilities(dependencies())).resolves.toEqual({ + platform: "win32", + architecture: "x64", + capabilities: { + symlink_creation: { available: false, reason: "EPERM" }, + no_follow_open: { + available: false, + reason: "O_NOFOLLOW is unavailable in this Node runtime", + }, + private_acl: { + available: false, + reason: + "Windows ACL enforcement is not implemented by this REA build", + }, + unix_domain_socket: { + available: false, + reason: "Node path-based IPC uses named pipes on Windows", + }, + pty: { available: false, reason: "probe unavailable" }, + }, + }); + }); + + it("reports successful probes independently", async () => { + const result = await probeWindowsCapabilities( + dependencies({ + probeSymlinkCreation: () => + Promise.resolve({ available: true, reason: null }), + probePty: () => + Promise.resolve({ available: true, backend: "node-pty" }), + }), + ); + + expect(result.capabilities.symlink_creation).toEqual({ + available: true, + reason: null, + }); + expect(result.capabilities.pty).toEqual({ + available: true, + reason: null, + }); + expect(result.capabilities.private_acl.available).toBe(false); + }); +});