diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts
index 8c013dc7d2..a5b06d0dd3 100644
--- a/src/domain/apple/appleApplication.ts
+++ b/src/domain/apple/appleApplication.ts
@@ -241,6 +241,14 @@ const appleRootFormat = (format: string): RootFormat => {
const isNativeLibrary = (path: string): boolean =>
/\.(?:dylib|so)$/iu.test(path);
+const launchdDomain = (path: string): "agent" | "daemon" | undefined => {
+ if (anatomyTailMatches(path, ["Contents", "Library", "LaunchAgents", "*"]))
+ return "agent";
+ if (anatomyTailMatches(path, ["Contents", "Library", "LaunchDaemons", "*"]))
+ return "daemon";
+ return undefined;
+};
+
const classifyComponents = (
entries: readonly ProjectedInventoryEntry[],
all: readonly Component[],
@@ -280,29 +288,12 @@ const classifyComponents = (
privileged_helpers: matching((path) =>
anatomyTailMatches(path, ["Contents", "Library", "LaunchServices", "*"]),
),
- launchd_plists: matching(
- (path) =>
- path.endsWith(".plist") &&
- (anatomyTailMatches(path, [
- "Contents",
- "Library",
- "LaunchAgents",
- "*",
- ]) ||
- anatomyTailMatches(path, [
- "Contents",
- "Library",
- "LaunchDaemons",
- "*",
- ])),
- ).map((component) => ({
- ...component,
- domain: component.path
- .split("/")
- .some((segment) => anatomyNameIs(segment, "LaunchDaemons"))
- ? ("daemon" as const)
- : ("agent" as const),
- })),
+ launchd_plists: matching((path) => path.endsWith(".plist")).flatMap(
+ (component) => {
+ const domain = launchdDomain(component.path);
+ return domain === undefined ? [] : [{ ...component, domain }];
+ },
+ ),
helpers: matching((path) =>
anatomyTailMatches(path, ["Contents", "Helpers", "*"]),
),
diff --git a/tests/acceptance/applications/appleLaunchdPlists.test.ts b/tests/acceptance/applications/appleLaunchdPlists.test.ts
new file mode 100644
index 0000000000..c319377ef6
--- /dev/null
+++ b/tests/acceptance/applications/appleLaunchdPlists.test.ts
@@ -0,0 +1,93 @@
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
+import { expect } from "vitest";
+
+import { appleApplicationProjectionResultSchema } from "../../../src/domain/apple/appleApplication.js";
+import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import { cliTest } from "../../support/cli/cliFixture.js";
+
+cliTest(
+ "Apple launchd domains follow their owning directory through CLI and MCP",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-apple-launchd-cli-");
+ const environment = { HOME: root, USERPROFILE: root };
+ const expected = [
+ [
+ "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/agent.plist",
+ "agent",
+ ],
+ [
+ "LaunchDaemons/Sample.app/Contents/Resources/LaunchDaemons/Nested.app/Contents/Library/LaunchAgents/nested.plist",
+ "agent",
+ ],
+ [
+ "LaunchAgents/Other.app/Contents/Library/LaunchDaemons/daemon.plist",
+ "daemon",
+ ],
+ [
+ "launchdaemons/Case.app/contents/library/launchagents/mixed.plist",
+ "agent",
+ ],
+ ] as const;
+ const writer = new ZipWriter(new Uint8ArrayWriter());
+ for (const [path] of expected)
+ await writer.add(path, new TextReader(""));
+ for (const path of [
+ "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/nested/ignored.plist",
+ "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/not-a-plist",
+ "Contents/Library/LaunchAgents/unrooted.plist",
+ "__MACOSX/Sample.app/Contents/Library/LaunchAgents/sidecar.plist",
+ ])
+ await writer.add(path, new TextReader(""));
+ await writer.add(
+ "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/directory.plist/",
+ undefined,
+ { directory: true },
+ );
+ const path = join(root, "Launchd.zip");
+ await writeFile(path, await writer.close());
+ const inspected = await cli.run({
+ arguments: ["inspect-artifact", path, "--json"],
+ environment,
+ });
+ expect(inspected.exitCode, inspected.stderr).toBe(0);
+ const source = parseEvidence(inspected.json);
+ const input = { inventory_evidence: [source] };
+ const projected = await cli.run({
+ arguments: [
+ "project-apple-application-graph",
+ JSON.stringify(input),
+ "--json",
+ ],
+ environment,
+ });
+ expect(projected.exitCode, projected.stderr).toBe(0);
+ const evidence = parseEvidence(projected.json);
+ const result = appleApplicationProjectionResultSchema.parse(
+ evidence.normalized_result,
+ );
+ expect(
+ result.components.launchd_plists.map(({ path, domain }) => [
+ path,
+ domain,
+ ]),
+ ).toEqual(
+ [...expected].sort(([left], [right]) =>
+ compareUnicodeCodePoints(left, right),
+ ),
+ );
+ expect(result.coverage).toEqual({
+ status: "complete-within-inventory",
+ inventory_complete: true,
+ });
+ const { call } = await connectLocalToolsMcp(environment);
+ const mcp = await call("project_apple_application_graph", input);
+ expect(mcp.isError).not.toBe(true);
+ expect(parseEvidence(mcp.structuredContent)).toEqual(evidence);
+ },
+);
diff --git a/tests/boundary/filesystem/appleApplication.test.ts b/tests/boundary/filesystem/appleApplication.test.ts
index c83dcaf589..94f4daa85c 100644
--- a/tests/boundary/filesystem/appleApplication.test.ts
+++ b/tests/boundary/filesystem/appleApplication.test.ts
@@ -318,6 +318,57 @@ it("applies application-root and sidecar rules to unread entries", async () => {
});
describe("macOS application projection", () => {
+ it("classifies launchd plists by their owning directory instead of unrelated ancestors", async () => {
+ const root = await createTestTempDirectory("rea-apple-launchd-domain-");
+ const path = join(root, "Launchd.zip");
+ const expected = [
+ [
+ "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/agent.plist",
+ "agent",
+ ],
+ [
+ "LaunchDaemons/Sample.app/Contents/Resources/LaunchDaemons/Nested.app/Contents/Library/LaunchAgents/nested.plist",
+ "agent",
+ ],
+ [
+ "LaunchAgents/Other.app/Contents/Library/LaunchDaemons/daemon.plist",
+ "daemon",
+ ],
+ [
+ "launchdaemons/Case.app/contents/library/launchagents/mixed.plist",
+ "agent",
+ ],
+ ] as const;
+ const writer = new ZipWriter(new Uint8ArrayWriter());
+ for (const [entry] of expected)
+ await writer.add(entry, new TextReader(""));
+ await writer.add(
+ "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/nested/ignored.plist",
+ new TextReader(""),
+ );
+ await writeFile(path, await writer.close());
+
+ const inventory = parseEvidence(
+ await runProviderAnalysis(path, "inventory_artifact", {}),
+ );
+ const projection = appleApplicationProjectionResultSchema.parse(
+ requireSuccessfulProjection(
+ projectAppleApplicationEvidence({ inventory_evidence: [inventory] }),
+ ).normalized_result,
+ );
+ expect(
+ projection.components.launchd_plists.map(({ path, domain }) => [
+ path,
+ domain,
+ ]),
+ ).toEqual(expect.arrayContaining([...expected]));
+ expect(projection.components.launchd_plists).toHaveLength(expected.length);
+ expect(projection.coverage).toEqual({
+ status: "complete-within-inventory",
+ inventory_complete: true,
+ });
+ });
+
it("projects a macOS .app directory inventory with nested bundle roles", async () => {
const root = await createTestTempDirectory("rea-apple-macos-");
const app = join(root, "Fixture.app");