[GH-ISSUE #3353] [Bug] Unrelated LaunchDaemons ancestor mislabels a LaunchAgents plist as daemon #1220

Closed
opened 2026-10-16 21:02:57 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3353

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

Apple inventory projection labels a plist directly inside an application's Contents/Library/LaunchAgents/ as domain: "daemon" when an unrelated archive ancestor is named LaunchDaemons. The public inspect-artifact and project-apple-application-graph commands both exit successfully. The projection retains the correct path and plist format but returns the wrong launchd domain with coverage.status: "complete-within-inventory" and inventory_complete: true.

Changing only the unrelated archive ancestor from LaunchDaemons to Resources makes the same LaunchAgents plist report agent. A separate control with Resources/Sample.app/Contents/Library/LaunchDaemons/task.plist correctly reports daemon.

The failure also reproduced with the LaunchAgents plist inside a nested application under a resource directory named LaunchDaemons. The one-entry archive below retains the failure after removing those extra bundle ancestors.

Reproduction

  1. Start from a clean checkout at 8dde6370d0809ad04cda3342d4e6f290f48aa063, use a supported Node.js runtime, and prepare the local CLI:
npm ci
npm run build:cached
  1. Run this script from the repository root. It creates the complete archive input, an isolated configuration home, and the inventory Evidence request inline, invokes both public commands, and prints the relevant projection fields:
python3 - <<'PYTHON'
import json
import os
from pathlib import Path
import subprocess
import tempfile
import zipfile

with tempfile.TemporaryDirectory(prefix="rea-apple-probe-") as directory:
    work = Path(directory)
    (work / "home").mkdir()
    env = dict(os.environ, HOME=str(work / "home"))
    archive = work / "sample.zip"
    with zipfile.ZipFile(archive, "w") as output:
        output.writestr(
            "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/task.plist",
            "<plist><dict/></plist>",
        )
    inspected = subprocess.run(
        ["node", "scripts/rea.mjs", "inspect-artifact", str(archive),
         "--format", "json", "--full-output"],
        env=env, text=True, capture_output=True, check=True,
    )
    evidence = json.loads(inspected.stdout)["data"]
    request = work / "request.json"
    request.write_text(json.dumps({"inventory_evidence": [evidence]}))
    projected = subprocess.run(
        ["node", "scripts/rea.mjs", "project-apple-application-graph", str(request),
         "--format", "json", "--full-output"],
        env=env, text=True, capture_output=True, check=True,
    )
    result = json.loads(projected.stdout)["data"]["normalized_result"]
    print(json.dumps(result["components"]["launchd_plists"], indent=2))
PYTHON
  1. Observe domain: "daemon" for LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/task.plist, even though the plist is directly inside LaunchAgents.

  2. Repeat step 2 with only the archive-entry string changed to Resources/Sample.app/Contents/Library/LaunchAgents/task.plist. This control reports agent.

  3. Repeat step 2 with only the archive-entry string changed to Resources/Sample.app/Contents/Library/LaunchDaemons/task.plist. This control reports daemon.

Expected behavior

docs/apple-application-analysis.md:121-123 assigns agent to plists directly in Contents/Library/LaunchAgents/ and daemon to those directly in Contents/Library/LaunchDaemons/. The component's domain should be agent, following its direct LaunchAgents placement. An ancestor outside the application root has no launchd-domain role. A file directly inside LaunchDaemons must remain daemon.

Impact and scope

The supported impact assessment is P2. The supported Apple projection returns an incorrect launchd-domain classification for a bounded class of archived or nested application paths. This misleads service inventory analysis; it does not alter the plist or execute a service.

A workaround is available. The projection preserves the exact component path, so an analyst can determine the correct domain from the immediate LaunchAgents directory while retaining the original artifact and content identity.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0 on macOS 27.0.1. Architecture was not recorded in the reproduction evidence. The reproduction uses the built local CLI and requires no analysis engine or target execution.

Target and artifact context

A ZIP archive containing one regular file, LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/task.plist, with inline content <plist><dict/></plist>. The enclosing application root is LaunchDaemons/Sample.app; the plist's immediate parent inside that application is Contents/Library/LaunchAgents. The request contains authenticated inventory Evidence returned by the public inspection command.

Interface

CLI

Earliest failure stage

Transport or result projection

Relevant output or evidence

The public CLI reproduction returned this component and coverage state:

{
  "path": "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/task.plist",
  "format": "plist",
  "domain": "daemon"
}
coverage.status: "complete-within-inventory"
inventory_complete: true

The failure evidence comes from an executed public CLI reproduction. Local verification reproduced the failure, and the reproduction includes all input content. The two controls change the enclosing archive name and the owning launchd directory separately. They distinguish the unrelated ancestor from the actual directory that establishes the component's domain.

Typed outcome or recovery expected

Run the reproduction unchanged and require domain: "agent" for the retained LaunchAgents path. The control with an unrelated Resources ancestor must also remain agent, and the file directly inside LaunchDaemons must remain daemon. Preserve the component's exact path and content identity.

Analysis or suspected cause

Source-derived reasoning: src/domain/apple/appleApplication.ts:291-312 identifies the launchd component by its path tail, then chooses its domain by searching every path segment for LaunchDaemons. This can let an unrelated archive or nested-bundle ancestor override the direct LaunchAgents placement. The observed CLI result above establishes the wrong domain; this source inspection explains the suspected cause.

Additional context

Related closed #1486 concerns mixed case policies for Apple bundle anatomy; this failure uses canonical case and depends on an unrelated ancestor's name.

Related closed #1489 concerns bridge and runtime-family inference from path substrings; this failure concerns launchd-domain selection for a plist already matched to its direct launchd directory.

Related closed #2546 concerns AppleDouble metadata entering IPA component lists; this failure mislabels an ordinary plist in a one-entry ZIP.

Related closed #3174 concerns omitted extensionless Mach-O components outside IPA application roots; this failure retains a rooted plist but assigns the wrong domain.

Related closed #3177 concerns Unicode ordering of Apple projection arrays; this failure concerns launchd semantics with ASCII paths.

Related closed #1234 concerns byte-identical files sharing path-specific classifications; this one-entry archive has no content collision and retains the correct plist format.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3353 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior Apple inventory projection labels a plist directly inside an application's `Contents/Library/LaunchAgents/` as `domain: "daemon"` when an unrelated archive ancestor is named `LaunchDaemons`. The public `inspect-artifact` and `project-apple-application-graph` commands both exit successfully. The projection retains the correct path and plist format but returns the wrong launchd domain with `coverage.status: "complete-within-inventory"` and `inventory_complete: true`. Changing only the unrelated archive ancestor from `LaunchDaemons` to `Resources` makes the same LaunchAgents plist report `agent`. A separate control with `Resources/Sample.app/Contents/Library/LaunchDaemons/task.plist` correctly reports `daemon`. The failure also reproduced with the LaunchAgents plist inside a nested application under a resource directory named `LaunchDaemons`. The one-entry archive below retains the failure after removing those extra bundle ancestors. ### Reproduction 1. Start from a clean checkout at `8dde6370d0809ad04cda3342d4e6f290f48aa063`, use a supported Node.js runtime, and prepare the local CLI: ```sh npm ci npm run build:cached ``` 2. Run this script from the repository root. It creates the complete archive input, an isolated configuration home, and the inventory Evidence request inline, invokes both public commands, and prints the relevant projection fields: ```sh python3 - <<'PYTHON' import json import os from pathlib import Path import subprocess import tempfile import zipfile with tempfile.TemporaryDirectory(prefix="rea-apple-probe-") as directory: work = Path(directory) (work / "home").mkdir() env = dict(os.environ, HOME=str(work / "home")) archive = work / "sample.zip" with zipfile.ZipFile(archive, "w") as output: output.writestr( "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/task.plist", "<plist><dict/></plist>", ) inspected = subprocess.run( ["node", "scripts/rea.mjs", "inspect-artifact", str(archive), "--format", "json", "--full-output"], env=env, text=True, capture_output=True, check=True, ) evidence = json.loads(inspected.stdout)["data"] request = work / "request.json" request.write_text(json.dumps({"inventory_evidence": [evidence]})) projected = subprocess.run( ["node", "scripts/rea.mjs", "project-apple-application-graph", str(request), "--format", "json", "--full-output"], env=env, text=True, capture_output=True, check=True, ) result = json.loads(projected.stdout)["data"]["normalized_result"] print(json.dumps(result["components"]["launchd_plists"], indent=2)) PYTHON ``` 3. Observe `domain: "daemon"` for `LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/task.plist`, even though the plist is directly inside LaunchAgents. 4. Repeat step 2 with only the archive-entry string changed to `Resources/Sample.app/Contents/Library/LaunchAgents/task.plist`. This control reports `agent`. 5. Repeat step 2 with only the archive-entry string changed to `Resources/Sample.app/Contents/Library/LaunchDaemons/task.plist`. This control reports `daemon`. ### Expected behavior `docs/apple-application-analysis.md:121-123` assigns `agent` to plists directly in `Contents/Library/LaunchAgents/` and `daemon` to those directly in `Contents/Library/LaunchDaemons/`. The component's domain should be `agent`, following its direct LaunchAgents placement. An ancestor outside the application root has no launchd-domain role. A file directly inside LaunchDaemons must remain `daemon`. ### Impact and scope The supported impact assessment is P2. The supported Apple projection returns an incorrect launchd-domain classification for a bounded class of archived or nested application paths. This misleads service inventory analysis; it does not alter the plist or execute a service. A workaround is available. The projection preserves the exact component path, so an analyst can determine the correct domain from the immediate LaunchAgents directory while retaining the original artifact and content identity. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0 on macOS 27.0.1. Architecture was not recorded in the reproduction evidence. The reproduction uses the built local CLI and requires no analysis engine or target execution. ### Target and artifact context A ZIP archive containing one regular file, `LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/task.plist`, with inline content `<plist><dict/></plist>`. The enclosing application root is `LaunchDaemons/Sample.app`; the plist's immediate parent inside that application is `Contents/Library/LaunchAgents`. The request contains authenticated inventory Evidence returned by the public inspection command. ### Interface CLI ### Earliest failure stage Transport or result projection ### Relevant output or evidence The public CLI reproduction returned this component and coverage state: ```text { "path": "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/task.plist", "format": "plist", "domain": "daemon" } coverage.status: "complete-within-inventory" inventory_complete: true ``` The failure evidence comes from an executed public CLI reproduction. Local verification reproduced the failure, and the reproduction includes all input content. The two controls change the enclosing archive name and the owning launchd directory separately. They distinguish the unrelated ancestor from the actual directory that establishes the component's domain. ### Typed outcome or recovery expected Run the reproduction unchanged and require `domain: "agent"` for the retained LaunchAgents path. The control with an unrelated `Resources` ancestor must also remain `agent`, and the file directly inside LaunchDaemons must remain `daemon`. Preserve the component's exact path and content identity. ### Analysis or suspected cause Source-derived reasoning: `src/domain/apple/appleApplication.ts:291-312` identifies the launchd component by its path tail, then chooses its domain by searching every path segment for `LaunchDaemons`. This can let an unrelated archive or nested-bundle ancestor override the direct LaunchAgents placement. The observed CLI result above establishes the wrong domain; this source inspection explains the suspected cause. ### Additional context Related closed [#1486](https://github.com/morluto/rea/issues/1486) concerns mixed case policies for Apple bundle anatomy; this failure uses canonical case and depends on an unrelated ancestor's name. Related closed [#1489](https://github.com/morluto/rea/issues/1489) concerns bridge and runtime-family inference from path substrings; this failure concerns launchd-domain selection for a plist already matched to its direct launchd directory. Related closed [#2546](https://github.com/morluto/rea/issues/2546) concerns AppleDouble metadata entering IPA component lists; this failure mislabels an ordinary plist in a one-entry ZIP. Related closed [#3174](https://github.com/morluto/rea/issues/3174) concerns omitted extensionless Mach-O components outside IPA application roots; this failure retains a rooted plist but assigns the wrong domain. Related closed [#3177](https://github.com/morluto/rea/issues/3177) concerns Unicode ordering of Apple projection arrays; this failure concerns launchd semantics with ASCII paths. Related closed [#1234](https://github.com/morluto/rea/issues/1234) concerns byte-identical files sharing path-specific classifications; this one-entry archive has no content collision and retains the correct plist format.
Author
Owner

@morluto commented on GitHub (Oct 17, 2026):

Fixed on main by 5d6029ea62. Adapted @bnkaiteam's proposal and ZIP regression from #3504, adding public CLI/stdio MCP controls. This is not a PR merge or readiness attestation.

The matched immediate Contents/Library/LaunchAgents or LaunchDaemons suffix now owns both membership and domain. Removed the independent scan of every ancestor. Regular-file, root-attribution, sidecar, .plist and anatomy case policies remain intact.

Two new regressions fail before the fix. All53focused checks pass afterward, including real ZIP inventory, nested bundles, misleading ancestors, case variants, negative nested/unrooted/sidecar/directory paths, and identical CLI/MCP Evidence replies. Build,check:fast/typecheck/lint,docs:check (5README checks), final CLI rerun and hooks pass. Two read-only reviews found no blocker.

12 completed actual built public CLI projection tasks compared fc935d0df in an isolated checkout against the candidate, using2authored ZIP inventories. First processes were recorded separately from2alternating repeats. Repeated-process medians:

Task Full reply tokens Elapsed ms CPU ms OS peak MB
plain 1020→1020 363.44→366.72 549.97→557.13 349.58→353.17
ancestor 1002→1008 362.29→362.97 561.46→560.36 354.93→353.91

The ordinary control's complete reply and projection ID are byte-identical. Tokens are full stdout cl100k_base estimates; CPU and peakRSS use OSwait4, with sampled process-family RSS retained separately. No descendants were observed; sampled peaks can miss brief peaks. No speed improvement or true-cold claim. Whole-agent/catalog/MCP, inventorygeneration,large-input and failure costs remain unmeasured. The owned baseline worktree was removed normally. No target execution, real deep provider, heavy profiling or published npm verification.

<!-- gh-comment-id:6233725874 --> @morluto commented on GitHub (Oct 17, 2026): Fixed on main by 5d6029ea62a65ec34fd739ee40223c77c6fdb63b. Adapted @bnkaiteam's proposal and ZIP regression from #3504, adding public CLI/stdio MCP controls. This is not a PR merge or readiness attestation. The matched immediate Contents/Library/LaunchAgents or LaunchDaemons suffix now owns both membership and domain. Removed the independent scan of every ancestor. Regular-file, root-attribution, sidecar, `.plist` and anatomy case policies remain intact. Two new regressions fail before the fix. All53focused checks pass afterward, including real ZIP inventory, nested bundles, misleading ancestors, case variants, negative nested/unrooted/sidecar/directory paths, and identical CLI/MCP Evidence replies. Build,check:fast/typecheck/lint,docs:check (5README checks), final CLI rerun and hooks pass. Two read-only reviews found no blocker. 12 completed actual built public CLI projection tasks compared fc935d0df in an isolated checkout against the candidate, using2authored ZIP inventories. First processes were recorded separately from2alternating repeats. Repeated-process medians: | Task | Full reply tokens | Elapsed ms | CPU ms | OS peak MB | | --- | --- | --- | --- | --- | | plain | 1020→1020 | 363.44→366.72 | 549.97→557.13 | 349.58→353.17 | | ancestor | 1002→1008 | 362.29→362.97 | 561.46→560.36 | 354.93→353.91 | The ordinary control's complete reply and projection ID are byte-identical. Tokens are full stdout cl100k_base estimates; CPU and peakRSS use OSwait4, with sampled process-family RSS retained separately. No descendants were observed; sampled peaks can miss brief peaks. No speed improvement or true-cold claim. Whole-agent/catalog/MCP, inventorygeneration,large-input and failure costs remain unmeasured. The owned baseline worktree was removed normally. No target execution, real deep provider, heavy profiling or published npm verification.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1220
No description provided.