Skip to content

Commit 0581989

Browse files
authored
Merge PR #373: metadata-only local-agent manifest for Defender / Agent 365 (P-LEGIBLE.1, ADR-0384)
feat(legibility): metadata-only local-agent manifest for Defender / Agent 365 (P-LEGIBLE.1, ADR-0384)
2 parents 97acae1 + 0060242 commit 0581989

10 files changed

Lines changed: 469 additions & 4 deletions

‎DECISIONS.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24154,3 +24154,11 @@ P-SANDBOX.13 designed around this (the Add folder route never accepts a path fro
2415424154
**Decision.** Recover in place wherever ownership is provable, and leave a redacted incident report every time. Startup: a run ledger (`<userData>/run-state.json`) marks a clean exit; after an unclean one, main enumerates processes once and stops ONLY what the ledger proves is ours (the recorded engine pid with the same image path and a start time within 5 s, its descendants by creation-ordered parent edges, and orphans whose parent is the dead recorded engine), never by name, never the current main; one `taskkill /F` names every proven pid (no `/T`, whose parent walk has no creation-order check). The engine persists the master session id (`~/.omp/lucid-last-session-<PORT>.json`), snapshots the previous one at start, and the window resumes it with a VERIFIED `session/load` (failure is reported, a fresh session starts, and the user is told "Your previous session could not be recovered. A new session was started."). Runtime: a cleared turn releases its own listener and cancels its session; a dead master child is revived on demand with the same session id; `ACPClient.stop()` tree-kills on Windows so tool processes the agent started do not outlive it, and resolves true only once that is confirmed (taskkill exits 0 and the child's own exit is observed; POSIX: the child exits after SIGTERM, then SIGKILL); a replacement master is never spawned while the child `restart()` retired is unconfirmed, so a watchdog or window recovery that cannot confirm it fails with nothing spawned and nothing re-sent; a deliberate exit (quit, the settings relaunch, the GPU relaunch, the foreign-port quit) marks the run ledger clean only after the engine and everything under it, the omp tree included, are verified stopped (bounded at 20 s), and otherwise leaves it unclean for the next launch's reaper; the renderer runs a bounded supervisor on "reconnecting" or a refused send (probe, reattach, recover the agent once, restart the engine once through a main-process IPC that refuses while the engine's nonce health still answers and is rate-limited, then give up with a plain message). Incidents live in `<userData>/incidents/<id>.md|.json` (main writes there directly and hands the engine the same folder as `LUCID_DATA_ROOT`), outside the `~/.omp` tree the contained agent may write; with no data root outside it, nothing is recorded. Every text is redacted on the way in (the support-bundle rules plus e-mail, long hex, and profile paths), the home path itself is never stored, developer `[ASKSAGE_DIAG]` records (whose `raw` field quotes provider output) are stripped from every log tail, which is read from a line boundary, and incidents never contain prompts, transcripts, settings or credential stores. Every read validates the stored record field by field and rebuilds the title, the public issue body and the report from it, so nothing shown or prefilled is read back from disk as text. Submitting is always the user's action: the prefilled GitHub issue carries the summary only, because the repository is public; the full report stays local for the user to review and attach.
2415524155

2415624156
**Consequences.** The logged wedge and dead-child paths self-heal in seconds instead of minutes or never, a relaunch always yields a window, and every recovery leaves evidence a maintainer can act on. Automatic stopping of leftovers is new policy: it is limited to ledger-proven processes, and anything the ledger cannot prove still goes through the ADR-0382 warning dialog. If a retired agent's tree cannot be confirmed stopped, that engine refuses to start another agent (each attempt retries the stop while the old root still runs) and says to quit and reopen LUCID: availability is traded for never running two agents in one workspace. Quitting waits for the engine tree to be stopped and verified. A standalone engine without `LUCID_DATA_ROOT` records no incidents. Open for maintainers: whether the public issue tracker is the right destination for incident summaries or a private channel (support@) should be offered instead; and the Agent Builder / scheduled agent path still runs `omp -p` through `Bun.spawnSync`, which blocks the engine event loop for up to 120 s per segment and, on timeout, kills only the shim (the real omp keeps working): a separate increment.
24157+
24158+
## ADR-0384 -- P-LEGIBLE.1: legible to Defender and Agent 365 without a content path (2026-09-23, issue #302)
24159+
24160+
**Context.** Microsoft Defender for Endpoint now inventories local AI agents (portal: Assets > AI agents > Local agents; advanced hunting: `AgentsInfo | where Platform == "LocalAgents"`) and, in preview, protects them at runtime. In an M365 E7 / Agent 365 shop an agent that is not in that inventory reads as shadow AI and risks an endpoint block regardless of merit. The issue asked what "legible" concretely requires, whether it fits our invariants, and for the minimal increment. Findings from Microsoft's primary docs (discover-local-ai-agents, local-agent-discovery-overview, ai-agent-runtime-protection-overview, all dated 2026-09-16): (1) Discovery is a Microsoft-maintained list of supported agents (Claude Code, Codex CLI, Cursor, Copilot and roughly 30 more); there is no public manifest or registration API a third party can use to enroll. The profile Defender builds is `Name`, `Version`, `McpServers`, `DeclaredTools` and `RawAgentInfo.localAgentMetadata` = `vendor`, `relatedProcess`, `trustedProcess`, `autoApprove` (both reported as the strings "true"/"false"), device, account, `localMcps`. (2) Entra Agent ID does not apply: local agents resolve to the OS user through a `used by` edge; `can authenticate as` is for cloud agents. (3) Runtime protection hooks three checkpoints (user prompt, pre-tool call, post-tool response) through each vendor's own hook interface (Claude Code, Codex CLI, Copilot CLI hooks). The fallback, network inspection, does not support certificate pinning or HTTP/3 and by construction cannot see loopback traffic, so for this IDE it observes essentially nothing. (4) In audit or block mode, detections forward to Defender XDR in the cloud, and Defender discovery requires the commercial cloud (sovereign and national clouds are not supported), which matters for CUI deployments. (5) `trustedProcess` describes the host binary; `build-desktop.yml` documents our installers as unsigned (it signs only when the `WIN_CSC_LINK` / `MAC_CSC_LINK` secrets exist, and macOS otherwise builds with `identity: null`), so a future profile would likely report "false".
24161+
24162+
**Decision.** Ship the metadata half now, and refuse the content half until it can be gated. `desktop/local_agent_manifest.ts` builds an ADVISORY, LUCID-defined manifest (schema `lucid.local-agent-manifest/1`). Microsoft publishes no vendor-writable manifest format and no enrollment API, and nothing documents Defender reading this file; only its field names are borrowed from the profile Defender builds for supported agents (`vendor`, `version`, `relatedProcess`, `processes`, `autoApprove`, `mcpServers`, `localMcps`), plus our posture (`controlPlane` loopback + ADR-0024 token, `runtimeProtection.agentNativeHooks: "none"`, `networkInspection: "not-effective"`, `content: "metadata-only"`). The engine writes it at boot to `<userData>/local-agent-manifest.json` (temp file + rename, advisory: a failed write logs and never blocks), only when Electron launched it, and `main.ts` passes `LUCID_HOST_EXE` so `relatedProcess` is the real host binary rather than a guess. `<userData>` is Electron's app-name directory: the standard build never calls `app.setName`, so it is the package name (`%APPDATA%\lucidagentide-desktop`, `~/Library/Application Support/lucidagentide-desktop`, `~/.config/lucidagentide-desktop`), Creator's is `LucidCreator`, and a non-default port appends `-<port>`. Because userData deliberately survives an uninstall, the file must not outlive the app: `desktop/build/installer.nsh` (wired as `build.nsis.include`, inherited by the Creator overlay) deletes ONLY `local-agent-manifest.json` and its crash-leftover temp file from those directories on a real NSIS uninstall (not on an auto-update), with both directory names taken from electron-builder's own `APP_PACKAGE_NAME` / `PRODUCT_FILENAME` defines. Portable, macOS and Linux installs have no uninstall hook, so the documented detection check validates the installed executable (on Windows, the NSIS uninstall entry's install location plus `LucidAgentIDE.exe`) before it trusts the file. The content rule is structural, not a filter: remote MCP entries keep only name, type and URL origin (no userinfo, path, query, headers), local MCP entries only name and command basename (no args, no env), and nothing from a prompt or tool call is in scope. `autoApprove` is "true" because Agent mode answers omp's per-tool asks itself (the in-process gate and exec/egress tier prompts still apply), or fleet full-auto is on. No hook seam ships in this increment. Gov/CUI posture, stated explicitly: no agent-native hook exists, so nothing reaches Defender beyond what an endpoint agent already observes (process, file and network telemetry); the honest answer for a CUI tenant is "network inspection only", which here means discovery-grade visibility and no content inspection.
24163+
24164+
**Consequences.** An admin can identify the IDE today (collect the file via Intune remediation or live response, gated on the executable still being installed; hunt `DeviceProcessEvents` for the listed processes); the file does not by itself make the app appear in Defender's inventory, and any future Microsoft use of it would be Microsoft's decision, not something this file enables. `docs/DEFENDER-AGENT365-COEXISTENCE.md` is the runbook. Loopback-only (ADR-0022), the per-launch token (ADR-0024) and the fail-closed gate are untouched: no listener, route, event name or gate path changed. Not done, each its own increment: (a) a `UserPromptSubmit` / `PreToolUse` / `PostToolUse` hook seam compatible with the peer-CLI contract, which must sit behind a managed tighten-only knob, run after our own gate (it may add a block, never remove one), and default off whenever the AskSage lockdown or a CUI session is active, with a metadata-only payload mode; (b) Authenticode and Developer ID signing, the prerequisite for `trustedProcess: "true"`; (c) a vendor submission to Microsoft for inclusion in the supported list. Needs a real tenant to verify: whether discovery can key on this manifest at all, payload retention for XDR-forwarded hook events, and whether the Agent 365 Registry accepts a local agent without an M365 app package.

‎Makefile‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,3 +1150,7 @@ demo-P-PORTGUARD.3: demo-portguard-2 ## P-PORTGUARD.3 (ADR-0382): reap our OWN o
11501150
.PHONY: demo-P-MODEL.4
11511151
demo-P-MODEL.4: ## P-MODEL.4 (ADR-0383): GPT-6 Sol and Luna. omp 18.2.7 -> 18.2.10 carries both ids natively; LUCID adds the cataloged prices (Astra 10/50, Sol 2/10, Luna 0.10/0.50), the 1M windows and cards, an Astra > Sol > Luna fresh-install order, and pins the Regular/Max walk across the three tiers.
11521152
$(BUN) test $(TEST_IGNORES) desktop/model_pricing.test.ts desktop/renderer/model_families.test.ts desktop/renderer/agent_flow.test.ts desktop/startup_model.test.ts harness/prompt/prefix_compaction.test.ts
1153+
1154+
.PHONY: demo-P-LEGIBLE.1
1155+
demo-P-LEGIBLE.1: ## P-LEGIBLE.1 (ADR-0384, issue #302): legible to Defender / Agent 365 without a content path. Each launch writes a metadata-only local-agent manifest (Defender's vendor / relatedProcess / autoApprove / mcpServers / localMcps vocabulary) to userData; MCP entries keep only name, type, URL origin or command basename, so no header, arg, env, path or query can leak. No hook seam, no listener, gate untouched.
1156+
$(BUN) test $(TEST_IGNORES) desktop/local_agent_manifest.test.ts harness/adr_numbering.test.ts

‎PROGRESS.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5083,4 +5083,9 @@ Roadmap phases (each its own future increment + ADR for its frozen-contract delt
50835083
## P-MEET.1: the Meetings panel, a thin client of the Lucid Meeting Hub (LUCIDMeetingHub#7)
50845084
- **shipped:** finding "what did we decide last Tuesday" meant leaving the IDE, because meeting recall existed only conversationally through `lucid_tool.py`. New left-rail Meetings fly-out: search, recent list (title/date/app), detail (summary, decisions, open action items), and an upcoming-meeting row that deep-links the Hub window. `desktop/meetings_hub.ts` is the engine-side client of the Hub's bearer-scoped `/ext/*` surface on loopback, 127.0.0.1:5123 by default (`/ext/meetings`, `/ext/meeting/<file>`, `/ext/todos?open=1`, `POST /ext/todos/mark`, `/ext/premeeting`, `/ext/pair/claim`) with every field re-typed at the boundary; `desktop/renderer/meetings_panel.ts` is the pure HTML half. The pairing bearer goes to the OS-encrypted vault under ref `meeting_hub_token` (`main.ts` injects it as `LUCID_MEETING_HUB_TOKEN`, the Figma PAT seam) and never to the settings file. Dormant Hub = one honest info row behind a 300ms HEAD probe, no retry loop, no polling anywhere. A locked Hub vault renders metadata rows plus a notice, never "no meetings". Review fixes: the engine captures `LUCID_MEETING_HUB_TOKEN` into module state and deletes it from process.env when meetings_hub.ts loads (before any omp/fleet/scanner child spawns, the P-SANDBOX.15 discipline); `LUCID_MEETING_HUB_URL` is refused unless it is plain `http://` on 127.0.0.1, localhost or [::1] (any port), and a refused origin is contacted by nothing; a failed `/ext/todos` read leaves action items UNKNOWN (a "?" line plus a notice), never struck through as done; both Hub links use the engine-validated origin, so a configured port is honoured; the pairing and success copy says the token can read meetings AND mark action items done. 55 tests across the two new suites (`bun test desktop/meetings_hub.test.ts desktop/renderer/meetings_panel.test.ts`), including a fresh-process check that the engine still authenticates while a child it spawns sees no token.
50855085
- **stubbed:** no live run against a real Hub or a real Electron window; the "Install Lucid Meeting Hub" row carries no install URL because there is no published one to point at; talk-time bar and `/ext/analytics` are not consumed; mark-done is one-way (the open list is all the panel can see, so un-doing stays a Hub action).
5086-
- **next:** Nick's three calls from LUCIDMeetingHub#7 - left-rail placement/priority, pairing UX now vs Plan 8 device certs, and whether mark-done belongs in v1 at all; then pair against a live Hub, walk dormant -> unpaired -> locked -> unlocked in the running app, and rebuild `renderer/app.bundle.js` before believing anything on screen (ADR-0260).
5086+
- **next:** Nick's three calls from LUCIDMeetingHub#7 - left-rail placement/priority, pairing UX now vs Plan 8 device certs, and whether mark-done belongs in v1 at all; then pair against a live Hub, walk dormant -> unpaired -> locked -> unlocked in the running app, and rebuild `renderer/app.bundle.js` before believing anything on screen (ADR-0260).
5087+
5088+
## P-LEGIBLE.1: legible to Defender and Agent 365 without a content path (ADR-0384, issue #302)
5089+
- **shipped:** investigated Defender local-agent discovery and runtime protection against Microsoft's primary docs: discovery is a Microsoft-maintained supported list (no public registration), Entra Agent ID does not apply to local agents, runtime protection rides vendor hook interfaces, and network inspection cannot see pinned/HTTP/3/loopback traffic. New pure `desktop/local_agent_manifest.ts` + a boot write in `dev.ts` (only when Electron launched it; `main.ts` passes `LUCID_HOST_EXE`): each launch writes `<userData>/local-agent-manifest.json` (standard build `%APPDATA%\lucidagentide-desktop`, Creator `LucidCreator`), an advisory LUCID-defined file whose field names borrow Defender's profile vocabulary (vendor, version, relatedProcess, processes, autoApprove as a string, mcpServers, localMcps) plus our posture (loopback + ADR-0024 token, no agent-native hook, network inspection not effective); Microsoft publishes no vendor-writable manifest format, so writing it enrolls nothing. Metadata only by construction: MCP entries keep name, type, URL origin or command basename, never headers, args, env, path or query. Review fixes: the runbook and Intune/live-response commands now name the real userData folder (`lucidagentide-desktop`, not `LucidAgentIDE`); new `desktop/build/installer.nsh` (`build.nsis.include`) deletes only the manifest on a real NSIS uninstall so it cannot report a removed install, and the documented detection check requires the installed `LucidAgentIDE.exe` from the uninstall entry. Admin runbook `docs/DEFENDER-AGENT365-COEXISTENCE.md` with the CUI posture ("network inspection only") and collection/hunting recipes. `make demo-P-LEGIBLE.1`.
5090+
- **stubbed:** no hook seam (UserPromptSubmit / PreToolUse / PostToolUse) and no managed payload knob yet; the manifest refreshes only at launch, so an MCP change appears on the next start; nothing verified in a real M365 tenant (whether discovery can key on the manifest, XDR payload retention, Agent 365 Registry acceptance); installers remain unsigned, so a future Defender profile would report trustedProcess "false". The NSIS uninstall hook is contract-tested but was not run through a real electron-builder build and uninstall here; portable, macOS and Linux installs have no uninstall hook, so there the executable check in the runbook is the only guard against a stale manifest.
5091+
- **next:** P-LEGIBLE.2, the hook seam behind a managed tighten-only knob that runs after our gate (add a block, never remove one) and defaults off under AskSage lockdown or a CUI session, with a metadata-only payload mode; separately, Authenticode / Developer ID signing and a vendor submission to Microsoft.

‎desktop/build/installer.nsh‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Copyright (c) 2026 TechLead 187 LLC
2+
# SPDX-License-Identifier: BUSL-1.1
3+
4+
# desktop/build/installer.nsh - electron-builder NSIS include (package.json build.nsis.include; the
5+
# Creator overlay inherits it through its `...cfg.nsis` spread).
6+
#
7+
# P-LEGIBLE.1 (ADR-0384): every launch writes `local-agent-manifest.json` into the app's userData dir,
8+
# and endpoint collectors (Intune remediation, Defender live response) read that file to identify the
9+
# install. userData deliberately survives an uninstall (settings, vault key, logs), so without this hook
10+
# the manifest would keep reporting an install that is gone. On a real uninstall we delete ONLY the
11+
# manifest (and a temp file a crashed write could have left), never the directory or anything else in it.
12+
#
13+
# Which directories: Electron names userData after the app name. The standard build never calls
14+
# app.setName, so it is the package name (APP_PACKAGE_NAME = lucidagentide-desktop); the Creator flavor
15+
# calls app.setName(productName) (PRODUCT_FILENAME = LucidCreator). A non-default LUCID_PORT suffixes the
16+
# dir with -<port> (desktop/main.ts). Both names come from electron-builder's own defines, so this file
17+
# holds no flavor-specific string; clearing the manifest from a dir a flavor never writes to is a no-op.
18+
19+
!define LUCID_AGENT_MANIFEST "local-agent-manifest.json"
20+
21+
# Delete the manifest from $APPDATA\<BASE> and every $APPDATA\<BASE>-<port> sibling.
22+
!macro lucidRemoveAgentManifest BASE
23+
Push $R0
24+
Push $R1
25+
Delete "$APPDATA\${BASE}\${LUCID_AGENT_MANIFEST}"
26+
Delete "$APPDATA\${BASE}\${LUCID_AGENT_MANIFEST}.*.tmp"
27+
FindFirst $R0 $R1 "$APPDATA\${BASE}-*"
28+
${DoWhile} $R1 != ""
29+
Delete "$APPDATA\$R1\${LUCID_AGENT_MANIFEST}"
30+
Delete "$APPDATA\$R1\${LUCID_AGENT_MANIFEST}.*.tmp"
31+
FindNext $R0 $R1
32+
${Loop}
33+
FindClose $R0
34+
Pop $R1
35+
Pop $R0
36+
!macroend
37+
38+
!macro customUnInstall
39+
# An auto-update runs the old uninstaller with --updated; the app is still installed, so keep it.
40+
${ifNot} ${isUpdated}
41+
# Electron always uses per-user app data, even for a per-machine install (same switch as
42+
# electron-builder's own --delete-app-data path).
43+
${if} $installMode == "all"
44+
SetShellVarContext current
45+
${endif}
46+
!insertmacro lucidRemoveAgentManifest "${APP_PACKAGE_NAME}"
47+
!insertmacro lucidRemoveAgentManifest "${PRODUCT_FILENAME}"
48+
${if} $installMode == "all"
49+
SetShellVarContext all
50+
${endif}
51+
${endif}
52+
!macroend

0 commit comments

Comments
 (0)