From 14f52b279706f37ac0011c86cd47fb753fe702ea Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 14:19:41 -0400 Subject: [PATCH 1/2] refactor(hermes): define path ownership contract Signed-off-by: Julie Yaunches --- .../hermes-path-ownership-lifecycle.test.ts | 1113 +++++ src/lib/agent/hermes-path-ownership.test.ts | 799 ++++ src/lib/agent/hermes-path-ownership.ts | 3924 +++++++++++++++++ 3 files changed, 5836 insertions(+) create mode 100644 src/lib/agent/hermes-path-ownership-lifecycle.test.ts create mode 100644 src/lib/agent/hermes-path-ownership.test.ts create mode 100644 src/lib/agent/hermes-path-ownership.ts diff --git a/src/lib/agent/hermes-path-ownership-lifecycle.test.ts b/src/lib/agent/hermes-path-ownership-lifecycle.test.ts new file mode 100644 index 00000000000..2723f4de0a6 --- /dev/null +++ b/src/lib/agent/hermes-path-ownership-lifecycle.test.ts @@ -0,0 +1,1113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { CONFIDENTIALITY_STATE_DIRS, HIGH_RISK_STATE_DIRS } from "../shields/state-dir-lock"; +import { loadAgent } from "./defs"; +import { + findHermesManagedArtifact, + HERMES_CONTRACT_GAPS, + HERMES_LEGACY_MIGRATION, + HERMES_MANAGED_ARTIFACTS, + type HermesManagedArtifact, + resolveHermesArtifactBackup, + resolveHermesArtifactPath, + resolveHermesBackupAction, + resolveHermesIdentities, + resolveHermesPosture, +} from "./hermes-path-ownership"; + +const TOPOLOGIES = ["root-separated", "same-uid"] as const; + +function artifact(id: string): HermesManagedArtifact { + const result = HERMES_MANAGED_ARTIFACTS.find((candidate) => candidate.id === id); + if (!result) throw new Error("Missing Hermes artifact '" + id + "'"); + return result; +} + +function topLevelPath(artifactRule: HermesManagedArtifact): string | null { + const relativePath = + typeof artifactRule.relativePath === "string" + ? artifactRule.relativePath + : artifactRule.relativePath.default; + if (relativePath === "." || relativePath.includes("/")) return null; + return relativePath; +} + +describe("Hermes path ownership lifecycle contract", () => { + it("keeps Curator state writable without exposing protected skill transitions (#8006)", () => { + for (const id of ["curator-state", "curator-recovery"] as const) { + expect(artifact(id)).toMatchObject({ + artifactClass: "durable-state", + shields: "keep-writable", + restore: "restore", + migration: "preserve", + }); + } + expect(artifact("curator-state").backup).toBe("file"); + expect(artifact("curator-recovery").backup).toBe("directory"); + + for (const id of ["curator-archive", "curator-suppression"] as const) { + expect(artifact(id)).toMatchObject({ + artifactClass: "protected-configuration", + shields: "seal", + restore: "restore", + migration: "preserve", + }); + expect(artifact(id).required.shields).toBeDefined(); + } + + expect(findHermesManagedArtifact("/sandbox/.hermes/skills/.curator_state")).toMatchObject({ + artifact: { id: "curator-state" }, + pathRole: "migration-source", + }); + expect( + findHermesManagedArtifact("/sandbox/.hermes/skills/.curator_backups/snapshot/skills.tar.gz"), + ).toMatchObject({ + artifact: { id: "curator-recovery" }, + pathRole: "migration-source", + }); + expect( + findHermesManagedArtifact("/sandbox/.hermes/skills/.archive/reviewer/SKILL.md"), + ).toMatchObject({ + artifact: { id: "curator-archive" }, + pathRole: "target", + }); + }); + + it("preserves supported gateway preferences and authorization state (#8006)", () => { + for (const id of ["discord-thread-state", "gateway-voice-mode"] as const) { + expect(artifact(id)).toMatchObject({ + artifactClass: "durable-state", + backup: "file", + restore: "restore", + migration: "preserve", + }); + expect(resolveHermesPosture(artifact(id).required.create, "root-separated")).toMatchObject({ + owner: "gateway", + group: "sandbox", + mode: "0600", + }); + } + expect(artifact("shell-hook-allowlist")).toMatchObject({ + artifactClass: "protected-configuration", + backup: "file", + restore: "restore", + migration: "preserve", + shields: "seal", + }); + expect( + resolveHermesPosture( + artifact("shell-hook-allowlist").required.create, + "root-separated", + "gateway", + ), + ).toMatchObject({ owner: "gateway", group: "sandbox", mode: "0660" }); + expect( + resolveHermesPosture(artifact("shell-hook-allowlist").required.shields!.up, "root-separated"), + ).toMatchObject({ owner: "root", group: "root", mode: "0444" }); + + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + expect(findHermesManagedArtifact(home + "/discord_threads.json")?.artifact.id).toBe( + "discord-thread-state", + ); + expect(findHermesManagedArtifact(home + "/gateway_voice_mode.json")?.artifact.id).toBe( + "gateway-voice-mode", + ); + expect(findHermesManagedArtifact(home + "/shell-hooks-allowlist.json")?.artifact.id).toBe( + "shell-hook-allowlist", + ); + } + }); + + it("backs up pairing grants without transient requests or throttling state (#8006)", () => { + const backup = resolveHermesArtifactBackup(artifact("pairing-state"), { kind: "default" }); + expect(artifact("pairing-state").presence).toBe("required"); + expect(resolveHermesBackupAction(backup, "slack-approved.json")).toBe("file"); + expect(resolveHermesBackupAction(backup, "slack-pending.json")).toBe("exclude"); + expect(resolveHermesBackupAction(backup, "_rate_limits.json")).toBe("exclude"); + expect(resolveHermesBackupAction(backup, "tmpabc123.tmp")).toBe("exclude"); + + const approved = artifact("pairing-approved"); + expect(approved).toMatchObject({ + artifactClass: "protected-configuration", + producers: ["root"], + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }); + expect(resolveHermesPosture(approved.required.shields!.up, "root-separated")).toEqual({ + kind: "path", + owner: "root", + group: "root", + mode: "0444", + }); + + for (const id of ["pairing-pending", "pairing-rate-limits"] as const) { + const runtime = artifact(id); + expect(runtime).toMatchObject({ + artifactClass: "mutable-runtime-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + expect(resolveHermesIdentities(runtime.producers, "root-separated")).toEqual(["gateway"]); + expect(resolveHermesIdentities(runtime.readers, "root-separated")).toEqual(["gateway"]); + expect(resolveHermesPosture(runtime.required.create, "root-separated")).toMatchObject({ + owner: "gateway", + mode: "0600", + }); + } + expect(artifact("pairing-write-staging")).toMatchObject({ + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + + for (const [id, relativePath] of [ + ["pairing-approved-write-staging", "pairing/tmpabc123.tmp"], + ["feishu-comment-pairing-write-staging", "feishu_comment_pairing.tmp"], + ] as const) { + const staging = artifact(id); + expect(staging).toMatchObject({ + artifactClass: "derived-disposable-state", + producers: ["root"], + readers: ["root"], + shields: "unchanged", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + expect(resolveHermesPosture(staging.required.create, "root-separated")).toEqual({ + kind: "path", + owner: "root", + group: "root", + mode: "0600", + }); + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + expect(findHermesManagedArtifact(home + "/" + relativePath)).toMatchObject({ + artifact: { id }, + pathRole: "target", + }); + } + } + + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + expect(findHermesManagedArtifact(home + "/pairing/slack-approved.json")).toMatchObject({ + artifact: { id: "pairing-approved" }, + pathRole: "target", + }); + expect( + findHermesManagedArtifact(home + "/platforms/pairing/slack-approved.json"), + ).toMatchObject({ artifact: { id: "pairing-approved" }, pathRole: "migration-source" }); + for (const [relativePath, id] of [ + ["runtime/pairing/slack-pending.json", "pairing-pending"], + ["runtime/pairing/_rate_limits.json", "pairing-rate-limits"], + ["runtime/pairing/tmpabc123.tmp", "pairing-write-staging"], + ] as const) { + expect(findHermesManagedArtifact(home + "/" + relativePath)).toMatchObject({ + artifact: { id }, + pathRole: "target", + }); + } + for (const oldRoot of ["pairing", "platforms/pairing"] as const) { + expect( + findHermesManagedArtifact(home + "/" + oldRoot + "/slack-pending.json"), + ).toMatchObject({ artifact: { id: "pairing-pending" }, pathRole: "migration-source" }); + } + } + + const platformsBackup = resolveHermesArtifactBackup(artifact("platform-state"), { + kind: "default", + }); + expect(resolveHermesBackupAction(platformsBackup, "pairing/slack-approved.json")).toBe( + "exclude", + ); + expect(resolveHermesBackupAction(platformsBackup, "pairing/slack-pending.json")).toBe( + "exclude", + ); + expect(resolveHermesBackupAction(platformsBackup, "pairing/_rate_limits.json")).toBe("exclude"); + expect(resolveHermesBackupAction(platformsBackup, "pairing/tmpabc123.tmp")).toBe("exclude"); + }); + + it("records the writable-runtime roots that the current Shields inventory still seals (#8006)", () => { + const sealedDirectoryRoots = HERMES_MANAGED_ARTIFACTS.filter( + (entry) => entry.kind === "directory" && entry.shields === "seal", + ) + .map(topLevelPath) + .filter((entry): entry is string => entry !== null); + + expect( + sealedDirectoryRoots.filter((entry) => HIGH_RISK_STATE_DIRS.includes(entry)).sort(), + ).toEqual(["cron", "hooks", "plugins", "profiles", "skills", "skins", "workspace"]); + expect( + sealedDirectoryRoots.filter((entry) => CONFIDENTIALITY_STATE_DIRS.includes(entry)), + ).toEqual(["pairing"]); + + const writableDirectoryRoots = new Set( + HERMES_MANAGED_ARTIFACTS.filter( + (entry) => entry.kind === "directory" && entry.shields === "keep-writable", + ) + .map(topLevelPath) + .filter((entry): entry is string => entry !== null), + ); + expect(HIGH_RISK_STATE_DIRS.filter((entry) => writableDirectoryRoots.has(entry))).toEqual([ + "whatsapp", + "platforms", + "weixin", + ]); + expect(CONFIDENTIALITY_STATE_DIRS.filter((entry) => writableDirectoryRoots.has(entry))).toEqual( + [], + ); + }); + + it("keeps gateway-produced live state writable in the target contract (#8006)", () => { + for (const id of ["tool-home", "kanban-state"]) { + const entry = artifact(id); + expect(entry.artifactClass, id).toMatch( + /^(credential-reference|durable-state|mutable-runtime-state)$/u, + ); + expect(entry.shields, id).toBe("keep-writable"); + expect(entry.required.shields, id).toBeUndefined(); + expect(resolveHermesIdentities(entry.producers, "root-separated"), id).toContain("gateway"); + expect(resolveHermesIdentities(entry.readers, "root-separated"), id).toContain("gateway"); + expect( + resolveHermesPosture(entry.required.create, "root-separated", "gateway"), + id, + ).toMatchObject({ + kind: "tree", + descendantOwner: "gateway", + group: "sandbox", + fileMode: "g+rwX,o-rwx", + }); + } + + for (const id of [ + "pairing-runtime-state", + "whatsapp-session", + "legacy-whatsapp-session", + ] as const) { + const entry = artifact(id); + expect(entry.shields, id).toBe("keep-writable"); + expect(entry.backup, id).toBe("exclude"); + expect(entry.restore, id).toBe("discard"); + expect(entry.required.restore, id).toBe("absent"); + expect(resolveHermesIdentities(entry.producers, "root-separated"), id).toEqual(["gateway"]); + expect(resolveHermesIdentities(entry.readers, "root-separated"), id).toEqual(["gateway"]); + expect(resolveHermesPosture(entry.required.create, "root-separated"), id).toMatchObject({ + kind: "tree", + owner: "gateway", + descendantOwner: "gateway", + directoryMode: "0700", + fileMode: "u-only", + }); + expect(resolveHermesIdentities(entry.producers, "same-uid"), id).toEqual(["sandbox"]); + expect(resolveHermesPosture(entry.required.create, "same-uid"), id).toMatchObject({ + kind: "tree", + owner: "sandbox", + descendantOwner: "sandbox", + directoryMode: "0700", + fileMode: "0600", + }); + } + + expect( + resolveHermesPosture(artifact("weixin-accounts").required.create, "root-separated"), + ).toMatchObject({ + kind: "tree", + owner: "sandbox", + descendantOwner: "preserve", + group: "sandbox", + directoryMode: "02770", + fileMode: "preserve", + }); + + expect(artifact("feishu-comment-pairing")).toMatchObject({ + presence: "required", + artifactClass: "protected-configuration", + producers: ["root"], + shields: "seal", + backup: "file", + restore: "restore", + }); + expect( + resolveHermesPosture( + artifact("feishu-comment-pairing").required.shields!.up, + "root-separated", + ), + ).toMatchObject({ owner: "root", group: "root", mode: "0444" }); + expect( + resolveHermesPosture(artifact("feishu-comment-rules").required.shields!.up, "root-separated"), + ).toMatchObject({ owner: "root", group: "root", mode: "0444" }); + }); + + it("protects private children from writable ancestor replacement (#8006)", () => { + const home = artifact("agent-home-root"); + const restoredHome = home.required.restore; + if (restoredHome === "absent") throw new Error("Hermes home must exist after restore"); + for (const posture of [ + home.required.create, + restoredHome, + home.required.shields!.up, + home.required.shields!.down, + ]) { + expect(resolveHermesPosture(posture, "root-separated")).toEqual({ + kind: "path", + owner: "root", + group: "sandbox", + mode: "03770", + }); + } + + for (const id of ["platform-state", "whatsapp-platform", "legacy-whatsapp-platform"] as const) { + expect( + resolveHermesPosture(artifact(id).required.create, "root-separated", "gateway"), + id, + ).toMatchObject({ + kind: "tree", + owner: "root", + directoryMode: "03770", + }); + } + + expect( + resolveHermesPosture( + artifact("runtime-directory").required.create, + "root-separated", + "gateway", + ), + ).toMatchObject({ + kind: "tree", + owner: "gateway", + directoryMode: "03770", + }); + expect( + resolveHermesPosture(artifact("pairing-state").required.create, "root-separated"), + ).toMatchObject({ + kind: "tree", + owner: "root", + descendantOwner: "root", + directoryMode: "0755", + }); + + for (const [target, id, mode] of [ + ["/sandbox/.hermes/platforms/whatsapp/session/creds.json", "whatsapp-session", "0700"], + ["/sandbox/.hermes/runtime/pairing/slack-pending.json", "pairing-pending", "0600"], + ["/sandbox/.hermes/pairing/slack-approved.json", "pairing-approved", "0444"], + ] as const) { + const resolved = findHermesManagedArtifact(target)?.artifact; + expect(resolved?.id, target).toBe(id); + if (!resolved) throw new Error("Missing Hermes artifact for '" + target + "'"); + const posture = resolveHermesPosture(resolved.required.create, "root-separated", "gateway"); + expect(posture.kind === "tree" ? posture.directoryMode : posture.mode, target).toBe(mode); + } + }); + + it("separates Weixin credentials, durable context, and runtime cursors (#8006)", () => { + const contextTokens = findHermesManagedArtifact( + "/sandbox/.hermes/weixin/accounts/primary.context-tokens.json", + )?.artifact; + const syncCursor = findHermesManagedArtifact( + "/sandbox/.hermes/weixin/accounts/primary.sync.json", + )?.artifact; + + expect(findHermesManagedArtifact("/sandbox/.hermes/weixin/accounts/primary.json")).toBeNull(); + expect(contextTokens?.id).toBe("weixin-context-tokens"); + expect(syncCursor?.id).toBe("weixin-sync-cursor"); + expect( + findHermesManagedArtifact( + "/sandbox/.hermes/profiles/research/weixin/accounts/primary.context-tokens.json", + )?.artifact.id, + ).toBe("weixin-context-tokens"); + + expect(contextTokens).toMatchObject({ + artifactClass: "durable-state", + backup: "file", + restore: "restore", + migration: "preserve", + }); + expect( + resolveHermesPosture(contextTokens!.required.create, "root-separated", "gateway"), + ).toMatchObject({ owner: "gateway", group: "sandbox", mode: "0600" }); + const restoredContextTokens = contextTokens!.required.restore; + if (restoredContextTokens === "absent") throw new Error("Weixin context must restore"); + expect(resolveHermesPosture(restoredContextTokens, "root-separated")).toMatchObject({ + owner: "gateway", + group: "sandbox", + mode: "0600", + }); + + expect(syncCursor).toMatchObject({ + artifactClass: "mutable-runtime-state", + backup: "exclude", + restore: "discard", + migration: "preserve", + }); + expect(resolveHermesPosture(syncCursor!.required.create, "root-separated")).toMatchObject({ + owner: "gateway", + group: "sandbox", + mode: "0600", + }); + + const weixinBackup = resolveHermesArtifactBackup(artifact("weixin-state"), { + kind: "default", + }); + expect(resolveHermesBackupAction(weixinBackup, "accounts/primary.json")).toBe("exclude"); + expect(resolveHermesBackupAction(weixinBackup, "accounts/primary.context-tokens.json")).toBe( + "file", + ); + expect(resolveHermesBackupAction(weixinBackup, "accounts/primary.sync.json")).toBe("exclude"); + expect(resolveHermesBackupAction(weixinBackup, "accounts/unknown.tmp")).toBe("exclude"); + }); + + it("seals LSP installs while keeping PowerShell sessions writable (#8006)", () => { + const powershellRuntime = artifact("lsp-powershell-runtime"); + expect(powershellRuntime).toMatchObject({ + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "discard", + }); + expect(resolveHermesIdentities(powershellRuntime.producers, "root-separated")).toEqual([ + "sandbox", + "gateway", + ]); + const restoreRequirement = powershellRuntime.required.restore; + if (restoreRequirement === "absent") throw new Error("PowerShell runtime must be recreated"); + for (const topology of TOPOLOGIES) { + expect(resolveHermesPosture(restoreRequirement, topology), topology).toMatchObject({ + owner: "sandbox", + descendantOwner: "sandbox", + group: "sandbox", + directoryMode: "02770", + }); + } + + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + for (const relativePath of [ + "lsp/bin/pyright", + "lsp/node_modules/pyright/package.json", + "lsp/python-packages/pyright/__init__.py", + "lsp/package-lock.json", + ]) { + expect(findHermesManagedArtifact(home + "/" + relativePath)?.artifact).toMatchObject({ + id: "lsp-cache", + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "regenerate", + shields: "seal", + }); + } + for (const relativePath of ["lsp/pses/pses-session-42.json", "lsp/pses/pses.log"]) { + expect(findHermesManagedArtifact(home + "/" + relativePath)?.artifact).toMatchObject({ + id: "lsp-powershell-runtime", + artifactClass: "mutable-runtime-state", + backup: "exclude", + restore: "regenerate", + }); + } + } + expect(findHermesManagedArtifact("/sandbox/.hermes/bin/tirith")?.artifact.id).toBe( + "tirith-binary", + ); + }); + + it("classifies oversized hook output spill files as disposable (#8006)", () => { + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + expect( + findHermesManagedArtifact(home + "/hook_outputs/session/1234.txt")?.artifact, + ).toMatchObject({ + id: "hook-output-spill", + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + shields: "keep-writable", + }); + } + }); + + it("keeps supported model metadata caches disposable in every UI home (#8006)", () => { + for (const [id, relativePath] of [ + ["context-length-cache", "context_length_cache.yaml"], + ["models-dev-cache", "models_dev_cache.json"], + ["ollama-cloud-models-cache", "ollama_cloud_models_cache.json"], + ] as const) { + for (const home of [ + { kind: "default" }, + { kind: "named-profile", name: "research" }, + ] as const) { + expect( + findHermesManagedArtifact(resolveHermesArtifactPath(artifact(id), home)), + ).toMatchObject({ + artifact: { + id, + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + pathRole: "target", + }); + } + + const dashboardId = "dashboard-" + id; + expect( + findHermesManagedArtifact( + resolveHermesArtifactPath(artifact(dashboardId), { kind: "dashboard" }), + ), + ).toMatchObject({ + artifact: { + id: dashboardId, + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + pathRole: "target", + }); + + expect(resolveHermesArtifactPath(artifact(id), { kind: "default" })).toBe( + "/sandbox/.hermes/" + relativePath, + ); + } + + expect(findHermesManagedArtifact("/sandbox/.hermes/.anthropic_oauth.json")).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/provider_models_cache.json")).toBeNull(); + }); + + it("excludes raw WhatsApp session credentials from ordinary backups (#8006)", () => { + const platformBackup = resolveHermesArtifactBackup(artifact("platform-state"), { + kind: "default", + }); + const preferredBackup = resolveHermesArtifactBackup(artifact("whatsapp-platform"), { + kind: "default", + }); + const legacyBackup = resolveHermesArtifactBackup(artifact("legacy-whatsapp-platform"), { + kind: "default", + }); + + expect(resolveHermesBackupAction(platformBackup, "pairing/slack-approved.json")).toBe( + "exclude", + ); + expect(resolveHermesBackupAction(platformBackup, "whatsapp/session/creds.json")).toBe( + "exclude", + ); + expect( + resolveHermesBackupAction(platformBackup, "whatsapp/session/app-state-sync-key/key-1.json"), + ).toBe("exclude"); + expect(resolveHermesBackupAction(platformBackup, "whatsapp/bridge.log")).toBe("exclude"); + expect(resolveHermesBackupAction(platformBackup, "whatsapp/session/bridge.pid")).toBe( + "exclude", + ); + expect(resolveHermesBackupAction(preferredBackup, "session/creds.json")).toBe("exclude"); + expect( + resolveHermesBackupAction(preferredBackup, "session/app-state-sync-key/key-1.json"), + ).toBe("exclude"); + expect(resolveHermesBackupAction(preferredBackup, "bridge.log")).toBe("exclude"); + expect(resolveHermesBackupAction(legacyBackup, "session/bridge.pid")).toBe("exclude"); + expect(resolveHermesBackupAction(legacyBackup, "session/creds.json")).toBe("exclude"); + + for (const id of ["whatsapp-session", "legacy-whatsapp-session"] as const) { + expect(artifact(id)).toMatchObject({ + artifactClass: "credential-reference", + backup: "exclude", + restore: "discard", + migration: "preserve", + }); + expect(artifact(id).required.restore).toBe("absent"); + expect(resolveHermesIdentities(artifact(id).producers, "root-separated")).toEqual([ + "gateway", + ]); + expect(resolveHermesIdentities(artifact(id).readers, "root-separated")).toEqual(["gateway"]); + } + + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + for (const [root, id] of [ + ["platforms/whatsapp/session", "whatsapp-session"], + ["whatsapp/session", "legacy-whatsapp-session"], + ] as const) { + for (const relativePath of ["creds.json", "app-state-sync-key/key-1.json"] as const) { + expect( + findHermesManagedArtifact(home + "/" + root + "/" + relativePath)?.artifact.id, + root + "/" + relativePath, + ).toBe(id); + } + } + } + + for (const [target, id] of [ + ["/sandbox/.hermes/platforms/whatsapp/bridge.log", "whatsapp-bridge-log"], + ["/sandbox/.hermes/platforms/whatsapp/session/bridge.pid", "whatsapp-bridge-pid"], + ["/sandbox/.hermes/whatsapp/bridge.log", "legacy-whatsapp-bridge-log"], + ["/sandbox/.hermes/whatsapp/session/bridge.pid", "legacy-whatsapp-bridge-pid"], + ] as const) { + expect(findHermesManagedArtifact(target)?.artifact, target).toMatchObject({ + id, + backup: "exclude", + restore: "discard", + migration: "discard", + }); + } + }); + + it("excludes interrupted session staging writes from durable backups (#8006)", () => { + const backup = resolveHermesArtifactBackup(artifact("sessions"), { kind: "default" }); + expect(resolveHermesBackupAction(backup, "sessions.json")).toBe("file"); + expect(resolveHermesBackupAction(backup, "history/conversation.json")).toBe("file"); + expect(resolveHermesBackupAction(backup, ".sessions_abc123.tmp")).toBe("exclude"); + + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + expect( + findHermesManagedArtifact(home + "/sessions/.sessions_abc123.tmp")?.artifact, + ).toMatchObject({ + id: "sessions-write-staging", + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + } + }); + + it("excludes crash-residual atomic staging files from durable state (#8006)", () => { + for (const [parentId, relativePath] of [ + ["memories", ".mem_abc123.tmp"], + ["skills", ".usage_abc123.tmp"], + ["skills", "..curator_state_abc123.tmp"], + ["skills", ".curator_suppressed_abc123.tmp"], + ["skills", ".bundled_manifest_abc123.tmp"], + ["skills", ".lock_abc123.tmp"], + ["skills", ".hub/.lock_abc123.tmp"], + ["skills", ".SKILL.md.tmp.abc123"], + ["skills", "reviewer/.SKILL.md.tmp.abc123"], + ["skills", "reviewer/references/.notes.md.tmp.abc123"], + ["cron-definitions-root", ".jobs_abc123.tmp"], + ["cron-definitions-root", ".sugg_abc123.tmp"], + ["cron-definitions-root", ".hb_abc123.tmp"], + ["cron-definitions-root", "output/job/.output_abc123.tmp"], + ] as const) { + const backup = resolveHermesArtifactBackup(artifact(parentId), { kind: "default" }); + expect(resolveHermesBackupAction(backup, relativePath), parentId + ":" + relativePath).toBe( + "exclude", + ); + } + + const stagingPaths = new Map([ + [".processes_abc123.tmp", "top-level-atomic-staging"], + ["..restart_notify_abc123.tmp", "top-level-atomic-staging"], + ["..restart_last_processed_abc123.tmp", "top-level-atomic-staging"], + ["..restart_pending_abc123.tmp", "top-level-atomic-staging"], + ["..restart_failure_counts_abc123.tmp", "top-level-atomic-staging"], + ["..drain_request_abc123.tmp", "top-level-atomic-staging"], + [".discord_threads_abc123.tmp", "top-level-atomic-staging"], + ["tmpabc123.tmp", "top-level-temporary-staging"], + [".config.yaml.nemoclaw.1234.0123456789abcdef", "nemoclaw-protected-write-staging"], + ["auth.json.tmp.1234.0123456789abcdef", "authentication-write-staging"], + ["gateway-starts.tmp", "gateway-start-ledger-write-staging"], + ["memories/.mem_abc123.tmp", "memory-write-staging"], + ["skills/.usage_abc123.tmp", "skills-write-staging"], + ["skills/..curator_state_abc123.tmp", "skills-write-staging"], + ["skills/.curator_suppressed_abc123.tmp", "skills-write-staging"], + ["skills/.bundled_manifest_abc123.tmp", "skills-write-staging"], + ["skills/.lock_abc123.tmp", "skills-write-staging"], + ["skills/.hub/.lock_abc123.tmp", "skills-hub-lock-staging"], + ["skills/.SKILL.md.tmp.abc123", "skills-descendant-write-staging"], + ["skills/reviewer/.SKILL.md.tmp.abc123", "skills-descendant-write-staging"], + ["skills/reviewer/references/.notes.md.tmp.abc123", "skills-descendant-write-staging"], + ["shell-hooks-allowlist.json.abc123.tmp", "shell-hook-allowlist-write-staging"], + ["cron/.jobs_abc123.tmp", "cron-write-staging"], + ["cron/.sugg_abc123.tmp", "cron-write-staging"], + ["cron/.hb_abc123.tmp", "cron-write-staging"], + ["cron/output/job/.output_abc123.tmp", "cron-output-write-staging"], + ["runtime/cron/output/job/.output_abc123.tmp", "runtime-cron-output-write-staging"], + ] as const); + + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + for (const [relativePath, id] of stagingPaths) { + const staging = findHermesManagedArtifact(home + "/" + relativePath)?.artifact; + expect(staging, relativePath).toMatchObject({ + id, + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + expect(staging?.shields, relativePath).toBe( + id.startsWith("skills-") || + id.startsWith("cron-") || + id === "nemoclaw-protected-write-staging" || + id === "shell-hook-allowlist-write-staging" + ? "seal" + : "keep-writable", + ); + } + } + + expect( + findHermesManagedArtifact("/sandbox/.hermes/active_profile.tmp")?.artifact, + ).toMatchObject({ + id: "active-profile-write-staging", + artifactClass: "derived-disposable-state", + shields: "seal", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + expect( + findHermesManagedArtifact("/sandbox/.hermes/dashboard-home/config.yaml.nemoclaw.tmp") + ?.artifact, + ).toMatchObject({ + id: "dashboard-config-write-staging", + artifactClass: "derived-disposable-state", + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + }); + + it("makes the Kanban selective backup contract executable (#8006)", () => { + const backup = resolveHermesArtifactBackup(artifact("kanban-state"), { kind: "default" }); + const expectations = new Map([ + ["current", "file"], + ["boards/release/kanban.db", "sqlite"], + ["boards/release/kanban.db-wal", "exclude"], + ["boards/release/kanban.db-shm", "exclude"], + ["boards/release/kanban.db-journal", "exclude"], + ["boards/release/kanban.db-future", "file"], + ["boards/release/kanban.db.init.lock", "exclude"], + ["boards/release/kanban.db.dispatch.lock", "exclude"], + ["boards/release/kanban.db.corrupt.0123456789abcdef.bak", "file"], + ["boards/release/kanban.db.corrupt.0123456789abcdef.bak-wal", "file"], + ["boards/release/metadata.json", "file"], + ["boards/release/workspaces/task/output.txt", "exclude"], + ["attachments/task/design.png", "file"], + ["boards/release/attachments/task/design.png", "file"], + ["boards/_archived/release/kanban.db", "sqlite"], + ["boards/_archived/release/kanban.db-wal", "exclude"], + ["boards/_archived/release/kanban.db.init.lock", "exclude"], + ["boards/_archived/release/kanban.db.corrupt.0123456789abcdef.bak", "file"], + ["boards/_archived/release/metadata.json", "file"], + ["boards/_archived/release/attachments/task/design.png", "file"], + ["boards/_archived/release/logs/task.log", "exclude"], + ["logs/task.log", "exclude"], + ] as const); + + for (const [relativePath, action] of expectations) { + expect(resolveHermesBackupAction(backup, relativePath), relativePath).toBe(action); + } + + const contracts = new Map([ + ["kanban/current", "kanban-current"], + ["kanban/attachments/task/design.png", "kanban-attachments"], + ["kanban/boards/release/kanban.db", "kanban-board-database"], + ["kanban/boards/release/kanban.db-wal", "kanban-board-database-sidecar-wal"], + ["kanban/boards/release/kanban.db.init.lock", "kanban-board-lock"], + ["kanban/boards/release/kanban.db.corrupt.0123456789abcdef.bak", "kanban-board-recovery"], + ["kanban/boards/release/workspaces/task/output.txt", "kanban-board-workspaces"], + ["kanban/boards/release/attachments/task/design.png", "kanban-board-attachments"], + ["kanban/boards/release/logs/task.log", "kanban-board-logs"], + ["kanban/boards/_archived/release/kanban.db", "archived-kanban-board-database"], + [ + "kanban/boards/_archived/release/kanban.db-shm", + "archived-kanban-board-database-sidecar-shm", + ], + ["kanban/boards/_archived/release/kanban.db.dispatch.lock", "archived-kanban-board-lock"], + [ + "kanban/boards/_archived/release/kanban.db.corrupt.0123456789abcdef.bak-shm", + "archived-kanban-board-recovery", + ], + [ + "kanban/boards/_archived/release/workspaces/task/output.txt", + "archived-kanban-board-workspaces", + ], + ] as const); + + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + for (const [relativePath, id] of contracts) { + expect( + findHermesManagedArtifact(home + "/" + relativePath)?.artifact.id, + relativePath, + ).toBe(id); + } + } + expect( + findHermesManagedArtifact( + "/sandbox/.hermes/dashboard-home/kanban/boards/release/attachments/task/design.png", + )?.artifact.id, + ).toBe("dashboard-kanban-board-attachments"); + expect(findHermesManagedArtifact("/sandbox/.hermes/kanban.db.init.lock")?.artifact.id).toBe( + "default-kanban-lock", + ); + expect( + findHermesManagedArtifact("/sandbox/.hermes/kanban.db.corrupt.0123456789abcdef.bak-wal") + ?.artifact.id, + ).toBe("default-kanban-recovery"); + expect( + findHermesManagedArtifact("/sandbox/.hermes/dashboard-home/kanban.db.dispatch.lock")?.artifact + .id, + ).toBe("dashboard-default-kanban-lock"); + expect( + findHermesManagedArtifact( + "/sandbox/.hermes/dashboard-home/kanban.db.corrupt.0123456789abcdef.bak", + )?.artifact.id, + ).toBe("dashboard-default-kanban-recovery"); + expect(artifact("kanban-board-database")).toMatchObject({ + artifactClass: "durable-state", + backup: "sqlite", + restore: "restore", + }); + expect(artifact("kanban-board-database-sidecar-wal")).toMatchObject({ + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + }); + expect(artifact("kanban-board-lock")).toMatchObject({ + artifactClass: "mutable-runtime-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + expect(artifact("kanban-board-recovery")).toMatchObject({ + artifactClass: "durable-state", + backup: "file", + restore: "restore", + migration: "preserve", + }); + expect(artifact("archived-kanban-board-workspaces")).toMatchObject({ + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "regenerate", + }); + expect(artifact("archived-kanban-board-attachments")).toMatchObject({ + artifactClass: "durable-state", + backup: "directory", + restore: "restore", + migration: "preserve", + }); + }); + + it("rejects equally specific selective backup rules (#8006)", () => { + expect(() => + resolveHermesBackupAction( + { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: "{first}.json", action: "file" }, + { relativePattern: "{second}.json", action: "exclude" }, + ], + }, + "state.json", + ), + ).toThrow("Ambiguous Hermes selective backup contract"); + }); + + it("prefers constrained backup rules over recursive fallbacks (#8006)", () => { + const backup = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: "skills/{name}", action: "exclude" }, + { relativePattern: "skills/**/{name}", action: "file" }, + ], + } as const; + + expect(resolveHermesBackupAction(backup, "skills/reviewer")).toBe("exclude"); + expect(resolveHermesBackupAction(backup, "skills/nested/reviewer")).toBe("file"); + }); + + it("matches repeated recursive segments without combinatorial backtracking (#8006)", () => { + expect( + resolveHermesBackupAction( + { + kind: "selective", + fallback: "exclude", + selectors: [ + { + relativePattern: "**/**/**/**/**/**/**/**/**/**/expected", + action: "file", + }, + ], + }, + "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/other", + ), + ).toBe("exclude"); + }); + + it("rejects parent traversal in backup candidates and selectors (#8006)", () => { + expect(() => resolveHermesBackupAction("file", "../secret")).toThrow("parent traversal"); + expect(() => resolveHermesBackupAction("file", "secret/")).toThrow("canonical relative path"); + expect(() => + resolveHermesBackupAction( + { + kind: "selective", + fallback: "exclude", + selectors: [{ relativePattern: "../{file}", action: "file" }], + }, + "secret", + ), + ).toThrow("parent traversal"); + }); + + it("records current manifest gaps separately from the target backup contract (#8006)", () => { + const hermes = loadAgent("hermes"); + expect(hermes.stateDirs).toEqual([ + "memories", + "sessions", + "skills", + "plugins", + "cron", + "logs", + "skins", + "plans", + "workspace", + "profiles", + "cache", + "pairing", + "dashboard-home", + "platforms", + "weixin", + ]); + expect(hermes.stateFiles).toEqual([ + { path: "SOUL.md", strategy: "copy" }, + { path: ".hermes_history", strategy: "copy" }, + { path: "runtime/state.db", strategy: "sqlite_backup" }, + { path: "runtime/cron-executions.db", strategy: "sqlite_backup" }, + { path: "gateway/discord_message_recovery.db", strategy: "sqlite_backup" }, + { path: "kanban.db", strategy: "sqlite_backup" }, + ]); + + expect(artifact("soul").backup).toBe("file"); + expect(artifact("tui-history").backup).toBe("file"); + expect(artifact("main-state-database").backup).toBe("sqlite"); + expect(artifact("response-store-database").backup).toBe("sqlite"); + expect(artifact("verification-evidence-database").backup).toBe("sqlite"); + expect(artifact("channel-aliases").backup).toBe("file"); + expect(artifact("discord-thread-state").backup).toBe("file"); + expect(artifact("gateway-voice-mode").backup).toBe("file"); + expect(artifact("shell-hook-allowlist").backup).toBe("file"); + expect(artifact("logs").backup).toBe("exclude"); + expect(artifact("cache").backup).toBe("exclude"); + expect(artifact("profiles-root").backup).toBe("exclude"); + expect(artifact("nemoclaw-plugin").backup).toBe("exclude"); + expect(artifact("dashboard-home").backup).toBe("exclude"); + expect(artifact("dashboard-memory").backup).toBe("file"); + expect(artifact("dashboard-user").backup).toBe("file"); + expect(artifact("dashboard-config").backup).toBe("exclude"); + expect(artifact("dashboard-status").restore).toBe("discard"); + expect(artifact("kanban-state").backup).toMatchObject({ kind: "selective" }); + }); + + it("records migration retention and privileged transition gaps (#8006)", () => { + expect(HERMES_LEGACY_MIGRATION).toEqual({ + source: "/sandbox/.hermes-data", + destination: "/sandbox/.hermes", + failure: { + required: "retain-source", + knownGap: "source-removal-precedes-final-validation", + }, + }); + expect(HERMES_CONTRACT_GAPS).toEqual([ + { + id: "cron-jobs-schema-split", + currentPaths: ["cron/jobs.json"], + targetArtifactIds: ["cron-job-definitions", "cron-job-runtime-state"], + consumer: "pinned Hermes cron.jobs and cron.scheduler", + migration: "record-level", + failure: "retain-source", + }, + { + id: "curator-runtime-relocation", + currentPaths: ["skills/.curator_state", "skills/.curator_backups"], + targetArtifactIds: ["curator-state", "curator-recovery"], + consumer: "pinned Hermes agent.curator and agent.curator_backup", + migration: "path-relocation", + failure: "retain-source", + }, + { + id: "skills-protected-transition", + currentPaths: ["skills", "skills/.archive", "skills/.curator_suppressed"], + targetArtifactIds: ["skills", "curator-archive", "curator-suppression"], + consumer: + "pinned Hermes Curator, tools.skill_manager_tool, tools.skills_sync, and gateway startup", + migration: "privileged-transition", + failure: "retain-source", + }, + { + id: "lsp-install-privileged-transition", + currentPaths: ["lsp", "lsp/bin", "lsp/node_modules", "lsp/python-packages"], + targetArtifactIds: ["lsp-cache"], + consumer: "pinned Hermes agent.lsp.install and agent.lsp.servers", + migration: "privileged-transition", + failure: "retain-source", + }, + { + id: "pairing-runtime-relocation", + currentPaths: [ + "pairing/{platform}-pending.json", + "pairing/_rate_limits.json", + "platforms/pairing/{platform}-pending.json", + "platforms/pairing/_rate_limits.json", + ], + targetArtifactIds: ["pairing-runtime-state", "pairing-pending", "pairing-rate-limits"], + consumer: "pinned Hermes PairingStore pending request and rate-limit writers", + migration: "path-relocation", + failure: "retain-source", + }, + { + id: "pairing-approval-privileged-transition", + currentPaths: [ + "pairing/{platform}-approved.json", + "platforms/pairing/{platform}-approved.json", + "feishu_comment_pairing.json", + ], + targetArtifactIds: [ + "pairing-approved", + "pairing-approved-write-staging", + "feishu-comment-pairing", + "feishu-comment-pairing-write-staging", + ], + consumer: "pinned Hermes pairing approval, dashboard, and Feishu comment pairing writers", + migration: "privileged-transition", + failure: "retain-source", + }, + { + id: "whatsapp-session-secure-handoff", + currentPaths: ["platforms/whatsapp/session", "whatsapp/session"], + targetArtifactIds: ["whatsapp-session", "legacy-whatsapp-session"], + consumer: + "pinned Hermes WhatsApp bridge Baileys multi-file authentication state and NemoClaw snapshot/rebuild restore", + migration: "privileged-transition", + failure: "retain-source", + limitation: + "same-uid topology cannot isolate gateway session credentials from sandbox code", + }, + ]); + expect( + findHermesManagedArtifact("/sandbox/.hermes/platforms/pairing/slack-approved.json"), + ).toMatchObject({ + artifact: { + id: "pairing-approved", + migrationSources: [ + expect.objectContaining({ action: "migrate", onFailure: "leave-source" }), + ], + }, + pathRole: "migration-source", + }); + expect(findHermesManagedArtifact("/sandbox/.hermes/cron/jobs.json")).toMatchObject({ + artifact: { id: "cron-job-definitions" }, + pathRole: "target", + }); + expect(resolveHermesArtifactPath(artifact("cron-job-runtime-state"), { kind: "default" })).toBe( + "/sandbox/.hermes/runtime/cron/job-state.json", + ); + expect(artifact("migration-marker")).toMatchObject({ + restore: "discard", + migration: "regenerate", + }); + }); +}); diff --git a/src/lib/agent/hermes-path-ownership.test.ts b/src/lib/agent/hermes-path-ownership.test.ts new file mode 100644 index 00000000000..732e5e21ea9 --- /dev/null +++ b/src/lib/agent/hermes-path-ownership.test.ts @@ -0,0 +1,799 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + findHermesManagedArtifact, + HERMES_MANAGED_ARTIFACTS, + HERMES_UNSUPPORTED_RESIDUAL_PATHS, + type HermesHome, + type HermesManagedArtifact, + type HermesPosture, + type HermesPostureRequirement, + type HermesRequiredPostures, + resolveHermesArtifactBackup, + resolveHermesArtifactPath, + resolveHermesArtifactRestore, + resolveHermesBackupAction, + resolveHermesHomePath, + resolveHermesIdentities, + resolveHermesPosture, +} from "./hermes-path-ownership"; + +const TOPOLOGIES = ["root-separated", "same-uid"] as const; + +function artifact(id: string): HermesManagedArtifact { + const result = HERMES_MANAGED_ARTIFACTS.find((candidate) => candidate.id === id); + if (!result) throw new Error("Missing Hermes artifact '" + id + "'"); + return result; +} + +function homesFor(artifactRule: HermesManagedArtifact): HermesHome[] { + if (artifactRule.scope === "agent-home") { + return [{ kind: "default" }, { kind: "named-profile", name: "research" }]; + } + if (artifactRule.scope === "default-home") return [{ kind: "default" }]; + if (artifactRule.scope === "dashboard") return [{ kind: "dashboard" }]; + return [{ kind: "named-profile", name: "research" }]; +} + +function postures(requirement: HermesPostureRequirement): HermesPosture[] { + return TOPOLOGIES.map((topology) => resolveHermesPosture(requirement, topology, "sandbox")); +} + +function materializePattern(pattern: string): string { + return pattern.replace(/\*\*/gu, "nested/item").replace(/\{[^/{}]+\}/gu, "sample"); +} + +describe("Hermes path ownership contract", () => { + it("classifies every named artifact and defines complete lifecycle policy (#8006)", () => { + expect(new Set(HERMES_MANAGED_ARTIFACTS.map((entry) => entry.id)).size).toBe( + HERMES_MANAGED_ARTIFACTS.length, + ); + expect(new Set(HERMES_MANAGED_ARTIFACTS.map((entry) => entry.artifactClass))).toEqual( + new Set([ + "protected-configuration", + "credential-reference", + "mutable-runtime-state", + "durable-state", + "derived-disposable-state", + ]), + ); + + const concreteTargets = new Set(); + for (const entry of HERMES_MANAGED_ARTIFACTS as readonly HermesManagedArtifact[]) { + for (const topology of TOPOLOGIES) { + const producers = resolveHermesIdentities(entry.producers, topology); + const readers = resolveHermesIdentities(entry.readers, topology); + expect(producers.length, entry.id).toBeGreaterThan(0); + expect(readers.length, entry.id).toBeGreaterThan(0); + for (const producer of producers) { + const create = resolveHermesPosture(entry.required.create, topology, producer); + if (producer === "root") continue; + expect(readers, entry.id + ":" + producer).toContain(producer); + if (create.kind === "path") { + expect(create.owner, entry.id + ":" + producer).toBe(producer); + } else if (create.descendantOwner !== "preserve") { + expect(create.descendantOwner, entry.id + ":" + producer).toBe(producer); + } + } + } + expect(resolveHermesIdentities(entry.producers, "same-uid"), entry.id).not.toContain( + "gateway", + ); + expect(resolveHermesIdentities(entry.readers, "same-uid"), entry.id).not.toContain("gateway"); + expect(entry.presence === "required" || entry.presence === "optional", entry.id).toBe(true); + expect(typeof entry.relativePath === "string" || entry.scope === "agent-home", entry.id).toBe( + true, + ); + expect( + typeof entry.backup === "object" && + "default" in entry.backup && + entry.scope !== "agent-home", + entry.id, + ).toBe(false); + expect(typeof entry.restore === "object" && entry.scope !== "agent-home", entry.id).toBe( + false, + ); + + const required: HermesRequiredPostures = entry.required; + const lifecycleRequirements = [ + required.create, + required.restore, + required.shields?.up, + required.shields?.down, + ]; + for (const requirement of lifecycleRequirements) { + if (!requirement || requirement === "absent") continue; + for (const posture of postures(requirement)) { + if (entry.match === "subtree") expect(posture.kind, entry.id).toBe("tree"); + if (posture.kind === "path") { + expect(posture.mode, entry.id).toMatch(/^0[0-7]{3,4}$/); + } else { + expect(posture.directoryMode, entry.id).toMatch(/^0[0-7]{3,4}$/); + expect(posture.descendantOwner, entry.id).not.toBe("producer"); + expect(posture.symlinks, entry.id).toBe("ownership-only"); + } + } + } + + if (entry.shields === "seal") expect(entry.required.shields, entry.id).toBeDefined(); + + for (const home of homesFor(entry)) { + const backup = resolveHermesArtifactBackup(entry, home); + const restore = resolveHermesArtifactRestore(entry, home); + expect(backup === "exclude" && restore === "restore", entry.id).toBe(false); + expect(entry.required.restore === "absent", entry.id).toBe(restore === "discard"); + if (typeof backup === "object") { + expect(backup.kind, entry.id).toBe("selective"); + expect(backup.selectors.length, entry.id).toBeGreaterThan(0); + expect(new Set(backup.selectors.map((selector) => selector.relativePattern)).size).toBe( + backup.selectors.length, + ); + } + + const target = resolveHermesArtifactPath(entry, home); + const key = home.kind + ":" + target + ":" + entry.match; + expect(concreteTargets.has(key), entry.id).toBe(false); + concreteTargets.add(key); + + const resolvedTarget = findHermesManagedArtifact(materializePattern(target)); + expect(resolvedTarget?.artifact.id, entry.id + ":" + home.kind).toBe(entry.id); + expect(resolvedTarget?.pathRole, entry.id + ":" + home.kind).toBe("target"); + + for (const source of entry.migrationSources ?? []) { + expect(source.relativePath, entry.id).not.toBe("."); + expect(source.relativePath, entry.id).not.toMatch(/^\//u); + const sourcePath = + resolveHermesHomePath(home) + "/" + materializePattern(source.relativePath); + const resolvedSource = findHermesManagedArtifact(sourcePath); + expect(resolvedSource?.artifact.id, sourcePath).toBe(entry.id); + expect(resolvedSource?.pathRole, sourcePath).toBe("migration-source"); + if (source.action === "migrate") { + expect(source.onFailure, sourcePath).toBe("leave-source"); + } + } + } + } + }); + + it("resolves every dynamic path for default and named homes without ambiguity (#8006)", () => { + for (const entry of HERMES_MANAGED_ARTIFACTS.filter( + (candidate) => candidate.match === "pattern", + )) { + for (const home of homesFor(entry)) { + const target = materializePattern(resolveHermesArtifactPath(entry, home)); + expect(findHermesManagedArtifact(target)?.artifact.id, entry.id + ":" + home.kind).toBe( + entry.id, + ); + } + } + }); + + it("resolves exact and nested paths while unknown profile state fails closed (#8006)", () => { + expect(resolveHermesHomePath({ kind: "default" })).toBe("/sandbox/.hermes"); + expect(resolveHermesHomePath({ kind: "named-profile", name: "research" })).toBe( + "/sandbox/.hermes/profiles/research", + ); + expect(resolveHermesHomePath({ kind: "dashboard" })).toBe("/sandbox/.hermes/dashboard-home"); + + expect(findHermesManagedArtifact("/sandbox/.hermes/config.yaml")?.artifact.id).toBe("config"); + expect( + findHermesManagedArtifact("/sandbox/.hermes/profiles/research/config.yaml")?.artifact.id, + ).toBe("config"); + expect( + findHermesManagedArtifact("/sandbox/.hermes/platforms/whatsapp/session/credentials.json") + ?.artifact.id, + ).toBe("whatsapp-session"); + expect( + findHermesManagedArtifact("/sandbox/.hermes/whatsapp/session/credentials.json")?.artifact.id, + ).toBe("legacy-whatsapp-session"); + expect( + findHermesManagedArtifact("/sandbox/.hermes/platforms/pairing/slack-pending.json"), + ).toMatchObject({ artifact: { id: "pairing-pending" }, pathRole: "migration-source" }); + expect( + findHermesManagedArtifact( + "/sandbox/.hermes/profiles/research/platforms/pairing/slack-pending.json", + ), + ).toMatchObject({ artifact: { id: "pairing-pending" }, pathRole: "migration-source" }); + expect( + findHermesManagedArtifact("/sandbox/.hermes/runtime/pairing/slack-pending.json"), + ).toMatchObject({ artifact: { id: "pairing-pending" }, pathRole: "target" }); + expect( + findHermesManagedArtifact("/sandbox/.hermes/pairing/slack-approved.json")?.artifact.id, + ).toBe("pairing-approved"); + expect( + findHermesManagedArtifact("/sandbox/.hermes/skills/reviewer/SKILL.md")?.artifact.id, + ).toBe("skills"); + expect(findHermesManagedArtifact("/sandbox/.hermes/runtime/future.tmp")?.artifact.id).toBe( + "runtime-directory", + ); + expect(findHermesManagedArtifact("/sandbox/.hermes/state/gateway.heartbeat")?.artifact.id).toBe( + "process-state-directory", + ); + expect( + findHermesManagedArtifact("/sandbox/.hermes/dashboard-home/MEMORY.md")?.artifact.id, + ).toBe("dashboard-memory"); + expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/future-state")).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/dashboard-home/future-state")).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/unmanaged.tmp")).toBeNull(); + expect(findHermesManagedArtifact("/outside/.hermes/config.yaml")).toBeNull(); + expect(findHermesManagedArtifact("/")).toBeNull(); + expect(() => findHermesManagedArtifact("/sandbox/.hermes/config.yaml/")).toThrow( + "canonical absolute path", + ); + }); + + it("maps default and named runtime layouts without widening durability (#8006)", () => { + const research = { kind: "named-profile", name: "research" } as const; + expect(resolveHermesArtifactPath(artifact("main-state-database"), { kind: "default" })).toBe( + "/sandbox/.hermes/runtime/state.db", + ); + expect(resolveHermesArtifactPath(artifact("main-state-database"), research)).toBe( + "/sandbox/.hermes/profiles/research/state.db", + ); + + for (const id of ["gateway-pid", "gateway-lock", "gateway-status"]) { + const basename = resolveHermesArtifactPath(artifact(id), { kind: "default" }) + .split("/") + .at(-1); + expect(resolveHermesArtifactPath(artifact(id), { kind: "default" })).toBe( + "/sandbox/.hermes/runtime/" + basename, + ); + expect(resolveHermesArtifactPath(artifact(id), research)).toBe( + "/sandbox/.hermes/profiles/research/runtime/" + basename, + ); + } + + expect(resolveHermesArtifactPath(artifact("channel-directory"), { kind: "default" })).toBe( + "/sandbox/.hermes/runtime/channel_directory.json", + ); + expect(resolveHermesArtifactPath(artifact("channel-directory"), research)).toBe( + "/sandbox/.hermes/profiles/research/channel_directory.json", + ); + expect(resolveHermesArtifactPath(artifact("channel-aliases"), { kind: "default" })).toBe( + "/sandbox/.hermes/channel_aliases.json", + ); + expect(resolveHermesArtifactPath(artifact("channel-aliases"), research)).toBe( + "/sandbox/.hermes/profiles/research/channel_aliases.json", + ); + expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/kanban.db")).toBeNull(); + }); + + it("separates unsupported direct-lifecycle residuals from managed paths (#8006)", () => { + expect(HERMES_UNSUPPORTED_RESIDUAL_PATHS).toEqual([ + { + id: "legacy-gateway-pid", + scope: "default-home", + relativePath: "gateway.pid", + match: "exact", + disposition: "discard", + }, + { + id: "legacy-cron-pid", + scope: "default-home", + relativePath: "cron.pid", + match: "exact", + disposition: "discard", + }, + { + id: "named-gateway-pid", + scope: "named-profile", + relativePath: "gateway.pid", + match: "exact", + disposition: "discard", + }, + { + id: "named-cron-pid", + scope: "named-profile", + relativePath: "cron.pid", + match: "exact", + disposition: "discard", + }, + { + id: "named-gateway-lock", + scope: "named-profile", + relativePath: "gateway.lock", + match: "exact", + disposition: "discard", + }, + { + id: "named-gateway-status", + scope: "named-profile", + relativePath: "gateway_state.json", + match: "exact", + disposition: "discard", + }, + { + id: "upstream-weixin-account-credential", + scope: "agent-home", + relativePath: "weixin/accounts/{account}.json", + match: "pattern", + disposition: "discard", + }, + { + id: "legacy-skill-usage-lock", + scope: "agent-home", + relativePath: "skills/.usage.json.lock", + match: "exact", + disposition: "discard", + }, + ]); + expect(findHermesManagedArtifact("/sandbox/.hermes/gateway.pid")).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/cron.pid")).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/gateway.pid")).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/cron.pid")).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/gateway.lock")).toBeNull(); + expect( + findHermesManagedArtifact("/sandbox/.hermes/profiles/research/gateway_state.json"), + ).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/weixin/accounts/primary.json")).toBeNull(); + expect( + findHermesManagedArtifact("/sandbox/.hermes/profiles/research/weixin/accounts/primary.json"), + ).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/skills/.usage.json.lock")).toBeNull(); + }); + + it("resolves the pinned core profile surface for default and named homes (#8006)", () => { + const relativePaths = [ + "MEMORY.md", + "USER.md", + "todo.json", + "system_prompt.md", + "AGENTS.md", + "CLAUDE.md", + ".cursorrules", + "scripts/task.sh", + "knowledge/index.md", + "preferences/theme.json", + "home/.config/tool/settings.json", + "web_cache/index.json", + "delegation_cache/result.json", + "piper_voices_cache/en_US.onnx", + "document_cache/item.json", + "browser_screenshots/page.png", + "checkpoints/session/checkpoint.json", + "sandboxes/task/rootfs", + "backups/archive.tar.gz", + "state-snapshots/quick/state.json", + "bin/tirith", + "local/runtime.json", + "processes.json", + ".drain_request.json", + "state/gateway.heartbeat", + "gateway-starts.log", + "auth.lock", + "response_store.db", + "verification_evidence.db", + "feishu_comment_rules.json", + "feishu_comment_pairing.json", + ]; + + for (const relativePath of relativePaths) { + expect( + findHermesManagedArtifact("/sandbox/.hermes/" + relativePath), + relativePath, + ).not.toBeNull(); + expect( + findHermesManagedArtifact("/sandbox/.hermes/profiles/research/" + relativePath), + relativePath, + ).not.toBeNull(); + } + expect(findHermesManagedArtifact("/sandbox/.hermes/active_profile")?.artifact.id).toBe( + "active-profile", + ); + expect( + findHermesManagedArtifact("/sandbox/.hermes/kanban/boards/roadmap/kanban.db")?.artifact.id, + ).toBe("kanban-board-database"); + expect( + findHermesManagedArtifact("/sandbox/.hermes/kanban/workspaces/task/output")?.artifact.id, + ).toBe("kanban-workspaces"); + expect( + findHermesManagedArtifact("/sandbox/.hermes/dashboard-home/kanban.db")?.artifact.id, + ).toBe("dashboard-kanban-database"); + for (const [relativePath, id] of [ + ["web_cache/index.json", "legacy-web-cache"], + ["delegation_cache/result.json", "legacy-delegation-cache"], + ["piper_voices_cache/en_US.onnx", "legacy-piper-voices-cache"], + ] as const) { + expect(findHermesManagedArtifact("/sandbox/.hermes/" + relativePath)?.artifact).toMatchObject( + { + id, + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + ); + } + }); + + it.each([ + "", + ".", + "..", + "team/alpha", + "team\\alpha", + "bad\nname", + "Uppercase", + "has space", + "default", + ])("rejects the unsafe profile name %j (#8006)", (name) => { + expect(() => resolveHermesHomePath({ kind: "named-profile", name })).toThrow( + "must match [a-z0-9][a-z0-9_-]{0,63}", + ); + }); + + it("keeps default and named ownership equal while their recovery policy can differ (#8006)", () => { + const defaultHome = { kind: "default" } as const; + const namedHome = { kind: "named-profile", name: "research" } as const; + + for (const id of ["config", "environment", "runtime-directory", "main-state-database"]) { + const entry = artifact(id); + const defaultRule = findHermesManagedArtifact(resolveHermesArtifactPath(entry, defaultHome)); + const namedRule = findHermesManagedArtifact(resolveHermesArtifactPath(entry, namedHome)); + expect(defaultRule?.artifact.id).toBe(id); + expect(namedRule?.artifact.id).toBe(id); + expect(defaultRule?.artifact.required).toEqual(namedRule?.artifact.required); + expect(defaultRule?.home).toEqual(defaultHome); + expect(namedRule?.home).toEqual(namedHome); + expect(resolveHermesArtifactPath(entry, defaultHome)).not.toBe( + resolveHermesArtifactPath(entry, namedHome), + ); + } + + expect(resolveHermesArtifactBackup(artifact("config"), defaultHome)).toBe("exclude"); + expect(resolveHermesArtifactRestore(artifact("config"), defaultHome)).toBe("regenerate"); + expect(resolveHermesArtifactBackup(artifact("config"), namedHome)).toBe("file"); + expect(resolveHermesArtifactRestore(artifact("config"), namedHome)).toBe("restore"); + expect(resolveHermesArtifactBackup(artifact("environment"), namedHome)).toBe("exclude"); + expect(resolveHermesArtifactRestore(artifact("environment"), namedHome)).toBe("regenerate"); + expect( + resolveHermesPosture(artifact("environment").required.create, "root-separated"), + ).toMatchObject({ owner: "sandbox", mode: "0640" }); + expect(resolveHermesPosture(artifact("environment").required.create, "same-uid")).toMatchObject( + { owner: "sandbox", mode: "0600" }, + ); + }); + + it("distinguishes topology, tree contents, and restore transitions (#8006)", () => { + expect( + resolveHermesPosture(artifact("agent-home-root").required.create, "root-separated"), + ).toEqual({ + kind: "path", + owner: "root", + group: "sandbox", + mode: "03770", + }); + expect( + resolveHermesPosture(artifact("agent-home-root").required.shields!.up, "root-separated"), + ).toEqual({ + kind: "path", + owner: "root", + group: "sandbox", + mode: "03770", + }); + + expect( + resolveHermesPosture( + artifact("runtime-directory").required.create, + "root-separated", + "gateway", + ), + ).toMatchObject({ + kind: "tree", + owner: "gateway", + descendantOwner: "gateway", + group: "sandbox", + directoryMode: "03770", + }); + expect( + resolveHermesPosture(artifact("runtime-directory").required.create, "same-uid", "sandbox"), + ).toMatchObject({ + kind: "tree", + owner: "sandbox", + descendantOwner: "sandbox", + group: "sandbox", + directoryMode: "03770", + }); + expect( + resolveHermesPosture(artifact("authentication-state").required.create, "root-separated"), + ).toEqual({ + kind: "path", + owner: "gateway", + group: "sandbox", + mode: "0600", + }); + expect(artifact("authentication-state").required.restore).toBe("absent"); + expect(artifact("authentication-state").artifactClass).toBe("credential-reference"); + expect(artifact("telegram-sticker-cache")).toMatchObject({ + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }); + expect( + resolveHermesPosture(artifact("telegram-sticker-cache").required.create, "root-separated"), + ).toMatchObject({ owner: "gateway", mode: "0600" }); + expect( + resolveHermesPosture(artifact("authentication-lock").required.create, "same-uid"), + ).toMatchObject({ owner: "sandbox", mode: "0600" }); + expect(() => + resolveHermesPosture(artifact("drain-request").required.create, "root-separated"), + ).toThrow("producer identity is required"); + expect( + resolveHermesPosture(artifact("drain-request").required.create, "root-separated", "sandbox"), + ).toMatchObject({ owner: "sandbox", mode: "0660" }); + + expect(resolveHermesPosture(artifact("skills").required.shields!.up, "root-separated")).toEqual( + { + kind: "tree", + owner: "root", + descendantOwner: "root", + group: "sandbox", + directoryMode: "0755", + fileMode: "go-w", + symlinks: "ownership-only", + }, + ); + expect(artifact("pairing-state").required.shields).toBeDefined(); + expect( + resolveHermesPosture(artifact("pairing-state").required.shields!.up, "root-separated"), + ).toEqual({ + kind: "tree", + owner: "root", + descendantOwner: "root", + group: "sandbox", + directoryMode: "0755", + fileMode: "go-w", + symlinks: "ownership-only", + }); + expect( + resolveHermesPosture(artifact("dashboard-home").required.create, "root-separated"), + ).toMatchObject({ + kind: "tree", + directoryMode: "0700", + fileMode: "0600", + }); + + const restoredMainDatabase = artifact("main-state-database").required.restore; + if (restoredMainDatabase === "absent") throw new Error("Main state database must restore"); + expect(resolveHermesPosture(restoredMainDatabase, "root-separated")).toEqual({ + kind: "path", + owner: "sandbox", + group: "sandbox", + mode: "0660", + }); + expect( + resolveHermesPosture( + artifact("cron-execution-database").required.create, + "root-separated", + "gateway", + ), + ).toMatchObject({ + owner: "gateway", + mode: "0660", + }); + expect(findHermesManagedArtifact("/sandbox/.hermes/cron/executions.db")).toMatchObject({ + artifact: { id: "cron-execution-database", backup: "sqlite" }, + pathRole: "migration-source", + }); + expect(artifact("main-state-sidecar-wal").required.restore).toBe("absent"); + expect(artifact("restart-seal-marker").required.restore).toBe("absent"); + expect(artifact("tirith-retry-marker").required.restore).toBe("absent"); + expect( + resolveHermesPosture(artifact("gateway-lock").required.create, "root-separated"), + ).toMatchObject({ owner: "gateway", mode: "0660" }); + expect( + resolveHermesPosture(artifact("tirith-retry-marker").required.create, "root-separated"), + ).toMatchObject({ owner: "gateway", mode: "0660" }); + }); + + it("names every identity that creates shared and transient artifacts (#8006)", () => { + expect(resolveHermesIdentities(artifact("skills").producers, "root-separated")).toEqual([ + "root", + "sandbox", + "gateway", + ]); + expect( + resolveHermesIdentities(artifact("main-state-database").producers, "root-separated"), + ).toEqual(["root", "sandbox", "gateway"]); + expect(resolveHermesIdentities(artifact("tui-history").producers, "root-separated")).toEqual([ + "root", + "sandbox", + ]); + expect( + resolveHermesIdentities(artifact("tirith-retry-marker").producers, "root-separated"), + ).toEqual(["gateway"]); + expect(resolveHermesIdentities(artifact("tirith-retry-marker").producers, "same-uid")).toEqual([ + "sandbox", + ]); + expect( + resolveHermesIdentities(artifact("agent-home-root").producers, "root-separated"), + ).toEqual(["root"]); + }); + + it("classifies SQLite sidecars as disposable across every home layout (#8006)", () => { + const paths = new Map([ + ["/sandbox/.hermes/runtime/state.db-journal", "main-state-sidecar-journal"], + ["/sandbox/.hermes/profiles/research/state.db-wal", "main-state-sidecar-wal"], + ["/sandbox/.hermes/runtime/cron-executions.db-shm", "cron-execution-sidecar-shm"], + [ + "/sandbox/.hermes/gateway/discord_message_recovery.db-journal", + "discord-recovery-sidecar-journal", + ], + ["/sandbox/.hermes/kanban.db-journal", "default-kanban-sidecar-journal"], + ["/sandbox/.hermes/dashboard-home/kanban.db-journal", "dashboard-kanban-sidecar-journal"], + ["/sandbox/.hermes/projects.db-journal", "projects-database-sidecar-journal"], + [ + "/sandbox/.hermes/profiles/research/response_store.db-journal", + "response-store-database-sidecar-journal", + ], + ["/sandbox/.hermes/memory_store.db-wal", "memory-store-database-sidecar-wal"], + [ + "/sandbox/.hermes/verification_evidence.db-shm", + "verification-evidence-database-sidecar-shm", + ], + ] as const); + + for (const [target, id] of paths) { + expect(findHermesManagedArtifact(target)?.artifact, target).toMatchObject({ + id, + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + }); + } + for (const suffix of ["wal", "shm", "journal"] as const) { + const source = findHermesManagedArtifact("/sandbox/.hermes/cron/executions.db-" + suffix); + expect(source, suffix).toMatchObject({ + artifact: { + id: "cron-execution-sidecar-" + suffix, + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + }, + pathRole: "migration-source", + }); + expect(source?.artifact.migrationSources).toContainEqual({ + relativePath: "cron/executions.db-" + suffix, + match: "exact", + action: "migrate", + onFailure: "leave-source", + }); + } + expect(findHermesManagedArtifact("/sandbox/.hermes/projects.db-backup")).toBeNull(); + expect(findHermesManagedArtifact("/sandbox/.hermes/kanban.db-future")).toBeNull(); + expect( + findHermesManagedArtifact("/sandbox/.hermes/dashboard-home/kanban.db-future"), + ).toBeNull(); + expect( + findHermesManagedArtifact("/sandbox/.hermes/kanban/boards/release/kanban.db-future")?.artifact + .id, + ).toBe("kanban-state"); + + for (const prefix of [ + "main-state-sidecar", + "cron-execution-sidecar", + "discord-recovery-sidecar", + "default-kanban-sidecar", + "dashboard-kanban-sidecar", + "projects-database-sidecar", + "response-store-database-sidecar", + "memory-store-database-sidecar", + "verification-evidence-database-sidecar", + "kanban-board-database-sidecar", + "archived-kanban-board-database-sidecar", + "dashboard-kanban-board-database-sidecar", + "dashboard-archived-kanban-board-database-sidecar", + ]) { + for (const suffix of ["wal", "shm", "journal"]) { + expect(artifact(prefix + "-" + suffix).backup, prefix).toBe("exclude"); + } + } + }); + + it("separates advisory locks from the durable trees they protect (#8006)", () => { + const runtimePaths = new Map([ + ["runtime/cron/.tick.lock", "cron-tick-lock"], + ["runtime/cron/.jobs.lock", "cron-jobs-lock"], + ["runtime/cron/ticker_heartbeat", "cron-ticker-heartbeat"], + ["runtime/cron/ticker_last_success", "cron-ticker-last-success"], + ["memories/MEMORY.md.lock", "memory-document-lock"], + ["memories/USER.md.lock", "user-document-lock"], + ["runtime/skill-usage.json.lock", "skill-usage-lock"], + ["kanban/.dispatcher.lock", "kanban-dispatcher-lock"], + ["shell-hooks-allowlist.json.lock", "shell-hook-allowlist-lock"], + [".sync.lock", "environment-sync-lock"], + [".clean_shutdown", "clean-shutdown-marker"], + [".restart_pending.json", "restart-pending"], + [".restart_failure_counts", "restart-failure-counts"], + ] as const); + + for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { + for (const [relativePath, id] of runtimePaths) { + expect( + findHermesManagedArtifact(home + "/" + relativePath)?.artifact, + relativePath, + ).toMatchObject({ + id, + artifactClass: "mutable-runtime-state", + backup: "exclude", + restore: "discard", + shields: "keep-writable", + }); + } + } + + expect( + findHermesManagedArtifact("/sandbox/.hermes/runtime/skills-prompt-snapshot.json"), + ).toMatchObject({ + artifact: { + id: "skills-prompt-snapshot", + artifactClass: "derived-disposable-state", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + pathRole: "target", + }); + expect( + findHermesManagedArtifact("/sandbox/.hermes/.skills_prompt_snapshot.json"), + ).toMatchObject({ + artifact: { id: "skills-prompt-snapshot" }, + pathRole: "migration-source", + }); + + for (const [relativePath, id] of [ + ["cron/.tick.lock", "cron-tick-lock"], + ["cron/.jobs.lock", "cron-jobs-lock"], + ["cron/ticker_heartbeat", "cron-ticker-heartbeat"], + ["cron/ticker_last_success", "cron-ticker-last-success"], + ] as const) { + const source = findHermesManagedArtifact("/sandbox/.hermes/" + relativePath); + expect(source?.artifact.id).toBe(id); + expect(source?.pathRole).toBe("migration-source"); + expect(source?.artifact.migrationSources).toContainEqual({ + relativePath, + match: "exact", + action: "discard", + }); + } + + for (const [id, relativePath, durablePath] of [ + ["cron-definitions-root", ".tick.lock", "jobs.json"], + ["memories", "MEMORY.md.lock", "notes.json"], + ["skills", ".usage.json.lock", "reviewer/SKILL.md"], + ["kanban-state", ".dispatcher.lock", "current"], + ] as const) { + const backup = resolveHermesArtifactBackup(artifact(id), { kind: "default" }); + expect(resolveHermesBackupAction(backup, relativePath), id).toBe("exclude"); + expect(resolveHermesBackupAction(backup, durablePath), id).toBe("file"); + } + + for (const id of [ + "cron-job-runtime-state", + "cron-suggestions", + "cron-output", + "skill-usage-state", + ] as const) { + expect(artifact(id)).toMatchObject({ + artifactClass: "durable-state", + shields: "keep-writable", + restore: "restore", + migration: "preserve", + }); + } + expect(artifact("cron-job-definitions")).toMatchObject({ + artifactClass: "protected-configuration", + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }); + expect(findHermesManagedArtifact("/sandbox/.hermes/skills/.usage.json")).toMatchObject({ + artifact: { id: "skill-usage-state" }, + pathRole: "migration-source", + }); + }); +}); diff --git a/src/lib/agent/hermes-path-ownership.ts b/src/lib/agent/hermes-path-ownership.ts new file mode 100644 index 00000000000..726fb2d76e5 --- /dev/null +++ b/src/lib/agent/hermes-path-ownership.ts @@ -0,0 +1,3924 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +/** + * Required Hermes ownership after each lifecycle phase. + * + * This is the target contract for the #8006 delivery stack. Characterization + * tests keep the current Docker, startup, Shields, backup, and restore + * implementations visible until later PRs migrate those consumers. + */ + +export type HermesIdentity = "root" | "sandbox" | "gateway"; +export type HermesOwner = HermesIdentity | "producer"; +export type HermesContentOwner = HermesOwner | "preserve"; +export type HermesGroup = "root" | "sandbox"; +export type HermesRuntimeTopology = "root-separated" | "same-uid"; +export type HermesHomeKind = "default" | "named-profile" | "dashboard"; +export type HermesArtifactScope = "agent-home" | "default-home" | "named-profile" | "dashboard"; +export type HermesArtifactKind = "file" | "directory" | "symlink"; +export type HermesArtifactPresence = "required" | "optional"; +export type HermesArtifactClass = + | "protected-configuration" + | "credential-reference" + | "mutable-runtime-state" + | "durable-state" + | "derived-disposable-state"; +export type HermesPathMatch = "exact" | "subtree" | "pattern"; +export type HermesMode = + | "03770" + | "02770" + | "0770" + | "0755" + | "0700" + | "0660" + | "0644" + | "0640" + | "0600" + | "0555" + | "0444" + | "0400" + | "0777"; +export type HermesShieldsBehavior = "seal" | "keep-writable" | "unchanged"; +export type HermesBackupAction = "directory" | "file" | "sqlite" | "exclude"; +export type HermesBackupBehavior = HermesBackupAction | HermesSelectiveBackup; +export type HermesRestoreBehavior = "restore" | "regenerate" | "discard"; +export type HermesMigrationRule = "preserve" | "regenerate" | "discard"; +export type HermesTreeFileMode = HermesMode | "g+rwX,o-rwx" | "go-w" | "u-only" | "preserve"; + +export interface HermesBackupSelector { + readonly relativePattern: string; + readonly action: Exclude; +} + +export interface HermesSelectiveBackup { + readonly kind: "selective"; + readonly fallback: "exclude"; + readonly selectors: readonly HermesBackupSelector[]; +} + +export type HermesMigrationSource = + | { + readonly relativePath: string; + readonly match: HermesPathMatch; + readonly action: "migrate"; + readonly onFailure: "leave-source"; + } + | { + readonly relativePath: string; + readonly match: HermesPathMatch; + readonly action: "discard"; + }; + +export interface HermesPathPosture { + readonly kind: "path"; + readonly owner: HermesOwner; + readonly group: HermesGroup; + readonly mode: HermesMode; +} + +export interface HermesTreePosture { + readonly kind: "tree"; + /** Owner of the managed subtree root itself. */ + readonly owner: HermesIdentity; + /** Owner required for descendants created by a lifecycle producer. */ + readonly descendantOwner: HermesContentOwner; + readonly group: HermesGroup; + readonly directoryMode: HermesMode; + readonly fileMode: HermesTreeFileMode; + readonly symlinks: "ownership-only"; +} + +export type HermesPosture = HermesPathPosture | HermesTreePosture; +export type HermesConcretePosture = + | (Omit & { readonly owner: HermesIdentity }) + | (Omit & { + readonly descendantOwner: HermesIdentity | "preserve"; + }); + +export interface HermesTopologyPostures { + readonly rootSeparated: HermesPosture; + readonly sameUid: HermesPosture; +} + +export interface HermesTopologyIdentities { + readonly rootSeparated: readonly HermesIdentity[]; + readonly sameUid: readonly HermesIdentity[]; +} + +export type HermesIdentityRule = readonly HermesIdentity[] | HermesTopologyIdentities; + +export type HermesPostureRequirement = HermesPosture | HermesTopologyPostures; + +export interface HermesRequiredPostures { + /** Postcondition after an artifact producer finishes its create or repair action. */ + readonly create: HermesPostureRequirement; + /** Postcondition after restore filtering and contract-required regeneration, before startup. */ + readonly restore: HermesPostureRequirement | "absent"; + /** Postconditions after a completed Shields transition. */ + readonly shields?: { + readonly up: HermesPostureRequirement; + readonly down: HermesPostureRequirement; + }; +} + +export interface HermesHomeRelativePaths { + readonly default: string; + readonly namedProfile: string; +} + +export interface HermesManagedArtifact { + readonly id: string; + readonly scope: HermesArtifactScope; + readonly relativePath: string | HermesHomeRelativePaths; + readonly match: HermesPathMatch; + readonly kind: HermesArtifactKind; + /** Whether the artifact must exist after create, or only has a contract when present. */ + readonly presence: HermesArtifactPresence; + readonly artifactClass: HermesArtifactClass; + /** Identities that may create or replace the artifact across all supported topologies. */ + readonly producers: HermesIdentityRule; + /** Identities that consume the artifact when its lifecycle posture grants access. */ + readonly readers: HermesIdentityRule; + readonly required: HermesRequiredPostures; + readonly shields: HermesShieldsBehavior; + readonly backup: HermesHomeRule; + readonly restore: HermesHomeRule; + readonly migration: HermesMigrationRule; + /** Older in-home paths consumed only by the artifact's migration step. */ + readonly migrationSources?: readonly HermesMigrationSource[]; +} + +export type HermesHomeRule = + | T + | { + readonly default: T; + readonly namedProfile: T; + }; + +export type HermesHome = + | { readonly kind: "default" } + | { readonly kind: "named-profile"; readonly name: string } + | { readonly kind: "dashboard" }; + +export interface ResolvedHermesArtifact { + readonly artifact: HermesManagedArtifact; + readonly home: HermesHome; + readonly pathRole: "target" | "migration-source"; +} + +export interface HermesUnsupportedResidualPath { + readonly id: string; + readonly scope: "agent-home" | "default-home" | "named-profile"; + readonly relativePath: string; + readonly match: "exact" | "pattern"; + readonly disposition: "discard"; +} + +export interface HermesContractGap { + readonly id: string; + readonly currentPaths: readonly string[]; + readonly targetArtifactIds: readonly string[]; + readonly consumer: string; + readonly migration: "record-level" | "path-relocation" | "privileged-transition"; + readonly failure: "retain-source"; + readonly limitation?: string; +} + +type HermesArtifactTemplate = Omit; + +export const HERMES_DEFAULT_HOME = "/sandbox/.hermes"; +export const HERMES_NAMED_PROFILES_DIRECTORY = "profiles"; +export const HERMES_DASHBOARD_DIRECTORY = "dashboard-home"; +export const HERMES_LEGACY_HOME = "/sandbox/.hermes-data"; + +export const HERMES_LEGACY_MIGRATION = { + source: HERMES_LEGACY_HOME, + destination: HERMES_DEFAULT_HOME, + failure: { + required: "retain-source", + knownGap: "source-removal-precedes-final-validation", + }, +} as const; + +export const HERMES_CONTRACT_GAPS = [ + { + id: "cron-jobs-schema-split", + currentPaths: ["cron/jobs.json"], + targetArtifactIds: ["cron-job-definitions", "cron-job-runtime-state"], + consumer: "pinned Hermes cron.jobs and cron.scheduler", + migration: "record-level", + failure: "retain-source", + }, + { + id: "curator-runtime-relocation", + currentPaths: ["skills/.curator_state", "skills/.curator_backups"], + targetArtifactIds: ["curator-state", "curator-recovery"], + consumer: "pinned Hermes agent.curator and agent.curator_backup", + migration: "path-relocation", + failure: "retain-source", + }, + { + id: "skills-protected-transition", + currentPaths: ["skills", "skills/.archive", "skills/.curator_suppressed"], + targetArtifactIds: ["skills", "curator-archive", "curator-suppression"], + consumer: + "pinned Hermes Curator, tools.skill_manager_tool, tools.skills_sync, and gateway startup", + migration: "privileged-transition", + failure: "retain-source", + }, + { + id: "lsp-install-privileged-transition", + currentPaths: ["lsp", "lsp/bin", "lsp/node_modules", "lsp/python-packages"], + targetArtifactIds: ["lsp-cache"], + consumer: "pinned Hermes agent.lsp.install and agent.lsp.servers", + migration: "privileged-transition", + failure: "retain-source", + }, + { + id: "pairing-runtime-relocation", + currentPaths: [ + "pairing/{platform}-pending.json", + "pairing/_rate_limits.json", + "platforms/pairing/{platform}-pending.json", + "platforms/pairing/_rate_limits.json", + ], + targetArtifactIds: ["pairing-runtime-state", "pairing-pending", "pairing-rate-limits"], + consumer: "pinned Hermes PairingStore pending request and rate-limit writers", + migration: "path-relocation", + failure: "retain-source", + }, + { + id: "pairing-approval-privileged-transition", + currentPaths: [ + "pairing/{platform}-approved.json", + "platforms/pairing/{platform}-approved.json", + "feishu_comment_pairing.json", + ], + targetArtifactIds: [ + "pairing-approved", + "pairing-approved-write-staging", + "feishu-comment-pairing", + "feishu-comment-pairing-write-staging", + ], + consumer: "pinned Hermes pairing approval, dashboard, and Feishu comment pairing writers", + migration: "privileged-transition", + failure: "retain-source", + }, + { + id: "whatsapp-session-secure-handoff", + currentPaths: ["platforms/whatsapp/session", "whatsapp/session"], + targetArtifactIds: ["whatsapp-session", "legacy-whatsapp-session"], + consumer: + "pinned Hermes WhatsApp bridge Baileys multi-file authentication state and NemoClaw snapshot/rebuild restore", + migration: "privileged-transition", + failure: "retain-source", + limitation: "same-uid topology cannot isolate gateway session credentials from sandbox code", + }, +] as const satisfies readonly HermesContractGap[]; + +/** Upstream direct-lifecycle leftovers that NemoClaw cleans up but does not support. */ +export const HERMES_UNSUPPORTED_RESIDUAL_PATHS = [ + { + id: "legacy-gateway-pid", + scope: "default-home", + relativePath: "gateway.pid", + match: "exact", + disposition: "discard", + }, + { + id: "legacy-cron-pid", + scope: "default-home", + relativePath: "cron.pid", + match: "exact", + disposition: "discard", + }, + { + id: "named-gateway-pid", + scope: "named-profile", + relativePath: "gateway.pid", + match: "exact", + disposition: "discard", + }, + { + id: "named-cron-pid", + scope: "named-profile", + relativePath: "cron.pid", + match: "exact", + disposition: "discard", + }, + { + id: "named-gateway-lock", + scope: "named-profile", + relativePath: "gateway.lock", + match: "exact", + disposition: "discard", + }, + { + id: "named-gateway-status", + scope: "named-profile", + relativePath: "gateway_state.json", + match: "exact", + disposition: "discard", + }, + { + id: "upstream-weixin-account-credential", + scope: "agent-home", + relativePath: "weixin/accounts/{account}.json", + match: "pattern", + disposition: "discard", + }, + { + id: "legacy-skill-usage-lock", + scope: "agent-home", + relativePath: "skills/.usage.json.lock", + match: "exact", + disposition: "discard", + }, +] as const satisfies readonly HermesUnsupportedResidualPath[]; + +const ROOT_HOME = { + kind: "path", + owner: "root", + group: "sandbox", + mode: "03770", +} as const satisfies HermesPathPosture; +const SANDBOX_DIRECTORY = { + kind: "tree", + owner: "sandbox", + descendantOwner: "sandbox", + group: "sandbox", + directoryMode: "0770", + fileMode: "g+rwX,o-rwx", + symlinks: "ownership-only", +} as const satisfies HermesTreePosture; +const SANDBOX_WRITABLE_DIRECTORY = { + ...SANDBOX_DIRECTORY, + descendantOwner: "producer", +} as const satisfies HermesTreePosture; +const SANDBOX_SETGID_DIRECTORY = { + kind: "tree", + owner: "sandbox", + descendantOwner: "sandbox", + group: "sandbox", + directoryMode: "02770", + fileMode: "g+rwX,o-rwx", + symlinks: "ownership-only", +} as const satisfies HermesTreePosture; +const SANDBOX_WRITABLE_SETGID_DIRECTORY = { + ...SANDBOX_SETGID_DIRECTORY, + descendantOwner: "producer", +} as const satisfies HermesTreePosture; +const SANDBOX_STICKY_DIRECTORY = { + ...SANDBOX_WRITABLE_SETGID_DIRECTORY, + directoryMode: "03770", +} as const satisfies HermesTreePosture; +const SANDBOX_PRESERVED_SETGID_DIRECTORY = { + ...SANDBOX_SETGID_DIRECTORY, + descendantOwner: "preserve", + fileMode: "preserve", +} as const satisfies HermesTreePosture; +const ROOT_HIGH_RISK_DIRECTORY = { + kind: "tree", + owner: "root", + descendantOwner: "root", + group: "sandbox", + directoryMode: "0755", + fileMode: "go-w", + symlinks: "ownership-only", +} as const satisfies HermesTreePosture; +const ROOT_STICKY_DIRECTORY = { + kind: "tree", + owner: "root", + descendantOwner: "producer", + group: "sandbox", + directoryMode: "03770", + fileMode: "g+rwX,o-rwx", + symlinks: "ownership-only", +} as const satisfies HermesTreePosture; +const GATEWAY_DIRECTORY = { + kind: "tree", + owner: "gateway", + descendantOwner: "producer", + group: "sandbox", + directoryMode: "02770", + fileMode: "g+rwX,o-rwx", + symlinks: "ownership-only", +} as const satisfies HermesTreePosture; +const GATEWAY_STICKY_DIRECTORY = { + ...GATEWAY_DIRECTORY, + directoryMode: "03770", +} as const satisfies HermesTreePosture; +const GATEWAY_PRIVATE_DIRECTORY = { + kind: "tree", + owner: "gateway", + descendantOwner: "gateway", + group: "sandbox", + directoryMode: "0700", + fileMode: "u-only", + symlinks: "ownership-only", +} as const satisfies HermesTreePosture; +const DASHBOARD_DIRECTORY = { + kind: "tree", + owner: "sandbox", + descendantOwner: "sandbox", + group: "sandbox", + directoryMode: "0700", + fileMode: "0600", + symlinks: "ownership-only", +} as const satisfies HermesTreePosture; +const SANDBOX_FILE = { + kind: "path", + owner: "sandbox", + group: "sandbox", + mode: "0640", +} as const satisfies HermesPathPosture; +const SANDBOX_SHARED_FILE = { + kind: "path", + owner: "sandbox", + group: "sandbox", + mode: "0660", +} as const satisfies HermesPathPosture; +const SANDBOX_PRIVATE_FILE = { + kind: "path", + owner: "sandbox", + group: "sandbox", + mode: "0600", +} as const satisfies HermesPathPosture; +const PRODUCER_SHARED_FILE = { + kind: "path", + owner: "producer", + group: "sandbox", + mode: "0660", +} as const satisfies HermesPathPosture; +const PRODUCER_PRIVATE_FILE = { + kind: "path", + owner: "producer", + group: "sandbox", + mode: "0600", +} as const satisfies HermesPathPosture; +const PRODUCER_FILE = { + kind: "path", + owner: "producer", + group: "sandbox", + mode: "0640", +} as const satisfies HermesPathPosture; +const SANDBOX_IMAGE_DOCUMENT = { + kind: "path", + owner: "sandbox", + group: "sandbox", + mode: "0644", +} as const satisfies HermesPathPosture; +const ROOT_PROTECTED_FILE = { + kind: "path", + owner: "root", + group: "root", + mode: "0444", +} as const satisfies HermesPathPosture; +const ROOT_MIGRATION_MARKER = { + kind: "path", + owner: "root", + group: "root", + mode: "0444", +} as const satisfies HermesPathPosture; +const ROOT_RESTART_MARKER = { + kind: "path", + owner: "root", + group: "root", + mode: "0400", +} as const satisfies HermesPathPosture; +const ROOT_PRIVATE_FILE = { + kind: "path", + owner: "root", + group: "root", + mode: "0600", +} as const satisfies HermesPathPosture; +const GATEWAY_FILE = { + kind: "path", + owner: "gateway", + group: "sandbox", + mode: "0640", +} as const satisfies HermesPathPosture; +const GATEWAY_SHARED_FILE = { + kind: "path", + owner: "gateway", + group: "sandbox", + mode: "0660", +} as const satisfies HermesPathPosture; +const GATEWAY_PRIVATE_FILE = { + kind: "path", + owner: "gateway", + group: "sandbox", + mode: "0600", +} as const satisfies HermesPathPosture; +const SANDBOX_EXECUTABLE = { + kind: "path", + owner: "sandbox", + group: "sandbox", + mode: "0755", +} as const satisfies HermesPathPosture; +const GATEWAY_EXECUTABLE = { + kind: "path", + owner: "gateway", + group: "sandbox", + mode: "0755", +} as const satisfies HermesPathPosture; +const ROOT_EXECUTABLE = { + kind: "path", + owner: "root", + group: "root", + mode: "0555", +} as const satisfies HermesPathPosture; +const ROOT_COMPATIBILITY_LINK = { + kind: "path", + owner: "root", + group: "sandbox", + mode: "0777", +} as const satisfies HermesPathPosture; + +const GATEWAY_DIRECTORY_BY_TOPOLOGY = { + rootSeparated: GATEWAY_DIRECTORY, + sameUid: SANDBOX_WRITABLE_SETGID_DIRECTORY, +} as const satisfies HermesTopologyPostures; +const GATEWAY_STICKY_DIRECTORY_BY_TOPOLOGY = { + rootSeparated: GATEWAY_STICKY_DIRECTORY, + sameUid: SANDBOX_STICKY_DIRECTORY, +} as const satisfies HermesTopologyPostures; +const GATEWAY_PRIVATE_DIRECTORY_BY_TOPOLOGY = { + rootSeparated: GATEWAY_PRIVATE_DIRECTORY, + sameUid: DASHBOARD_DIRECTORY, +} as const satisfies HermesTopologyPostures; +const GATEWAY_FILE_BY_TOPOLOGY = { + rootSeparated: GATEWAY_FILE, + sameUid: SANDBOX_FILE, +} as const satisfies HermesTopologyPostures; +const GATEWAY_SHARED_FILE_BY_TOPOLOGY = { + rootSeparated: GATEWAY_SHARED_FILE, + sameUid: SANDBOX_SHARED_FILE, +} as const satisfies HermesTopologyPostures; +const GATEWAY_PRIVATE_FILE_BY_TOPOLOGY = { + rootSeparated: GATEWAY_PRIVATE_FILE, + sameUid: SANDBOX_PRIVATE_FILE, +} as const satisfies HermesTopologyPostures; +const GATEWAY_EXECUTABLE_BY_TOPOLOGY = { + rootSeparated: GATEWAY_EXECUTABLE, + sameUid: SANDBOX_EXECUTABLE, +} as const satisfies HermesTopologyPostures; +const CREDENTIAL_FILE_BY_TOPOLOGY = { + rootSeparated: SANDBOX_FILE, + sameUid: SANDBOX_PRIVATE_FILE, +} as const satisfies HermesTopologyPostures; + +const GATEWAY_ROLE = { + rootSeparated: ["gateway"], + sameUid: ["sandbox"], +} as const satisfies HermesTopologyIdentities; +const SANDBOX_AND_GATEWAY_ROLE = { + rootSeparated: ["sandbox", "gateway"], + sameUid: ["sandbox"], +} as const satisfies HermesTopologyIdentities; +const ROOT_AND_GATEWAY_ROLE = { + rootSeparated: ["root", "gateway"], + sameUid: ["root", "sandbox"], +} as const satisfies HermesTopologyIdentities; +const ALL_ROLES = { + rootSeparated: ["root", "sandbox", "gateway"], + sameUid: ["root", "sandbox"], +} as const satisfies HermesTopologyIdentities; + +const HOME_REQUIREMENTS = { + create: ROOT_HOME, + restore: ROOT_HOME, + shields: { up: ROOT_HOME, down: ROOT_HOME }, +} as const satisfies HermesRequiredPostures; +const WRITABLE_DIRECTORY_REQUIREMENTS = { + create: SANDBOX_WRITABLE_DIRECTORY, + restore: SANDBOX_DIRECTORY, +} as const satisfies HermesRequiredPostures; +const SETGID_DIRECTORY_REQUIREMENTS = { + create: SANDBOX_WRITABLE_SETGID_DIRECTORY, + restore: SANDBOX_SETGID_DIRECTORY, +} as const satisfies HermesRequiredPostures; +const PRESERVED_SETGID_DIRECTORY_REQUIREMENTS = { + create: SANDBOX_PRESERVED_SETGID_DIRECTORY, + restore: SANDBOX_PRESERVED_SETGID_DIRECTORY, +} as const satisfies HermesRequiredPostures; +const HIGH_RISK_DIRECTORY_REQUIREMENTS = { + create: SANDBOX_WRITABLE_DIRECTORY, + restore: SANDBOX_SETGID_DIRECTORY, + shields: { up: ROOT_HIGH_RISK_DIRECTORY, down: SANDBOX_SETGID_DIRECTORY }, +} as const satisfies HermesRequiredPostures; +const GATEWAY_DIRECTORY_REQUIREMENTS = { + create: GATEWAY_DIRECTORY_BY_TOPOLOGY, + restore: GATEWAY_DIRECTORY_BY_TOPOLOGY, +} as const satisfies HermesRequiredPostures; +const GATEWAY_STICKY_DIRECTORY_REQUIREMENTS = { + create: GATEWAY_STICKY_DIRECTORY_BY_TOPOLOGY, + restore: GATEWAY_STICKY_DIRECTORY_BY_TOPOLOGY, +} as const satisfies HermesRequiredPostures; +const GATEWAY_PRIVATE_DIRECTORY_DISCARD_REQUIREMENTS = { + create: GATEWAY_PRIVATE_DIRECTORY_BY_TOPOLOGY, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const ROOT_STICKY_DIRECTORY_REQUIREMENTS = { + create: ROOT_STICKY_DIRECTORY, + restore: ROOT_STICKY_DIRECTORY, +} as const satisfies HermesRequiredPostures; +const ROOT_PROTECTED_DIRECTORY_REQUIREMENTS = { + create: ROOT_HIGH_RISK_DIRECTORY, + restore: ROOT_HIGH_RISK_DIRECTORY, + shields: { up: ROOT_HIGH_RISK_DIRECTORY, down: ROOT_HIGH_RISK_DIRECTORY }, +} as const satisfies HermesRequiredPostures; +const GATEWAY_DIRECTORY_DISCARD_REQUIREMENTS = { + create: GATEWAY_DIRECTORY_BY_TOPOLOGY, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const DASHBOARD_DIRECTORY_REQUIREMENTS = { + create: DASHBOARD_DIRECTORY, + restore: DASHBOARD_DIRECTORY, +} as const satisfies HermesRequiredPostures; +const CONFIG_FILE_REQUIREMENTS = { + create: SANDBOX_FILE, + restore: SANDBOX_FILE, + shields: { up: ROOT_PROTECTED_FILE, down: SANDBOX_FILE }, +} as const satisfies HermesRequiredPostures; +const ROOT_PROTECTED_FILE_REQUIREMENTS = { + create: ROOT_PROTECTED_FILE, + restore: ROOT_PROTECTED_FILE, + shields: { up: ROOT_PROTECTED_FILE, down: ROOT_PROTECTED_FILE }, +} as const satisfies HermesRequiredPostures; +const ROOT_PRIVATE_DISCARD_REQUIREMENTS = { + create: ROOT_PRIVATE_FILE, + restore: "absent", + shields: { up: ROOT_PRIVATE_FILE, down: ROOT_PRIVATE_FILE }, +} as const satisfies HermesRequiredPostures; +const SHARED_CONFIG_FILE_REQUIREMENTS = { + create: PRODUCER_SHARED_FILE, + restore: SANDBOX_SHARED_FILE, + shields: { up: ROOT_PROTECTED_FILE, down: SANDBOX_SHARED_FILE }, +} as const satisfies HermesRequiredPostures; +const CREDENTIAL_FILE_REQUIREMENTS = { + create: CREDENTIAL_FILE_BY_TOPOLOGY, + restore: CREDENTIAL_FILE_BY_TOPOLOGY, + shields: { up: ROOT_PROTECTED_FILE, down: CREDENTIAL_FILE_BY_TOPOLOGY }, +} as const satisfies HermesRequiredPostures; +const PROTECTED_DOCUMENT_REQUIREMENTS = { + create: SANDBOX_IMAGE_DOCUMENT, + restore: SANDBOX_FILE, + shields: { up: ROOT_PROTECTED_FILE, down: SANDBOX_FILE }, +} as const satisfies HermesRequiredPostures; +const SANDBOX_SHARED_FILE_REQUIREMENTS = { + create: SANDBOX_SHARED_FILE, + restore: SANDBOX_SHARED_FILE, +} as const satisfies HermesRequiredPostures; +const PRODUCER_SHARED_FILE_REQUIREMENTS = { + create: PRODUCER_SHARED_FILE, + restore: SANDBOX_SHARED_FILE, +} as const satisfies HermesRequiredPostures; +const SANDBOX_PRIVATE_FILE_REQUIREMENTS = { + create: SANDBOX_PRIVATE_FILE, + restore: SANDBOX_PRIVATE_FILE, +} as const satisfies HermesRequiredPostures; +const SANDBOX_PRIVATE_DISCARD_REQUIREMENTS = { + create: SANDBOX_PRIVATE_FILE, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const PRODUCER_SHARED_DISCARD_REQUIREMENTS = { + create: PRODUCER_SHARED_FILE, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const PRODUCER_PRIVATE_DISCARD_REQUIREMENTS = { + create: PRODUCER_PRIVATE_FILE, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const SEALED_STAGING_FILE_REQUIREMENTS = { + create: PRODUCER_PRIVATE_FILE, + restore: "absent", + shields: { up: ROOT_RESTART_MARKER, down: SANDBOX_PRIVATE_FILE }, +} as const satisfies HermesRequiredPostures; +const PRODUCER_FILE_DISCARD_REQUIREMENTS = { + create: PRODUCER_FILE, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const GATEWAY_FILE_REQUIREMENTS = { + create: GATEWAY_FILE_BY_TOPOLOGY, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const GATEWAY_SHARED_DISCARD_REQUIREMENTS = { + create: GATEWAY_SHARED_FILE_BY_TOPOLOGY, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const GATEWAY_SHARED_FILE_REQUIREMENTS = { + create: GATEWAY_SHARED_FILE_BY_TOPOLOGY, + restore: SANDBOX_SHARED_FILE, +} as const satisfies HermesRequiredPostures; +const GATEWAY_PRIVATE_DISCARD_REQUIREMENTS = { + create: GATEWAY_PRIVATE_FILE_BY_TOPOLOGY, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const GATEWAY_PRIVATE_FILE_REQUIREMENTS = { + create: GATEWAY_PRIVATE_FILE_BY_TOPOLOGY, + restore: GATEWAY_PRIVATE_FILE_BY_TOPOLOGY, +} as const satisfies HermesRequiredPostures; +const GATEWAY_EXECUTABLE_REQUIREMENTS = { + create: GATEWAY_EXECUTABLE_BY_TOPOLOGY, + restore: GATEWAY_EXECUTABLE_BY_TOPOLOGY, + shields: { up: ROOT_EXECUTABLE, down: GATEWAY_EXECUTABLE_BY_TOPOLOGY }, +} as const satisfies HermesRequiredPostures; +const SQLITE_FILE_REQUIREMENTS = { + create: PRODUCER_SHARED_FILE, + restore: SANDBOX_SHARED_FILE, +} as const satisfies HermesRequiredPostures; +const PRIVATE_SQLITE_FILE_REQUIREMENTS = { + create: GATEWAY_PRIVATE_FILE_BY_TOPOLOGY, + restore: GATEWAY_PRIVATE_FILE_BY_TOPOLOGY, +} as const satisfies HermesRequiredPostures; +const SQLITE_SIDECAR_REQUIREMENTS = { + create: PRODUCER_SHARED_FILE, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const PRIVATE_SQLITE_SIDECAR_REQUIREMENTS = { + create: GATEWAY_PRIVATE_FILE_BY_TOPOLOGY, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const COMPATIBILITY_LINK_REQUIREMENTS = { + create: ROOT_COMPATIBILITY_LINK, + restore: ROOT_COMPATIBILITY_LINK, +} as const satisfies HermesRequiredPostures; +const MIGRATION_MARKER_REQUIREMENTS = { + create: ROOT_MIGRATION_MARKER, + restore: "absent", +} as const satisfies HermesRequiredPostures; +const RESTART_MARKER_REQUIREMENTS = { + create: ROOT_RESTART_MARKER, + restore: "absent", +} as const satisfies HermesRequiredPostures; + +const KANBAN_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: "current", action: "file" }, + { relativePattern: ".dispatcher.lock", action: "exclude" }, + { relativePattern: "attachments/**", action: "file" }, + { relativePattern: "boards/{board}/workspaces/**", action: "exclude" }, + { relativePattern: "boards/{board}/attachments/**", action: "file" }, + { relativePattern: "boards/{board}/logs/**", action: "exclude" }, + { relativePattern: "boards/{board}/kanban.db.{lock}.lock", action: "exclude" }, + { relativePattern: "boards/{board}/kanban.db.corrupt.{backup}", action: "file" }, + { relativePattern: "boards/{board}/kanban.db-wal", action: "exclude" }, + { relativePattern: "boards/{board}/kanban.db-shm", action: "exclude" }, + { relativePattern: "boards/{board}/kanban.db-journal", action: "exclude" }, + { relativePattern: "boards/{board}/kanban.db", action: "sqlite" }, + { relativePattern: "boards/_archived/{board}/workspaces/**", action: "exclude" }, + { relativePattern: "boards/_archived/{board}/attachments/**", action: "file" }, + { relativePattern: "boards/_archived/{board}/logs/**", action: "exclude" }, + { + relativePattern: "boards/_archived/{board}/kanban.db.{lock}.lock", + action: "exclude", + }, + { + relativePattern: "boards/_archived/{board}/kanban.db.corrupt.{backup}", + action: "file", + }, + { relativePattern: "boards/_archived/{board}/kanban.db-wal", action: "exclude" }, + { relativePattern: "boards/_archived/{board}/kanban.db-shm", action: "exclude" }, + { relativePattern: "boards/_archived/{board}/kanban.db-journal", action: "exclude" }, + { relativePattern: "boards/_archived/{board}/kanban.db", action: "sqlite" }, + { relativePattern: "boards/_archived/{board}/**", action: "file" }, + { relativePattern: "boards/{board}/**", action: "file" }, + ], +} as const satisfies HermesSelectiveBackup; +const MEMORIES_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: ".mem_{temp}.tmp", action: "exclude" }, + { relativePattern: "MEMORY.md.lock", action: "exclude" }, + { relativePattern: "USER.md.lock", action: "exclude" }, + { relativePattern: "**", action: "file" }, + ], +} as const satisfies HermesSelectiveBackup; +const SESSIONS_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: ".sessions_{temp}.tmp", action: "exclude" }, + { relativePattern: "**", action: "file" }, + ], +} as const satisfies HermesSelectiveBackup; +const SKILLS_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: ".{temp}.tmp", action: "exclude" }, + { relativePattern: "**/.{filename}.tmp.{temp}", action: "exclude" }, + { relativePattern: ".hub/.lock_{temp}.tmp", action: "exclude" }, + { relativePattern: ".usage.json.lock", action: "exclude" }, + { relativePattern: "**", action: "file" }, + ], +} as const satisfies HermesSelectiveBackup; +const PAIRING_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: "{platform}-approved.json", action: "file" }, + { relativePattern: "{platform}-pending.json", action: "exclude" }, + { relativePattern: "_rate_limits.json", action: "exclude" }, + { relativePattern: "tmp{temp}.tmp", action: "exclude" }, + ], +} as const satisfies HermesSelectiveBackup; +const CRON_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: ".{temp}.tmp", action: "exclude" }, + { relativePattern: "output/{job}/.output_{temp}.tmp", action: "exclude" }, + { relativePattern: ".tick.lock", action: "exclude" }, + { relativePattern: ".jobs.lock", action: "exclude" }, + { relativePattern: "ticker_heartbeat", action: "exclude" }, + { relativePattern: "ticker_last_success", action: "exclude" }, + { relativePattern: "**", action: "file" }, + ], +} as const satisfies HermesSelectiveBackup; +const WEIXIN_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: "accounts/{account}.context-tokens.json", action: "file" }, + { relativePattern: "accounts/{account}.sync.json", action: "exclude" }, + { relativePattern: "accounts/{account}.json", action: "exclude" }, + ], +} as const satisfies HermesSelectiveBackup; +const WEIXIN_ACCOUNTS_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: "{account}.context-tokens.json", action: "file" }, + { relativePattern: "{account}.sync.json", action: "exclude" }, + { relativePattern: "{account}.json", action: "exclude" }, + ], +} as const satisfies HermesSelectiveBackup; +const WHATSAPP_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: "bridge.log", action: "exclude" }, + { relativePattern: "session/**", action: "exclude" }, + ], +} as const satisfies HermesSelectiveBackup; +const PLATFORMS_BACKUP = { + kind: "selective", + fallback: "exclude", + selectors: [ + { relativePattern: "pairing/**", action: "exclude" }, + { relativePattern: "whatsapp/bridge.log", action: "exclude" }, + { relativePattern: "whatsapp/session/**", action: "exclude" }, + ], +} as const satisfies HermesSelectiveBackup; + +const DURABLE_WRITABLE_DIRECTORY = { + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "durable-state", + producers: ALL_ROLES, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: WRITABLE_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "directory", + restore: "restore", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const DURABLE_WRITABLE_SETGID_DIRECTORY = { + ...DURABLE_WRITABLE_DIRECTORY, + required: SETGID_DIRECTORY_REQUIREMENTS, +} as const satisfies HermesArtifactTemplate; +const DURABLE_HIGH_RISK_DIRECTORY = { + ...DURABLE_WRITABLE_DIRECTORY, + required: HIGH_RISK_DIRECTORY_REQUIREMENTS, + shields: "seal", +} as const satisfies HermesArtifactTemplate; +const MEMORIES_DIRECTORY = { + ...DURABLE_WRITABLE_DIRECTORY, + backup: MEMORIES_BACKUP, +} as const satisfies HermesArtifactTemplate; +const SESSIONS_DIRECTORY = { + ...DURABLE_WRITABLE_DIRECTORY, + backup: SESSIONS_BACKUP, +} as const satisfies HermesArtifactTemplate; +const SKILLS_DIRECTORY = { + ...DURABLE_HIGH_RISK_DIRECTORY, + artifactClass: "protected-configuration", + backup: SKILLS_BACKUP, +} as const satisfies HermesArtifactTemplate; +const PAIRING_DIRECTORY = { + match: "subtree", + kind: "directory", + presence: "required", + artifactClass: "protected-configuration", + producers: ["root"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: ROOT_PROTECTED_DIRECTORY_REQUIREMENTS, + shields: "seal", + backup: PAIRING_BACKUP, + restore: "restore", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const CRON_DEFINITIONS_DIRECTORY = { + ...DURABLE_HIGH_RISK_DIRECTORY, + artifactClass: "protected-configuration", + backup: CRON_BACKUP, +} as const satisfies HermesArtifactTemplate; +const DURABLE_SELECTIVE_DIRECTORY = { + ...DURABLE_WRITABLE_DIRECTORY, + backup: KANBAN_BACKUP, +} as const satisfies HermesArtifactTemplate; +const WEIXIN_STATE_DIRECTORY = { + ...DURABLE_WRITABLE_SETGID_DIRECTORY, + required: PRESERVED_SETGID_DIRECTORY_REQUIREMENTS, + artifactClass: "derived-disposable-state", + backup: WEIXIN_BACKUP, + restore: "regenerate", + migration: "regenerate", +} as const satisfies HermesArtifactTemplate; +const WEIXIN_ACCOUNTS_DIRECTORY = { + ...WEIXIN_STATE_DIRECTORY, + backup: WEIXIN_ACCOUNTS_BACKUP, +} as const satisfies HermesArtifactTemplate; +const PLATFORM_ROOT_DIRECTORY = { + ...DURABLE_WRITABLE_SETGID_DIRECTORY, + artifactClass: "derived-disposable-state", + required: ROOT_STICKY_DIRECTORY_REQUIREMENTS, + backup: PLATFORMS_BACKUP, + restore: "regenerate", + migration: "regenerate", +} as const satisfies HermesArtifactTemplate; +const WHATSAPP_ROOT_DIRECTORY = { + ...PLATFORM_ROOT_DIRECTORY, + backup: WHATSAPP_BACKUP, +} as const satisfies HermesArtifactTemplate; +const WHATSAPP_SESSION_DIRECTORY = { + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "credential-reference", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DIRECTORY_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const DERIVED_WRITABLE_DIRECTORY = { + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: ALL_ROLES, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: WRITABLE_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", +} as const satisfies HermesArtifactTemplate; +const DERIVED_HIGH_RISK_DIRECTORY = { + ...DERIVED_WRITABLE_DIRECTORY, + required: HIGH_RISK_DIRECTORY_REQUIREMENTS, + shields: "seal", +} as const satisfies HermesArtifactTemplate; +const SQLITE_SIDECAR = { + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SQLITE_SIDECAR_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const PRIVATE_SQLITE_SIDECAR = { + ...SQLITE_SIDECAR, + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: PRIVATE_SQLITE_SIDECAR_REQUIREMENTS, +} as const satisfies HermesArtifactTemplate; +const DURABLE_SQLITE_DATABASE = { + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: ALL_ROLES, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SQLITE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "sqlite", + restore: "restore", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const DURABLE_PRIVATE_SQLITE_DATABASE = { + ...DURABLE_SQLITE_DATABASE, + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: PRIVATE_SQLITE_FILE_REQUIREMENTS, +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_SQLITE_DATABASE = { + ...DURABLE_SQLITE_DATABASE, + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_FILE_REQUIREMENTS, +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_SQLITE_SIDECAR = { + ...SQLITE_SIDECAR, + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_DISCARD_REQUIREMENTS, +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_DERIVED_DIRECTORY = { + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: DASHBOARD_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_DURABLE_FILE = { + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const KANBAN_DATABASE = { + ...DURABLE_SQLITE_DATABASE, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const KANBAN_SIDECAR = { + ...SQLITE_SIDECAR, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const KANBAN_DERIVED_DIRECTORY = { + ...DERIVED_WRITABLE_DIRECTORY, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const KANBAN_DURABLE_DIRECTORY = { + ...DURABLE_WRITABLE_SETGID_DIRECTORY, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const KANBAN_HIGH_RISK_DIRECTORY = { + ...DERIVED_HIGH_RISK_DIRECTORY, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_KANBAN_DATABASE = { + ...DASHBOARD_SQLITE_DATABASE, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_KANBAN_SIDECAR = { + ...DASHBOARD_SQLITE_SIDECAR, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_KANBAN_DERIVED_DIRECTORY = { + ...DASHBOARD_DERIVED_DIRECTORY, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_KANBAN_DURABLE_DIRECTORY = { + match: "pattern", + kind: "directory", + presence: "optional", + artifactClass: "durable-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: DASHBOARD_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "directory", + restore: "restore", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const KANBAN_RUNTIME_FILE = { + match: "pattern", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_FILE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_KANBAN_RUNTIME_FILE = { + ...KANBAN_RUNTIME_FILE, + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_DISCARD_REQUIREMENTS, +} as const satisfies HermesArtifactTemplate; +const DURABLE_SHARED_FILE = { + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: ALL_ROLES, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const KANBAN_RECOVERY_FILE = { + ...DURABLE_SHARED_FILE, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_KANBAN_RECOVERY_FILE = { + ...DASHBOARD_DURABLE_FILE, + match: "pattern", +} as const satisfies HermesArtifactTemplate; +const PROTECTED_OPTIONAL_DOCUMENT = { + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ["root", "sandbox"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PROTECTED_DOCUMENT_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const PAIRING_APPROVED_FILE = { + match: "pattern", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ["root"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: ROOT_PROTECTED_FILE_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", +} as const satisfies HermesArtifactTemplate; +const PAIRING_RUNTIME_FILE = { + match: "pattern", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", +} as const satisfies HermesArtifactTemplate; +const WRITABLE_STAGING_FILE = { + match: "pattern", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: ALL_ROLES, + readers: ALL_ROLES, + required: PRODUCER_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", +} as const satisfies HermesArtifactTemplate; +const ROOT_PRIVATE_STAGING_FILE = { + match: "pattern", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: ["root"], + readers: ["root"], + required: ROOT_PRIVATE_DISCARD_REQUIREMENTS, + shields: "unchanged", + backup: "exclude", + restore: "discard", + migration: "discard", +} as const satisfies HermesArtifactTemplate; +const SEALED_STAGING_FILE = { + ...WRITABLE_STAGING_FILE, + required: SEALED_STAGING_FILE_REQUIREMENTS, + shields: "seal", +} as const satisfies HermesArtifactTemplate; +const GATEWAY_DERIVED_FILE = { + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", +} as const satisfies HermesArtifactTemplate; +const DASHBOARD_DERIVED_FILE = { + ...GATEWAY_DERIVED_FILE, + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_DISCARD_REQUIREMENTS, +} as const satisfies HermesArtifactTemplate; +const COMPATIBILITY_LINK = { + match: "exact", + kind: "symlink", + presence: "required", + artifactClass: "derived-disposable-state", + producers: ["root"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: COMPATIBILITY_LINK_REQUIREMENTS, + shields: "unchanged", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", +} as const satisfies HermesArtifactTemplate; + +const SQLITE_SIDECAR_SUFFIXES = ["wal", "shm", "journal"] as const; + +function createSqliteSidecars( + idPrefix: string, + scope: HermesArtifactScope, + relativePath: string | HermesHomeRelativePaths, + template: HermesArtifactTemplate, + migrationSourcePath?: string, +): HermesManagedArtifact[] { + const expand = (value: string, suffix: (typeof SQLITE_SIDECAR_SUFFIXES)[number]): string => { + const expanded = value.replace("{sidecar}", suffix); + if (expanded === value) throw new Error("SQLite sidecar paths require a {sidecar} placeholder"); + return expanded; + }; + + return SQLITE_SIDECAR_SUFFIXES.map((suffix) => { + const migrationSource = migrationSourcePath + ? ({ + migrationSources: [ + { + relativePath: expand(migrationSourcePath, suffix), + match: "exact", + action: "migrate", + onFailure: "leave-source", + }, + ], + } as const) + : {}; + return { + id: idPrefix + "-" + suffix, + scope, + relativePath: + typeof relativePath === "string" + ? expand(relativePath, suffix) + : { + default: expand(relativePath.default, suffix), + namedProfile: expand(relativePath.namedProfile, suffix), + }, + ...template, + ...migrationSource, + }; + }); +} + +function createPairingArtifacts(): HermesManagedArtifact[] { + return [ + { + id: "pairing-approved", + scope: "agent-home", + relativePath: "pairing/{platform}-approved.json", + ...PAIRING_APPROVED_FILE, + migrationSources: [ + { + relativePath: "platforms/pairing/{platform}-approved.json", + match: "pattern", + action: "migrate", + onFailure: "leave-source", + }, + ], + }, + { + id: "pairing-approved-write-staging", + scope: "agent-home", + relativePath: "pairing/tmp{temp}.tmp", + ...ROOT_PRIVATE_STAGING_FILE, + }, + { + id: "pairing-pending", + scope: "agent-home", + relativePath: "runtime/pairing/{platform}-pending.json", + ...PAIRING_RUNTIME_FILE, + migrationSources: [ + { relativePath: "pairing/{platform}-pending.json", match: "pattern", action: "discard" }, + { + relativePath: "platforms/pairing/{platform}-pending.json", + match: "pattern", + action: "discard", + }, + ], + }, + { + id: "pairing-rate-limits", + scope: "agent-home", + relativePath: "runtime/pairing/_rate_limits.json", + ...PAIRING_RUNTIME_FILE, + match: "exact", + migrationSources: [ + { relativePath: "pairing/_rate_limits.json", match: "exact", action: "discard" }, + { + relativePath: "platforms/pairing/_rate_limits.json", + match: "exact", + action: "discard", + }, + ], + }, + { + id: "pairing-write-staging", + scope: "agent-home", + relativePath: "runtime/pairing/tmp{temp}.tmp", + ...WRITABLE_STAGING_FILE, + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + migrationSources: [ + { + relativePath: "platforms/pairing/tmp{temp}.tmp", + match: "pattern", + action: "discard", + }, + ], + }, + ]; +} + +function createModelCacheArtifacts(id: string, relativePath: string): HermesManagedArtifact[] { + return [ + { + id, + scope: "agent-home", + relativePath, + ...GATEWAY_DERIVED_FILE, + }, + { + id: "dashboard-" + id, + scope: "dashboard", + relativePath, + ...DASHBOARD_DERIVED_FILE, + }, + ]; +} + +export const HERMES_MANAGED_ARTIFACTS = [ + { + id: "agent-home-root", + scope: "agent-home", + relativePath: ".", + match: "exact", + kind: "directory", + presence: "required", + artifactClass: "protected-configuration", + producers: ["root"], + readers: ALL_ROLES, + required: HOME_REQUIREMENTS, + shields: "seal", + backup: "exclude", + restore: "regenerate", + migration: "preserve", + }, + { + id: "top-level-atomic-staging", + scope: "agent-home", + relativePath: ".{temp}.tmp", + ...WRITABLE_STAGING_FILE, + }, + { + id: "top-level-temporary-staging", + scope: "agent-home", + relativePath: "tmp{temp}.tmp", + ...WRITABLE_STAGING_FILE, + }, + { + id: "nemoclaw-protected-write-staging", + scope: "agent-home", + relativePath: ".{filename}.nemoclaw.{pid}.{token}", + ...SEALED_STAGING_FILE, + }, + { + id: "profiles-root", + scope: "default-home", + relativePath: "profiles", + match: "exact", + kind: "directory", + presence: "required", + artifactClass: "derived-disposable-state", + producers: ["root", "sandbox"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: HIGH_RISK_DIRECTORY_REQUIREMENTS, + shields: "seal", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + // Descendants stay fail-closed unless a more specific rule names them. + id: "dashboard-home", + scope: "dashboard", + relativePath: ".", + match: "exact", + kind: "directory", + presence: "required", + artifactClass: "derived-disposable-state", + producers: ["root", "sandbox"], + readers: ["sandbox"], + required: DASHBOARD_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "dashboard-atomic-staging", + scope: "dashboard", + relativePath: ".{temp}.tmp", + ...DASHBOARD_DERIVED_FILE, + match: "pattern", + }, + { + id: "dashboard-config-write-staging", + scope: "dashboard", + relativePath: "config.yaml.nemoclaw.tmp", + ...DASHBOARD_DERIVED_FILE, + }, + { + id: "config", + scope: "agent-home", + relativePath: "config.yaml", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "protected-configuration", + producers: ["root", "sandbox"], + readers: ALL_ROLES, + required: CONFIG_FILE_REQUIREMENTS, + shields: "seal", + backup: { default: "exclude", namedProfile: "file" }, + restore: { default: "regenerate", namedProfile: "restore" }, + migration: "preserve", + }, + { + id: "config-hash", + scope: "default-home", + relativePath: ".config-hash", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "protected-configuration", + producers: ["root", "sandbox"], + readers: ["root", "sandbox"], + required: CONFIG_FILE_REQUIREMENTS, + shields: "seal", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "active-profile", + scope: "default-home", + relativePath: "active_profile", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ["root", "sandbox"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: CONFIG_FILE_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "active-profile-write-staging", + scope: "default-home", + relativePath: "active_profile.tmp", + ...SEALED_STAGING_FILE, + }, + { + id: "environment", + scope: "agent-home", + relativePath: ".env", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "credential-reference", + producers: ["root", "sandbox"], + readers: ALL_ROLES, + required: CREDENTIAL_FILE_REQUIREMENTS, + shields: "seal", + backup: "exclude", + restore: "regenerate", + migration: "preserve", + }, + { + id: "shell-hook-allowlist", + scope: "agent-home", + relativePath: "shell-hooks-allowlist.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SHARED_CONFIG_FILE_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "shell-hook-allowlist-lock", + scope: "agent-home", + relativePath: "shell-hooks-allowlist.json.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "shell-hook-allowlist-write-staging", + scope: "agent-home", + relativePath: "shell-hooks-allowlist.json.{temp}.tmp", + ...SEALED_STAGING_FILE, + }, + { + id: "environment-sync-lock", + scope: "agent-home", + relativePath: ".sync.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "gateway-voice-mode", + scope: "agent-home", + relativePath: "gateway_voice_mode.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "telegram-sticker-cache", + scope: "agent-home", + relativePath: "sticker_cache.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + ...createModelCacheArtifacts("context-length-cache", "context_length_cache.yaml"), + ...createModelCacheArtifacts("models-dev-cache", "models_dev_cache.json"), + ...createModelCacheArtifacts("ollama-cloud-models-cache", "ollama_cloud_models_cache.json"), + { + id: "clean-shutdown-marker", + scope: "agent-home", + relativePath: ".clean_shutdown", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "authentication-state", + scope: "agent-home", + relativePath: "auth.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "credential-reference", + producers: ROOT_AND_GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "preserve", + }, + { + id: "authentication-write-staging", + scope: "agent-home", + relativePath: "auth.json.tmp.{pid}.{token}", + ...WRITABLE_STAGING_FILE, + producers: ROOT_AND_GATEWAY_ROLE, + readers: GATEWAY_ROLE, + }, + { + id: "authentication-lock", + scope: "agent-home", + relativePath: "auth.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "profile-metadata", + scope: "named-profile", + relativePath: "profile.yaml", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "protected-configuration", + producers: ["sandbox"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PROTECTED_DOCUMENT_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "bundled-skills-opt-out", + scope: "named-profile", + relativePath: ".no-bundled-skills", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ["sandbox"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PROTECTED_DOCUMENT_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "tool-home", + scope: "agent-home", + relativePath: "home", + ...DERIVED_WRITABLE_DIRECTORY, + artifactClass: "mutable-runtime-state", + migration: "preserve", + }, + { + id: "lsp-cache", + scope: "agent-home", + relativePath: "lsp", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "lsp-powershell-runtime", + scope: "agent-home", + relativePath: "lsp/pses", + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SETGID_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "discard", + }, + { + id: "dashboard-config", + scope: "dashboard", + relativePath: "config.yaml", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "protected-configuration", + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "dashboard-environment", + scope: "dashboard", + relativePath: ".env", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "credential-reference", + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "dashboard-status", + scope: "dashboard", + relativePath: "gateway_state.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "dashboard-memory", + scope: "dashboard", + relativePath: "MEMORY.md", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "dashboard-user", + scope: "dashboard", + relativePath: "USER.md", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ["sandbox"], + readers: ["sandbox"], + required: SANDBOX_PRIVATE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { id: "memories", scope: "agent-home", relativePath: "memories", ...MEMORIES_DIRECTORY }, + { + id: "memory-document-lock", + scope: "agent-home", + relativePath: "memories/MEMORY.md.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "user-document-lock", + scope: "agent-home", + relativePath: "memories/USER.md.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "memory-write-staging", + scope: "agent-home", + relativePath: "memories/.mem_{temp}.tmp", + ...WRITABLE_STAGING_FILE, + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + }, + { id: "sessions", scope: "agent-home", relativePath: "sessions", ...SESSIONS_DIRECTORY }, + { + id: "sessions-write-staging", + scope: "agent-home", + relativePath: "sessions/.sessions_{temp}.tmp", + match: "pattern", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "hook-output-spill", + scope: "agent-home", + relativePath: "hook_outputs", + ...DERIVED_WRITABLE_DIRECTORY, + producers: SANDBOX_AND_GATEWAY_ROLE, + }, + { id: "plans", scope: "agent-home", relativePath: "plans", ...DURABLE_WRITABLE_DIRECTORY }, + { + id: "scripts", + scope: "agent-home", + relativePath: "scripts", + ...DURABLE_HIGH_RISK_DIRECTORY, + }, + { + id: "knowledge", + scope: "agent-home", + relativePath: "knowledge", + ...DURABLE_HIGH_RISK_DIRECTORY, + }, + { + id: "preferences", + scope: "agent-home", + relativePath: "preferences", + ...DURABLE_HIGH_RISK_DIRECTORY, + }, + { + id: "kanban-state", + scope: "agent-home", + relativePath: "kanban", + ...DURABLE_SELECTIVE_DIRECTORY, + }, + { + id: "kanban-workspaces", + scope: "agent-home", + relativePath: "kanban/workspaces", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "kanban-logs", + scope: "agent-home", + relativePath: "kanban/logs", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "kanban-attachments", + scope: "agent-home", + relativePath: "kanban/attachments", + ...DURABLE_WRITABLE_SETGID_DIRECTORY, + }, + { + id: "kanban-current", + scope: "agent-home", + relativePath: "kanban/current", + ...DURABLE_SHARED_FILE, + }, + { + id: "kanban-dispatcher-lock", + scope: "agent-home", + relativePath: "kanban/.dispatcher.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "kanban-board-database", + scope: "agent-home", + relativePath: "kanban/boards/{board}/kanban.db", + ...KANBAN_DATABASE, + }, + ...createSqliteSidecars( + "kanban-board-database-sidecar", + "agent-home", + "kanban/boards/{board}/kanban.db-{sidecar}", + KANBAN_SIDECAR, + ), + { + id: "kanban-board-lock", + scope: "agent-home", + relativePath: "kanban/boards/{board}/kanban.db.{lock}.lock", + ...KANBAN_RUNTIME_FILE, + }, + { + id: "kanban-board-recovery", + scope: "agent-home", + relativePath: "kanban/boards/{board}/kanban.db.corrupt.{backup}", + ...KANBAN_RECOVERY_FILE, + }, + { + id: "kanban-board-workspaces", + scope: "agent-home", + relativePath: "kanban/boards/{board}/workspaces/**", + ...KANBAN_HIGH_RISK_DIRECTORY, + }, + { + id: "kanban-board-attachments", + scope: "agent-home", + relativePath: "kanban/boards/{board}/attachments/**", + ...KANBAN_DURABLE_DIRECTORY, + }, + { + id: "kanban-board-logs", + scope: "agent-home", + relativePath: "kanban/boards/{board}/logs/**", + ...KANBAN_DERIVED_DIRECTORY, + }, + { + id: "archived-kanban-board-database", + scope: "agent-home", + relativePath: "kanban/boards/_archived/{board}/kanban.db", + ...KANBAN_DATABASE, + }, + ...createSqliteSidecars( + "archived-kanban-board-database-sidecar", + "agent-home", + "kanban/boards/_archived/{board}/kanban.db-{sidecar}", + KANBAN_SIDECAR, + ), + { + id: "archived-kanban-board-lock", + scope: "agent-home", + relativePath: "kanban/boards/_archived/{board}/kanban.db.{lock}.lock", + ...KANBAN_RUNTIME_FILE, + }, + { + id: "archived-kanban-board-recovery", + scope: "agent-home", + relativePath: "kanban/boards/_archived/{board}/kanban.db.corrupt.{backup}", + ...KANBAN_RECOVERY_FILE, + }, + { + id: "archived-kanban-board-workspaces", + scope: "agent-home", + relativePath: "kanban/boards/_archived/{board}/workspaces/**", + ...KANBAN_HIGH_RISK_DIRECTORY, + }, + { + id: "archived-kanban-board-attachments", + scope: "agent-home", + relativePath: "kanban/boards/_archived/{board}/attachments/**", + ...KANBAN_DURABLE_DIRECTORY, + }, + { + id: "archived-kanban-board-logs", + scope: "agent-home", + relativePath: "kanban/boards/_archived/{board}/logs/**", + ...KANBAN_DERIVED_DIRECTORY, + }, + { + id: "dashboard-kanban-state", + scope: "dashboard", + relativePath: "kanban", + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "durable-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: DASHBOARD_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: KANBAN_BACKUP, + restore: "restore", + migration: "preserve", + }, + { + id: "dashboard-kanban-workspaces", + scope: "dashboard", + relativePath: "kanban/workspaces", + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: DASHBOARD_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "dashboard-kanban-logs", + scope: "dashboard", + relativePath: "kanban/logs", + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: DASHBOARD_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "dashboard-kanban-attachments", + scope: "dashboard", + relativePath: "kanban/attachments", + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "durable-state", + producers: ["sandbox"], + readers: ["sandbox"], + required: DASHBOARD_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "directory", + restore: "restore", + migration: "preserve", + }, + { + id: "dashboard-kanban-current", + scope: "dashboard", + relativePath: "kanban/current", + ...DASHBOARD_DURABLE_FILE, + }, + { + id: "dashboard-kanban-board-database", + scope: "dashboard", + relativePath: "kanban/boards/{board}/kanban.db", + ...DASHBOARD_KANBAN_DATABASE, + }, + ...createSqliteSidecars( + "dashboard-kanban-board-database-sidecar", + "dashboard", + "kanban/boards/{board}/kanban.db-{sidecar}", + DASHBOARD_KANBAN_SIDECAR, + ), + { + id: "dashboard-kanban-board-lock", + scope: "dashboard", + relativePath: "kanban/boards/{board}/kanban.db.{lock}.lock", + ...DASHBOARD_KANBAN_RUNTIME_FILE, + }, + { + id: "dashboard-kanban-board-recovery", + scope: "dashboard", + relativePath: "kanban/boards/{board}/kanban.db.corrupt.{backup}", + ...DASHBOARD_KANBAN_RECOVERY_FILE, + }, + { + id: "dashboard-kanban-board-workspaces", + scope: "dashboard", + relativePath: "kanban/boards/{board}/workspaces/**", + ...DASHBOARD_KANBAN_DERIVED_DIRECTORY, + }, + { + id: "dashboard-kanban-board-attachments", + scope: "dashboard", + relativePath: "kanban/boards/{board}/attachments/**", + ...DASHBOARD_KANBAN_DURABLE_DIRECTORY, + }, + { + id: "dashboard-kanban-board-logs", + scope: "dashboard", + relativePath: "kanban/boards/{board}/logs/**", + ...DASHBOARD_KANBAN_DERIVED_DIRECTORY, + }, + { + id: "dashboard-archived-kanban-board-database", + scope: "dashboard", + relativePath: "kanban/boards/_archived/{board}/kanban.db", + ...DASHBOARD_KANBAN_DATABASE, + }, + ...createSqliteSidecars( + "dashboard-archived-kanban-board-database-sidecar", + "dashboard", + "kanban/boards/_archived/{board}/kanban.db-{sidecar}", + DASHBOARD_KANBAN_SIDECAR, + ), + { + id: "dashboard-archived-kanban-board-lock", + scope: "dashboard", + relativePath: "kanban/boards/_archived/{board}/kanban.db.{lock}.lock", + ...DASHBOARD_KANBAN_RUNTIME_FILE, + }, + { + id: "dashboard-archived-kanban-board-recovery", + scope: "dashboard", + relativePath: "kanban/boards/_archived/{board}/kanban.db.corrupt.{backup}", + ...DASHBOARD_KANBAN_RECOVERY_FILE, + }, + { + id: "dashboard-archived-kanban-board-workspaces", + scope: "dashboard", + relativePath: "kanban/boards/_archived/{board}/workspaces/**", + ...DASHBOARD_KANBAN_DERIVED_DIRECTORY, + }, + { + id: "dashboard-archived-kanban-board-attachments", + scope: "dashboard", + relativePath: "kanban/boards/_archived/{board}/attachments/**", + ...DASHBOARD_KANBAN_DURABLE_DIRECTORY, + }, + { + id: "dashboard-archived-kanban-board-logs", + scope: "dashboard", + relativePath: "kanban/boards/_archived/{board}/logs/**", + ...DASHBOARD_KANBAN_DERIVED_DIRECTORY, + }, + { + id: "skills-prompt-snapshot", + scope: "agent-home", + relativePath: "runtime/skills-prompt-snapshot.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + migrationSources: [ + { + relativePath: ".skills_prompt_snapshot.json", + match: "exact", + action: "discard", + }, + ], + }, + { id: "skills", scope: "agent-home", relativePath: "skills", ...SKILLS_DIRECTORY }, + { + id: "skills-write-staging", + scope: "agent-home", + relativePath: "skills/.{temp}.tmp", + ...SEALED_STAGING_FILE, + }, + { + id: "skills-descendant-write-staging", + scope: "agent-home", + relativePath: "skills/**/.{filename}.tmp.{temp}", + ...SEALED_STAGING_FILE, + }, + { + id: "skills-hub-lock-staging", + scope: "agent-home", + relativePath: "skills/.hub/.lock_{temp}.tmp", + ...SEALED_STAGING_FILE, + }, + { + id: "curator-state", + scope: "agent-home", + relativePath: "runtime/curator/state.json", + ...DURABLE_SHARED_FILE, + producers: SANDBOX_AND_GATEWAY_ROLE, + migrationSources: [ + { + relativePath: "skills/.curator_state", + match: "exact", + action: "migrate", + onFailure: "leave-source", + }, + ], + }, + { + id: "curator-recovery", + scope: "agent-home", + relativePath: "runtime/curator/backups", + ...DURABLE_WRITABLE_SETGID_DIRECTORY, + producers: SANDBOX_AND_GATEWAY_ROLE, + migrationSources: [ + { + relativePath: "skills/.curator_backups", + match: "subtree", + action: "migrate", + onFailure: "leave-source", + }, + ], + }, + { + id: "curator-archive", + scope: "agent-home", + relativePath: "skills/.archive", + ...DURABLE_HIGH_RISK_DIRECTORY, + artifactClass: "protected-configuration", + }, + { + id: "curator-suppression", + scope: "agent-home", + relativePath: "skills/.curator_suppressed", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ALL_ROLES, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SHARED_CONFIG_FILE_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "skill-usage-state", + scope: "agent-home", + relativePath: "runtime/skill-usage.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", + migrationSources: [ + { + relativePath: "skills/.usage.json", + match: "exact", + action: "migrate", + onFailure: "leave-source", + }, + ], + }, + { + id: "skill-usage-lock", + scope: "agent-home", + relativePath: "runtime/skill-usage.json.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { id: "plugins", scope: "agent-home", relativePath: "plugins", ...DURABLE_HIGH_RISK_DIRECTORY }, + { + id: "cron-definitions-root", + scope: "agent-home", + relativePath: "cron", + ...CRON_DEFINITIONS_DIRECTORY, + }, + { + id: "cron-write-staging", + scope: "agent-home", + relativePath: "cron/.{temp}.tmp", + ...SEALED_STAGING_FILE, + }, + { + id: "cron-output-write-staging", + scope: "agent-home", + relativePath: "cron/output/{job}/.output_{temp}.tmp", + ...SEALED_STAGING_FILE, + }, + { + id: "runtime-cron-output-write-staging", + scope: "agent-home", + relativePath: "runtime/cron/output/{job}/.output_{temp}.tmp", + ...WRITABLE_STAGING_FILE, + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + }, + { + // Hermes currently mixes these definitions with mutable run metadata. + // The cron-jobs-schema-split gap records the consumer work needed before + // Shields can enforce this target posture. + id: "cron-job-definitions", + scope: "agent-home", + relativePath: "cron/jobs.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ALL_ROLES, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SHARED_CONFIG_FILE_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "cron-job-runtime-state", + scope: "agent-home", + relativePath: "runtime/cron/job-state.json", + ...DURABLE_SHARED_FILE, + producers: SANDBOX_AND_GATEWAY_ROLE, + }, + { + id: "cron-tick-lock", + scope: "agent-home", + relativePath: "runtime/cron/.tick.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + migrationSources: [ + { + relativePath: "cron/.tick.lock", + match: "exact", + action: "discard", + }, + ], + }, + { + id: "cron-jobs-lock", + scope: "agent-home", + relativePath: "runtime/cron/.jobs.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + migrationSources: [ + { + relativePath: "cron/.jobs.lock", + match: "exact", + action: "discard", + }, + ], + }, + { + id: "cron-suggestions", + scope: "agent-home", + relativePath: "runtime/cron/suggestions.json", + ...DURABLE_SHARED_FILE, + producers: SANDBOX_AND_GATEWAY_ROLE, + migrationSources: [ + { + relativePath: "cron/suggestions.json", + match: "exact", + action: "migrate", + onFailure: "leave-source", + }, + ], + }, + { + id: "cron-output", + scope: "agent-home", + relativePath: "runtime/cron/output", + ...DURABLE_WRITABLE_SETGID_DIRECTORY, + producers: SANDBOX_AND_GATEWAY_ROLE, + migrationSources: [ + { + relativePath: "cron/output", + match: "subtree", + action: "migrate", + onFailure: "leave-source", + }, + ], + }, + { + id: "cron-ticker-heartbeat", + scope: "agent-home", + relativePath: "runtime/cron/ticker_heartbeat", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + migrationSources: [ + { + relativePath: "cron/ticker_heartbeat", + match: "exact", + action: "discard", + }, + ], + }, + { + id: "cron-ticker-last-success", + scope: "agent-home", + relativePath: "runtime/cron/ticker_last_success", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + migrationSources: [ + { + relativePath: "cron/ticker_last_success", + match: "exact", + action: "discard", + }, + ], + }, + { id: "skins", scope: "agent-home", relativePath: "skins", ...DURABLE_HIGH_RISK_DIRECTORY }, + { + id: "workspace", + scope: "agent-home", + relativePath: "workspace", + ...DURABLE_HIGH_RISK_DIRECTORY, + }, + { + id: "platform-state", + scope: "agent-home", + relativePath: "platforms", + ...PLATFORM_ROOT_DIRECTORY, + }, + { + id: "whatsapp-platform", + scope: "agent-home", + relativePath: "platforms/whatsapp", + ...WHATSAPP_ROOT_DIRECTORY, + }, + { + id: "whatsapp-session", + scope: "agent-home", + relativePath: "platforms/whatsapp/session", + ...WHATSAPP_SESSION_DIRECTORY, + }, + { + id: "legacy-whatsapp-platform", + scope: "agent-home", + relativePath: "whatsapp", + ...WHATSAPP_ROOT_DIRECTORY, + }, + { + id: "legacy-whatsapp-session", + scope: "agent-home", + relativePath: "whatsapp/session", + ...WHATSAPP_SESSION_DIRECTORY, + }, + { + id: "whatsapp-bridge-log", + scope: "agent-home", + relativePath: "platforms/whatsapp/bridge.log", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "legacy-whatsapp-bridge-log", + scope: "agent-home", + relativePath: "whatsapp/bridge.log", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "whatsapp-bridge-pid", + scope: "agent-home", + relativePath: "platforms/whatsapp/session/bridge.pid", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "legacy-whatsapp-bridge-pid", + scope: "agent-home", + relativePath: "whatsapp/session/bridge.pid", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "weixin-state", + scope: "agent-home", + relativePath: "weixin", + ...WEIXIN_STATE_DIRECTORY, + }, + { + id: "weixin-accounts", + scope: "agent-home", + relativePath: "weixin/accounts", + ...WEIXIN_ACCOUNTS_DIRECTORY, + }, + { + id: "weixin-context-tokens", + scope: "agent-home", + relativePath: "weixin/accounts/{account}.context-tokens.json", + match: "pattern", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "weixin-sync-cursor", + scope: "agent-home", + relativePath: "weixin/accounts/{account}.sync.json", + match: "pattern", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "preserve", + }, + { + id: "pairing-state", + scope: "agent-home", + relativePath: "pairing", + ...PAIRING_DIRECTORY, + }, + { + id: "pairing-runtime-state", + scope: "agent-home", + relativePath: "runtime/pairing", + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DIRECTORY_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + ...createPairingArtifacts(), + { + id: "feishu-comment-rules", + scope: "agent-home", + relativePath: "feishu_comment_rules.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ["sandbox"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: CONFIG_FILE_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "feishu-comment-pairing", + scope: "agent-home", + relativePath: "feishu_comment_pairing.json", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "protected-configuration", + producers: ["root"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: ROOT_PROTECTED_FILE_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "feishu-comment-pairing-write-staging", + scope: "agent-home", + relativePath: "feishu_comment_pairing.tmp", + ...ROOT_PRIVATE_STAGING_FILE, + match: "exact", + }, + { + id: "logs", + scope: "agent-home", + relativePath: "logs", + ...DERIVED_WRITABLE_DIRECTORY, + required: SETGID_DIRECTORY_REQUIREMENTS, + }, + { + id: "logs-curator", + scope: "agent-home", + relativePath: "logs/curator", + ...DERIVED_WRITABLE_DIRECTORY, + required: SETGID_DIRECTORY_REQUIREMENTS, + }, + { id: "cache", scope: "agent-home", relativePath: "cache", ...DERIVED_WRITABLE_DIRECTORY }, + { + id: "legacy-web-cache", + scope: "agent-home", + relativePath: "web_cache", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "legacy-delegation-cache", + scope: "agent-home", + relativePath: "delegation_cache", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "legacy-piper-voices-cache", + scope: "agent-home", + relativePath: "piper_voices_cache", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "hooks-cache", + scope: "agent-home", + relativePath: "hooks", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "image-cache", + scope: "agent-home", + relativePath: "image_cache", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "audio-cache", + scope: "agent-home", + relativePath: "audio_cache", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "nemoclaw-plugin", + scope: "agent-home", + relativePath: "plugins/nemoclaw", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "document-cache", + scope: "agent-home", + relativePath: "document_cache", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "browser-screenshots", + scope: "agent-home", + relativePath: "browser_screenshots", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "checkpoints", + scope: "agent-home", + relativePath: "checkpoints", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "sandboxes", + scope: "agent-home", + relativePath: "sandboxes", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "upstream-backups", + scope: "agent-home", + relativePath: "backups", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "upstream-state-snapshots", + scope: "agent-home", + relativePath: "state-snapshots", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "local-runtime", + scope: "agent-home", + relativePath: "local", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "managed-binaries", + scope: "agent-home", + relativePath: "bin", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "tirith-binary", + scope: "agent-home", + relativePath: "bin/tirith", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: GATEWAY_EXECUTABLE_REQUIREMENTS, + shields: "seal", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "managed-node", + scope: "agent-home", + relativePath: "node", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "node-modules", + scope: "default-home", + relativePath: "node_modules", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "upstream-source", + scope: "default-home", + relativePath: "hermes-agent", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "upstream-worktrees", + scope: "default-home", + relativePath: ".worktrees", + ...DERIVED_HIGH_RISK_DIRECTORY, + }, + { + id: "generated-images", + scope: "agent-home", + relativePath: "images", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "pastes", + scope: "agent-home", + relativePath: "pastes", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "video-cache", + scope: "agent-home", + relativePath: "video_cache", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "temporary-vision-images", + scope: "agent-home", + relativePath: "temp_vision_images", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "temporary-video-files", + scope: "agent-home", + relativePath: "temp_video_files", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "spawn-trees", + scope: "agent-home", + relativePath: "spawn-trees", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "moa-traces", + scope: "agent-home", + relativePath: "moa-traces", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "pending-runtime", + scope: "agent-home", + relativePath: "pending", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { + id: "temporary-runtime", + scope: "agent-home", + relativePath: "tmp", + ...DERIVED_WRITABLE_DIRECTORY, + }, + { id: "memory-document", scope: "agent-home", relativePath: "MEMORY.md", ...DURABLE_SHARED_FILE }, + { id: "todo-state", scope: "agent-home", relativePath: "todo.json", ...DURABLE_SHARED_FILE }, + { + id: "user-document", + scope: "agent-home", + relativePath: "USER.md", + ...PROTECTED_OPTIONAL_DOCUMENT, + }, + { + id: "system-prompt", + scope: "agent-home", + relativePath: "system_prompt.md", + ...PROTECTED_OPTIONAL_DOCUMENT, + }, + { + id: "agents-instructions", + scope: "agent-home", + relativePath: "AGENTS.md", + ...PROTECTED_OPTIONAL_DOCUMENT, + }, + { + id: "claude-instructions", + scope: "agent-home", + relativePath: "CLAUDE.md", + ...PROTECTED_OPTIONAL_DOCUMENT, + }, + { + id: "cursor-rules", + scope: "agent-home", + relativePath: ".cursorrules", + ...PROTECTED_OPTIONAL_DOCUMENT, + }, + { + id: "soul", + scope: "agent-home", + relativePath: "SOUL.md", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "protected-configuration", + producers: ["root", "sandbox"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PROTECTED_DOCUMENT_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "tui-history", + scope: "agent-home", + relativePath: ".hermes_history", + match: "exact", + kind: "file", + presence: "required", + artifactClass: "durable-state", + producers: ["root", "sandbox"], + readers: ["sandbox"], + required: SANDBOX_SHARED_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "runtime-directory", + scope: "agent-home", + relativePath: "runtime", + match: "subtree", + kind: "directory", + presence: "required", + artifactClass: "mutable-runtime-state", + producers: ALL_ROLES, + readers: ALL_ROLES, + required: GATEWAY_STICKY_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "process-registry", + scope: "agent-home", + relativePath: "processes.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "drain-request", + scope: "agent-home", + relativePath: ".drain_request.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "restart-notification", + scope: "agent-home", + relativePath: ".restart_notify.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: ROOT_AND_GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "preserve", + }, + { + id: "restart-notification-deduplication", + scope: "agent-home", + relativePath: ".restart_last_processed.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: ROOT_AND_GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "preserve", + }, + { + id: "restart-pending", + scope: "agent-home", + relativePath: ".restart_pending.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "preserve", + }, + { + id: "restart-failure-counts", + scope: "agent-home", + relativePath: ".restart_failure_counts", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "preserve", + }, + { + id: "gateway-start-ledger", + scope: "agent-home", + relativePath: "gateway-starts.log", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: ALL_ROLES, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "gateway-start-ledger-write-staging", + scope: "agent-home", + relativePath: "gateway-starts.tmp", + ...GATEWAY_DERIVED_FILE, + readers: ALL_ROLES, + }, + { + id: "process-state-directory", + scope: "agent-home", + relativePath: "state", + match: "subtree", + kind: "directory", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: ROOT_AND_GATEWAY_ROLE, + readers: ALL_ROLES, + required: GATEWAY_DIRECTORY_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "gateway-directory", + scope: "agent-home", + relativePath: "gateway", + match: "subtree", + kind: "directory", + presence: "required", + artifactClass: "mutable-runtime-state", + producers: ALL_ROLES, + readers: ALL_ROLES, + required: GATEWAY_DIRECTORY_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "regenerate", + migration: "regenerate", + }, + { + id: "main-state-database", + scope: "agent-home", + relativePath: { default: "runtime/state.db", namedProfile: "state.db" }, + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: ALL_ROLES, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SQLITE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "sqlite", + restore: "restore", + migration: "preserve", + }, + { + id: "cron-execution-database", + scope: "agent-home", + relativePath: "runtime/cron-executions.db", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SQLITE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "sqlite", + restore: "restore", + migration: "preserve", + migrationSources: [ + { + relativePath: "cron/executions.db", + match: "exact", + action: "migrate", + onFailure: "leave-source", + }, + ], + }, + { + id: "discord-recovery-database", + scope: "agent-home", + relativePath: "gateway/discord_message_recovery.db", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: GATEWAY_SHARED_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "sqlite", + restore: "restore", + migration: "preserve", + }, + { + id: "discord-thread-state", + scope: "agent-home", + relativePath: "discord_threads.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: GATEWAY_ROLE, + readers: GATEWAY_ROLE, + required: GATEWAY_PRIVATE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "file", + restore: "restore", + migration: "preserve", + }, + { + id: "default-kanban-database", + scope: "default-home", + relativePath: "kanban.db", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "durable-state", + producers: ALL_ROLES, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: SQLITE_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "sqlite", + restore: "restore", + migration: "preserve", + }, + { + id: "default-kanban-lock", + scope: "default-home", + relativePath: "kanban.db.{lock}.lock", + ...KANBAN_RUNTIME_FILE, + }, + { + id: "default-kanban-recovery", + scope: "default-home", + relativePath: "kanban.db.corrupt.{backup}", + ...KANBAN_RECOVERY_FILE, + }, + { + id: "dashboard-kanban-database", + scope: "dashboard", + relativePath: "kanban.db", + ...DASHBOARD_SQLITE_DATABASE, + }, + { + id: "dashboard-default-kanban-lock", + scope: "dashboard", + relativePath: "kanban.db.{lock}.lock", + ...DASHBOARD_KANBAN_RUNTIME_FILE, + }, + { + id: "dashboard-default-kanban-recovery", + scope: "dashboard", + relativePath: "kanban.db.corrupt.{backup}", + ...DASHBOARD_KANBAN_RECOVERY_FILE, + }, + { + id: "projects-database", + scope: "agent-home", + relativePath: "projects.db", + ...DURABLE_SQLITE_DATABASE, + }, + { + id: "response-store-database", + scope: "agent-home", + relativePath: "response_store.db", + ...DURABLE_PRIVATE_SQLITE_DATABASE, + }, + { + id: "memory-store-database", + scope: "agent-home", + relativePath: "memory_store.db", + ...DURABLE_SQLITE_DATABASE, + }, + { + id: "verification-evidence-database", + scope: "agent-home", + relativePath: "verification_evidence.db", + ...DURABLE_SQLITE_DATABASE, + }, + { + id: "gateway-pid", + scope: "agent-home", + relativePath: "runtime/gateway.pid", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: ALL_ROLES, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "gateway-lock", + scope: "agent-home", + relativePath: "runtime/gateway.lock", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: ALL_ROLES, + required: GATEWAY_SHARED_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "gateway-status", + scope: "agent-home", + relativePath: "runtime/gateway_state.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: ALL_ROLES, + required: GATEWAY_FILE_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "channel-directory", + scope: "agent-home", + relativePath: { + default: "runtime/channel_directory.json", + namedProfile: "channel_directory.json", + }, + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: SANDBOX_AND_GATEWAY_ROLE, + readers: SANDBOX_AND_GATEWAY_ROLE, + required: PRODUCER_FILE_DISCARD_REQUIREMENTS, + shields: "keep-writable", + backup: "exclude", + restore: "discard", + migration: "regenerate", + }, + { + id: "channel-aliases", + scope: "agent-home", + relativePath: "channel_aliases.json", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "protected-configuration", + producers: ["root", "sandbox"], + readers: SANDBOX_AND_GATEWAY_ROLE, + required: CONFIG_FILE_REQUIREMENTS, + shields: "seal", + backup: "file", + restore: "restore", + migration: "preserve", + }, + ...createSqliteSidecars( + "main-state-sidecar", + "agent-home", + { + default: "runtime/state.db-{sidecar}", + namedProfile: "state.db-{sidecar}", + }, + SQLITE_SIDECAR, + ), + ...createSqliteSidecars( + "cron-execution-sidecar", + "agent-home", + "runtime/cron-executions.db-{sidecar}", + SQLITE_SIDECAR, + "cron/executions.db-{sidecar}", + ), + ...createSqliteSidecars( + "discord-recovery-sidecar", + "agent-home", + "gateway/discord_message_recovery.db-{sidecar}", + SQLITE_SIDECAR, + ), + ...createSqliteSidecars( + "default-kanban-sidecar", + "default-home", + "kanban.db-{sidecar}", + SQLITE_SIDECAR, + ), + ...createSqliteSidecars( + "dashboard-kanban-sidecar", + "dashboard", + "kanban.db-{sidecar}", + DASHBOARD_SQLITE_SIDECAR, + ), + ...createSqliteSidecars( + "projects-database-sidecar", + "agent-home", + "projects.db-{sidecar}", + SQLITE_SIDECAR, + ), + ...createSqliteSidecars( + "response-store-database-sidecar", + "agent-home", + "response_store.db-{sidecar}", + PRIVATE_SQLITE_SIDECAR, + ), + ...createSqliteSidecars( + "memory-store-database-sidecar", + "agent-home", + "memory_store.db-{sidecar}", + SQLITE_SIDECAR, + ), + ...createSqliteSidecars( + "verification-evidence-database-sidecar", + "agent-home", + "verification_evidence.db-{sidecar}", + SQLITE_SIDECAR, + ), + { + id: "state-database-alias", + scope: "default-home", + relativePath: "state.db", + ...COMPATIBILITY_LINK, + }, + { + id: "state-wal-alias", + scope: "default-home", + relativePath: "state.db-wal", + ...COMPATIBILITY_LINK, + }, + { + id: "state-shm-alias", + scope: "default-home", + relativePath: "state.db-shm", + ...COMPATIBILITY_LINK, + }, + { + id: "gateway-lock-alias", + scope: "default-home", + relativePath: "gateway.lock", + ...COMPATIBILITY_LINK, + }, + { + id: "gateway-status-alias", + scope: "default-home", + relativePath: "gateway_state.json", + ...COMPATIBILITY_LINK, + }, + { + id: "channel-directory-alias", + scope: "default-home", + relativePath: "channel_directory.json", + ...COMPATIBILITY_LINK, + }, + { + id: "migration-marker", + scope: "default-home", + relativePath: ".migration-complete", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "derived-disposable-state", + producers: ["root"], + readers: ["root", "sandbox"], + required: MIGRATION_MARKER_REQUIREMENTS, + shields: "unchanged", + backup: "exclude", + restore: "discard", + migration: "regenerate", + }, + { + id: "restart-seal-marker", + scope: "default-home", + relativePath: ".nemoclaw-hermes-restart-seal", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: ["root"], + readers: ["root"], + required: RESTART_MARKER_REQUIREMENTS, + shields: "unchanged", + backup: "exclude", + restore: "discard", + migration: "discard", + }, + { + id: "tirith-retry-marker", + scope: "agent-home", + relativePath: ".tirith-install-failed", + match: "exact", + kind: "file", + presence: "optional", + artifactClass: "mutable-runtime-state", + producers: GATEWAY_ROLE, + readers: ALL_ROLES, + required: GATEWAY_SHARED_DISCARD_REQUIREMENTS, + shields: "unchanged", + backup: "exclude", + restore: "discard", + migration: "discard", + }, +] as const satisfies readonly HermesManagedArtifact[]; + +const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +function assertProfileName(name: string): void { + if (!PROFILE_NAME.test(name) || name === "default") { + throw new Error("Hermes profile name must match [a-z0-9][a-z0-9_-]{0,63}"); + } +} + +function isWithin(candidate: string, root: string): boolean { + return candidate === root || candidate.startsWith(root + "/"); +} + +function artifactSupportsHome(artifact: HermesManagedArtifact, home: HermesHome): boolean { + if (artifact.scope === "agent-home") { + return home.kind === "default" || home.kind === "named-profile"; + } + if (artifact.scope === "default-home") return home.kind === "default"; + return artifact.scope === home.kind; +} + +function artifactRelativePath(artifact: HermesManagedArtifact, home: HermesHome): string { + let relativePath: string; + if (typeof artifact.relativePath === "string") { + relativePath = artifact.relativePath; + } else { + if (artifact.scope !== "agent-home") { + throw new Error("Only agent-home artifacts can declare layout-specific paths"); + } + if (home.kind === "default") relativePath = artifact.relativePath.default; + else if (home.kind === "named-profile") relativePath = artifact.relativePath.namedProfile; + else throw new Error("Agent-home artifacts do not belong to the dashboard home"); + } + validateHermesRelativePath(relativePath, true); + return relativePath; +} + +function resolveHermesHomeRule(rule: HermesHomeRule, home: HermesHome): T { + if ( + typeof rule !== "object" || + rule === null || + !("default" in rule) || + !("namedProfile" in rule) + ) { + return rule; + } + if (home.kind === "default") return rule.default; + if (home.kind === "named-profile") return rule.namedProfile; + throw new Error("Dashboard artifacts cannot declare default and named-profile rules"); +} + +function validateHermesRelativePath(relativePath: string, allowHome = false): void { + if ( + !relativePath || + (relativePath === "." && !allowHome) || + relativePath.endsWith("/") || + relativePath.includes("\\") || + path.posix.isAbsolute(relativePath) || + path.posix.normalize(relativePath) !== relativePath || + relativePath.split("/").includes("..") + ) { + throw new Error("Hermes paths must be canonical relative paths without parent traversal"); + } +} + +function validateHermesRelativePattern(pattern: string): void { + validateHermesRelativePath(pattern); + + const placeholders = new Set(); + const segments = pattern.split("/"); + for (const segment of segments) { + if (segment === "**") continue; + if (segment.includes("*")) { + throw new Error("Hermes path patterns allow only named placeholders and ** segments"); + } + const matches = [...segment.matchAll(/\{([^{}]+)\}/gu)]; + if (segment.replace(/\{[^{}]+\}/gu, "").match(/[{}]/u)) { + throw new Error("Hermes path patterns require balanced named placeholders"); + } + for (const match of matches) { + const name = match[1] ?? ""; + if (!/^[a-z][a-z0-9-]*$/u.test(name) || placeholders.has(name)) { + throw new Error("Hermes path pattern placeholders must be unique lowercase names"); + } + placeholders.add(name); + } + } +} + +function matchesPatternSegment(patternSegment: string, candidateSegment: string): boolean { + const placeholders = [...patternSegment.matchAll(/\{[^{}]+\}/gu)]; + if (placeholders.length === 0) return patternSegment === candidateSegment; + + let expression = "^"; + let cursor = 0; + for (const placeholder of placeholders) { + const index = placeholder.index ?? 0; + expression += patternSegment.slice(cursor, index).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + expression += ".+"; + cursor = index + placeholder[0].length; + } + expression += patternSegment.slice(cursor).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&") + "$"; + return new RegExp(expression, "u").test(candidateSegment); +} + +function matchesHermesRelativePattern(pattern: string, candidate: string): boolean { + validateHermesRelativePattern(pattern); + const patternSegments = pattern.split("/"); + const candidateSegments = candidate.split("/"); + const memo = new Map(); + + const matchesFrom = (patternIndex: number, candidateIndex: number): boolean => { + const key = patternIndex + ":" + candidateIndex; + const cached = memo.get(key); + if (cached !== undefined) return cached; + + let matches: boolean; + if (patternIndex === patternSegments.length) { + matches = candidateIndex === candidateSegments.length; + memo.set(key, matches); + return matches; + } + const patternSegment = patternSegments[patternIndex] ?? ""; + if (patternSegment === "**") { + matches = false; + for (let nextIndex = candidateIndex; nextIndex <= candidateSegments.length; nextIndex += 1) { + if (matchesFrom(patternIndex + 1, nextIndex)) { + matches = true; + break; + } + } + } else { + const candidateSegment = candidateSegments[candidateIndex]; + matches = + candidateSegment !== undefined && + matchesPatternSegment(patternSegment, candidateSegment) && + matchesFrom(patternIndex + 1, candidateIndex + 1); + } + memo.set(key, matches); + return matches; + }; + + return matchesFrom(0, 0); +} + +function compareHermesPatternSpecificity(left: string, right: string): number { + const metrics = (pattern: string) => { + const segments = pattern.split("/"); + return { + depth: segments.filter((segment) => segment !== "**").length, + literalCharacters: segments.reduce( + (total, segment) => total + segment.replace(/\{[^{}]+\}/gu, "").replace("**", "").length, + 0, + ), + recursiveSegments: segments.filter((segment) => segment === "**").length, + }; + }; + const leftMetrics = metrics(left); + const rightMetrics = metrics(right); + return ( + rightMetrics.depth - leftMetrics.depth || + rightMetrics.literalCharacters - leftMetrics.literalCharacters || + leftMetrics.recursiveSegments - rightMetrics.recursiveSegments + ); +} + +interface HermesContractMatch { + readonly relativePath: string; + readonly match: "exact" | "subtree" | "pattern"; +} + +interface HermesArtifactContractMatch extends HermesContractMatch { + readonly artifact: HermesManagedArtifact; + readonly pathRole: "target" | "migration-source"; +} + +function matchesHermesContractPath(contract: HermesContractMatch, candidate: string): boolean { + if (contract.match !== "pattern") validateHermesRelativePath(contract.relativePath, true); + if (contract.relativePath === candidate) return true; + if (contract.match === "pattern") { + return matchesHermesRelativePattern(contract.relativePath, candidate); + } + return ( + contract.match === "subtree" && + (contract.relativePath === "." || candidate.startsWith(contract.relativePath + "/")) + ); +} + +function compareHermesContractMatches( + left: HermesContractMatch, + right: HermesContractMatch, + candidate: string, +): number { + return ( + Number(right.relativePath === candidate) - Number(left.relativePath === candidate) || + compareHermesPatternSpecificity(left.relativePath, right.relativePath) + ); +} + +function residualSupportsHome(residual: HermesUnsupportedResidualPath, home: HermesHome): boolean { + if (residual.scope === "agent-home") { + return home.kind === "default" || home.kind === "named-profile"; + } + if (residual.scope === "default-home") return home.kind === "default"; + return home.kind === "named-profile"; +} + +export function resolveHermesHomePath(home: HermesHome): string { + if (home.kind === "default") return HERMES_DEFAULT_HOME; + if (home.kind === "dashboard") { + return path.posix.join(HERMES_DEFAULT_HOME, HERMES_DASHBOARD_DIRECTORY); + } + assertProfileName(home.name); + return path.posix.join(HERMES_DEFAULT_HOME, HERMES_NAMED_PROFILES_DIRECTORY, home.name); +} + +export function resolveHermesPosture( + requirement: HermesPostureRequirement, + topology: HermesRuntimeTopology, + producer?: HermesIdentity, +): HermesConcretePosture { + const posture = + "kind" in requirement + ? requirement + : topology === "root-separated" + ? requirement.rootSeparated + : requirement.sameUid; + if (posture.kind === "tree") { + const descendantOwner = posture.descendantOwner; + if (descendantOwner === "producer") { + if (!producer) { + throw new Error("A producer identity is required for producer-owned Hermes descendants"); + } + return { ...posture, descendantOwner: producer }; + } + return { ...posture, descendantOwner }; + } + if (posture.owner === "producer") { + if (!producer) { + throw new Error("A producer identity is required for a producer-owned Hermes artifact"); + } + return { ...posture, owner: producer }; + } + return { + kind: "path", + owner: posture.owner, + group: posture.group, + mode: posture.mode, + }; +} + +export function resolveHermesIdentities( + rule: HermesIdentityRule, + topology: HermesRuntimeTopology, +): readonly HermesIdentity[] { + if (!("rootSeparated" in rule)) return rule; + return topology === "root-separated" ? rule.rootSeparated : rule.sameUid; +} + +export function resolveHermesArtifactBackup( + artifact: HermesManagedArtifact, + home: HermesHome, +): HermesBackupBehavior { + if (!artifactSupportsHome(artifact, home)) { + throw new Error( + "Hermes artifact '" + artifact.id + "' does not belong to the " + home.kind + " home", + ); + } + return resolveHermesHomeRule(artifact.backup, home); +} + +export function resolveHermesArtifactRestore( + artifact: HermesManagedArtifact, + home: HermesHome, +): HermesRestoreBehavior { + if (!artifactSupportsHome(artifact, home)) { + throw new Error( + "Hermes artifact '" + artifact.id + "' does not belong to the " + home.kind + " home", + ); + } + return resolveHermesHomeRule(artifact.restore, home); +} + +export function resolveHermesBackupAction( + behavior: HermesBackupBehavior, + relativePath: string, +): HermesBackupAction { + validateHermesRelativePath(relativePath); + if (typeof behavior === "string") return behavior; + const selectors = behavior.selectors + .filter((candidate) => matchesHermesRelativePattern(candidate.relativePattern, relativePath)) + .sort((left, right) => + compareHermesPatternSpecificity(left.relativePattern, right.relativePattern), + ); + const selector = selectors[0]; + const nextSelector = selectors[1]; + if ( + selector && + nextSelector && + compareHermesPatternSpecificity(selector.relativePattern, nextSelector.relativePattern) === 0 + ) { + throw new Error("Ambiguous Hermes selective backup contract for " + relativePath); + } + return selector?.action ?? behavior.fallback; +} + +/** + * Resolves an artifact path in its home. Pattern artifacts retain named + * placeholders and recursive `**` segments; callers can use the result as a path + * template or pass a concrete path to {@link findHermesManagedArtifact}. + */ +export function resolveHermesArtifactPath( + artifact: HermesManagedArtifact, + requestedHome?: HermesHome, +): string { + let home = requestedHome; + if (!home) { + if (artifact.scope === "named-profile") { + throw new Error("Named-profile artifacts require a profile name"); + } + home = artifact.scope === "dashboard" ? { kind: "dashboard" } : { kind: "default" }; + } + if (!artifactSupportsHome(artifact, home)) { + throw new Error( + "Hermes artifact '" + artifact.id + "' does not belong to the " + home.kind + " home", + ); + } + const homePath = resolveHermesHomePath(home); + const relativePath = artifactRelativePath(artifact, home); + return relativePath === "." ? homePath : path.posix.join(homePath, relativePath); +} + +export function findHermesManagedArtifact(absolutePath: string): ResolvedHermesArtifact | null { + if ( + !path.posix.isAbsolute(absolutePath) || + (absolutePath !== "/" && absolutePath.endsWith("/")) || + path.posix.normalize(absolutePath) !== absolutePath + ) { + throw new Error("Hermes managed artifact lookup requires a canonical absolute path"); + } + + const dashboardRoot = resolveHermesHomePath({ kind: "dashboard" }); + const profilesRoot = path.posix.join(HERMES_DEFAULT_HOME, HERMES_NAMED_PROFILES_DIRECTORY); + let home: HermesHome; + + if (isWithin(absolutePath, dashboardRoot)) { + home = { kind: "dashboard" }; + } else if (isWithin(absolutePath, profilesRoot) && absolutePath !== profilesRoot) { + const relative = path.posix.relative(profilesRoot, absolutePath); + const profileName = relative.split("/")[0] ?? ""; + if (!PROFILE_NAME.test(profileName) || profileName === "default") return null; + home = { kind: "named-profile", name: profileName }; + } else if (isWithin(absolutePath, HERMES_DEFAULT_HOME)) { + home = { kind: "default" }; + } else { + return null; + } + + const homePath = resolveHermesHomePath(home); + const relativePath = path.posix.relative(homePath, absolutePath) || "."; + const managedArtifacts: readonly HermesManagedArtifact[] = HERMES_MANAGED_ARTIFACTS; + const artifacts = managedArtifacts + .flatMap((artifact) => { + if (!artifactSupportsHome(artifact, home)) return []; + const contracts: HermesArtifactContractMatch[] = [ + { + artifact, + relativePath: artifactRelativePath(artifact, home), + match: artifact.match, + pathRole: "target", + }, + ...(artifact.migrationSources ?? []).map((source) => ({ + artifact, + relativePath: source.relativePath, + match: source.match, + pathRole: "migration-source" as const, + })), + ]; + return contracts.filter((contract) => matchesHermesContractPath(contract, relativePath)); + }) + .sort((left, right) => compareHermesContractMatches(left, right, relativePath)); + const artifact = artifacts[0]; + const nextArtifact = artifacts[1]; + if ( + artifact && + nextArtifact && + compareHermesContractMatches(artifact, nextArtifact, relativePath) === 0 + ) { + throw new Error("Ambiguous Hermes path contract for " + absolutePath); + } + + const residuals = HERMES_UNSUPPORTED_RESIDUAL_PATHS.filter((candidate) => { + if (!residualSupportsHome(candidate, home)) return false; + return matchesHermesContractPath(candidate, relativePath); + }).sort((left, right) => compareHermesContractMatches(left, right, relativePath)); + const residual = residuals[0]; + const nextResidual = residuals[1]; + if ( + residual && + nextResidual && + compareHermesContractMatches(residual, nextResidual, relativePath) === 0 + ) { + throw new Error("Ambiguous unsupported Hermes path contract for " + absolutePath); + } + if (residual) { + if (!artifact) return null; + const residualPrecedence = compareHermesContractMatches(residual, artifact, relativePath); + if (residualPrecedence === 0) { + throw new Error("Ambiguous managed and unsupported Hermes path contract for " + absolutePath); + } + if (residualPrecedence < 0) return null; + } + + return artifact ? { artifact: artifact.artifact, home, pathRole: artifact.pathRole } : null; +} From 04430e28e7d1ffd9bbb316d0bf3a4ed188c655e1 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 16:49:22 -0400 Subject: [PATCH 2/2] refactor(hermes): simplify ownership contract validation Signed-off-by: Julie Yaunches --- .../hermes-path-ownership-lifecycle.test.ts | 311 +++++++------- src/lib/agent/hermes-path-ownership.test.ts | 382 +++++++++++------- src/lib/agent/hermes-path-ownership.ts | 24 +- 3 files changed, 407 insertions(+), 310 deletions(-) diff --git a/src/lib/agent/hermes-path-ownership-lifecycle.test.ts b/src/lib/agent/hermes-path-ownership-lifecycle.test.ts index 2723f4de0a6..0c7527d3fd3 100644 --- a/src/lib/agent/hermes-path-ownership-lifecycle.test.ts +++ b/src/lib/agent/hermes-path-ownership-lifecycle.test.ts @@ -10,6 +10,7 @@ import { HERMES_LEGACY_MIGRATION, HERMES_MANAGED_ARTIFACTS, type HermesManagedArtifact, + type HermesPostureRequirement, resolveHermesArtifactBackup, resolveHermesArtifactPath, resolveHermesBackupAction, @@ -19,10 +20,27 @@ import { const TOPOLOGIES = ["root-separated", "same-uid"] as const; +function expectArtifact( + result: HermesManagedArtifact | undefined, + message: string, +): HermesManagedArtifact { + expect(result, message).toBeDefined(); + return result as HermesManagedArtifact; +} + function artifact(id: string): HermesManagedArtifact { - const result = HERMES_MANAGED_ARTIFACTS.find((candidate) => candidate.id === id); - if (!result) throw new Error("Missing Hermes artifact '" + id + "'"); - return result; + return expectArtifact( + HERMES_MANAGED_ARTIFACTS.find((candidate) => candidate.id === id), + "Missing Hermes artifact '" + id + "'", + ); +} + +function expectRestoredPosture( + requirement: HermesPostureRequirement | "absent", + message: string, +): HermesPostureRequirement { + expect(requirement, message).not.toBe("absent"); + return requirement as HermesPostureRequirement; } function topLevelPath(artifactRule: HermesManagedArtifact): string | null { @@ -30,8 +48,11 @@ function topLevelPath(artifactRule: HermesManagedArtifact): string | null { typeof artifactRule.relativePath === "string" ? artifactRule.relativePath : artifactRule.relativePath.default; - if (relativePath === "." || relativePath.includes("/")) return null; - return relativePath; + return relativePath === "." || relativePath.includes("/") ? null : relativePath; +} + +function materializePathPattern(pathPattern: string): string { + return pathPattern.replace(/\{[^/{}]+\}/gu, "sample"); } describe("Hermes path ownership lifecycle contract", () => { @@ -264,11 +285,12 @@ describe("Hermes path ownership lifecycle contract", () => { }); it("keeps gateway-produced live state writable in the target contract (#8006)", () => { - for (const id of ["tool-home", "kanban-state"]) { + for (const [id, artifactClass] of [ + ["tool-home", "mutable-runtime-state"], + ["kanban-state", "durable-state"], + ] as const) { const entry = artifact(id); - expect(entry.artifactClass, id).toMatch( - /^(credential-reference|durable-state|mutable-runtime-state)$/u, - ); + expect(entry.artifactClass, id).toBe(artifactClass); expect(entry.shields, id).toBe("keep-writable"); expect(entry.required.shields, id).toBeUndefined(); expect(resolveHermesIdentities(entry.producers, "root-separated"), id).toContain("gateway"); @@ -345,8 +367,10 @@ describe("Hermes path ownership lifecycle contract", () => { it("protects private children from writable ancestor replacement (#8006)", () => { const home = artifact("agent-home-root"); - const restoredHome = home.required.restore; - if (restoredHome === "absent") throw new Error("Hermes home must exist after restore"); + const restoredHome = expectRestoredPosture( + home.required.restore, + "Hermes home must exist after restore", + ); for (const posture of [ home.required.create, restoredHome, @@ -397,9 +421,11 @@ describe("Hermes path ownership lifecycle contract", () => { ["/sandbox/.hermes/runtime/pairing/slack-pending.json", "pairing-pending", "0600"], ["/sandbox/.hermes/pairing/slack-approved.json", "pairing-approved", "0444"], ] as const) { - const resolved = findHermesManagedArtifact(target)?.artifact; - expect(resolved?.id, target).toBe(id); - if (!resolved) throw new Error("Missing Hermes artifact for '" + target + "'"); + const resolved = expectArtifact( + findHermesManagedArtifact(target)?.artifact, + "Missing Hermes artifact for '" + target + "'", + ); + expect(resolved.id, target).toBe(id); const posture = resolveHermesPosture(resolved.required.create, "root-separated", "gateway"); expect(posture.kind === "tree" ? posture.directoryMode : posture.mode, target).toBe(mode); } @@ -431,8 +457,10 @@ describe("Hermes path ownership lifecycle contract", () => { expect( resolveHermesPosture(contextTokens!.required.create, "root-separated", "gateway"), ).toMatchObject({ owner: "gateway", group: "sandbox", mode: "0600" }); - const restoredContextTokens = contextTokens!.required.restore; - if (restoredContextTokens === "absent") throw new Error("Weixin context must restore"); + const restoredContextTokens = expectRestoredPosture( + contextTokens!.required.restore, + "Weixin context must restore", + ); expect(resolveHermesPosture(restoredContextTokens, "root-separated")).toMatchObject({ owner: "gateway", group: "sandbox", @@ -474,8 +502,10 @@ describe("Hermes path ownership lifecycle contract", () => { "sandbox", "gateway", ]); - const restoreRequirement = powershellRuntime.required.restore; - if (restoreRequirement === "absent") throw new Error("PowerShell runtime must be recreated"); + const restoreRequirement = expectRestoredPosture( + powershellRuntime.required.restore, + "PowerShell runtime must be recreated", + ); for (const topology of TOPOLOGIES) { expect(resolveHermesPosture(restoreRequirement, topology), topology).toMatchObject({ owner: "sandbox", @@ -695,38 +725,46 @@ describe("Hermes path ownership lifecycle contract", () => { ); } - const stagingPaths = new Map([ - [".processes_abc123.tmp", "top-level-atomic-staging"], - ["..restart_notify_abc123.tmp", "top-level-atomic-staging"], - ["..restart_last_processed_abc123.tmp", "top-level-atomic-staging"], - ["..restart_pending_abc123.tmp", "top-level-atomic-staging"], - ["..restart_failure_counts_abc123.tmp", "top-level-atomic-staging"], - ["..drain_request_abc123.tmp", "top-level-atomic-staging"], - [".discord_threads_abc123.tmp", "top-level-atomic-staging"], - ["tmpabc123.tmp", "top-level-temporary-staging"], - [".config.yaml.nemoclaw.1234.0123456789abcdef", "nemoclaw-protected-write-staging"], - ["auth.json.tmp.1234.0123456789abcdef", "authentication-write-staging"], - ["gateway-starts.tmp", "gateway-start-ledger-write-staging"], - ["memories/.mem_abc123.tmp", "memory-write-staging"], - ["skills/.usage_abc123.tmp", "skills-write-staging"], - ["skills/..curator_state_abc123.tmp", "skills-write-staging"], - ["skills/.curator_suppressed_abc123.tmp", "skills-write-staging"], - ["skills/.bundled_manifest_abc123.tmp", "skills-write-staging"], - ["skills/.lock_abc123.tmp", "skills-write-staging"], - ["skills/.hub/.lock_abc123.tmp", "skills-hub-lock-staging"], - ["skills/.SKILL.md.tmp.abc123", "skills-descendant-write-staging"], - ["skills/reviewer/.SKILL.md.tmp.abc123", "skills-descendant-write-staging"], - ["skills/reviewer/references/.notes.md.tmp.abc123", "skills-descendant-write-staging"], - ["shell-hooks-allowlist.json.abc123.tmp", "shell-hook-allowlist-write-staging"], - ["cron/.jobs_abc123.tmp", "cron-write-staging"], - ["cron/.sugg_abc123.tmp", "cron-write-staging"], - ["cron/.hb_abc123.tmp", "cron-write-staging"], - ["cron/output/job/.output_abc123.tmp", "cron-output-write-staging"], - ["runtime/cron/output/job/.output_abc123.tmp", "runtime-cron-output-write-staging"], - ] as const); + const stagingPaths = [ + [".processes_abc123.tmp", "top-level-atomic-staging", "keep-writable"], + ["..restart_notify_abc123.tmp", "top-level-atomic-staging", "keep-writable"], + ["..restart_last_processed_abc123.tmp", "top-level-atomic-staging", "keep-writable"], + ["..restart_pending_abc123.tmp", "top-level-atomic-staging", "keep-writable"], + ["..restart_failure_counts_abc123.tmp", "top-level-atomic-staging", "keep-writable"], + ["..drain_request_abc123.tmp", "top-level-atomic-staging", "keep-writable"], + [".discord_threads_abc123.tmp", "top-level-atomic-staging", "keep-writable"], + ["tmpabc123.tmp", "top-level-temporary-staging", "keep-writable"], + [".config.yaml.nemoclaw.1234.0123456789abcdef", "nemoclaw-protected-write-staging", "seal"], + ["auth.json.tmp.1234.0123456789abcdef", "authentication-write-staging", "keep-writable"], + ["gateway-starts.tmp", "gateway-start-ledger-write-staging", "keep-writable"], + ["memories/.mem_abc123.tmp", "memory-write-staging", "keep-writable"], + ["skills/.usage_abc123.tmp", "skills-write-staging", "seal"], + ["skills/..curator_state_abc123.tmp", "skills-write-staging", "seal"], + ["skills/.curator_suppressed_abc123.tmp", "skills-write-staging", "seal"], + ["skills/.bundled_manifest_abc123.tmp", "skills-write-staging", "seal"], + ["skills/.lock_abc123.tmp", "skills-write-staging", "seal"], + ["skills/.hub/.lock_abc123.tmp", "skills-hub-lock-staging", "seal"], + ["skills/.SKILL.md.tmp.abc123", "skills-descendant-write-staging", "seal"], + ["skills/reviewer/.SKILL.md.tmp.abc123", "skills-descendant-write-staging", "seal"], + [ + "skills/reviewer/references/.notes.md.tmp.abc123", + "skills-descendant-write-staging", + "seal", + ], + ["shell-hooks-allowlist.json.abc123.tmp", "shell-hook-allowlist-write-staging", "seal"], + ["cron/.jobs_abc123.tmp", "cron-write-staging", "seal"], + ["cron/.sugg_abc123.tmp", "cron-write-staging", "seal"], + ["cron/.hb_abc123.tmp", "cron-write-staging", "seal"], + ["cron/output/job/.output_abc123.tmp", "cron-output-write-staging", "seal"], + [ + "runtime/cron/output/job/.output_abc123.tmp", + "runtime-cron-output-write-staging", + "keep-writable", + ], + ] as const; for (const home of ["/sandbox/.hermes", "/sandbox/.hermes/profiles/research"] as const) { - for (const [relativePath, id] of stagingPaths) { + for (const [relativePath, id, shields] of stagingPaths) { const staging = findHermesManagedArtifact(home + "/" + relativePath)?.artifact; expect(staging, relativePath).toMatchObject({ id, @@ -735,14 +773,7 @@ describe("Hermes path ownership lifecycle contract", () => { restore: "discard", migration: "discard", }); - expect(staging?.shields, relativePath).toBe( - id.startsWith("skills-") || - id.startsWith("cron-") || - id === "nemoclaw-protected-write-staging" || - id === "shell-hook-allowlist-write-staging" - ? "seal" - : "keep-writable", - ); + expect(staging?.shields, relativePath).toBe(shields); } } @@ -955,31 +986,37 @@ describe("Hermes path ownership lifecycle contract", () => { it("records current manifest gaps separately from the target backup contract (#8006)", () => { const hermes = loadAgent("hermes"); - expect(hermes.stateDirs).toEqual([ - "memories", - "sessions", - "skills", - "plugins", - "cron", - "logs", - "skins", - "plans", - "workspace", - "profiles", - "cache", - "pairing", - "dashboard-home", - "platforms", - "weixin", - ]); - expect(hermes.stateFiles).toEqual([ - { path: "SOUL.md", strategy: "copy" }, - { path: ".hermes_history", strategy: "copy" }, - { path: "runtime/state.db", strategy: "sqlite_backup" }, - { path: "runtime/cron-executions.db", strategy: "sqlite_backup" }, - { path: "gateway/discord_message_recovery.db", strategy: "sqlite_backup" }, - { path: "kanban.db", strategy: "sqlite_backup" }, - ]); + expect([...hermes.stateDirs].sort()).toEqual( + [ + "memories", + "sessions", + "skills", + "plugins", + "cron", + "logs", + "skins", + "plans", + "workspace", + "profiles", + "cache", + "pairing", + "dashboard-home", + "platforms", + "weixin", + ].sort(), + ); + expect( + [...hermes.stateFiles].sort((left, right) => left.path.localeCompare(right.path)), + ).toEqual( + [ + { path: "SOUL.md", strategy: "copy" }, + { path: ".hermes_history", strategy: "copy" }, + { path: "runtime/state.db", strategy: "sqlite_backup" }, + { path: "runtime/cron-executions.db", strategy: "sqlite_backup" }, + { path: "gateway/discord_message_recovery.db", strategy: "sqlite_backup" }, + { path: "kanban.db", strategy: "sqlite_backup" }, + ].sort((left, right) => left.path.localeCompare(right.path)), + ); expect(artifact("soul").backup).toBe("file"); expect(artifact("tui-history").backup).toBe("file"); @@ -1011,82 +1048,42 @@ describe("Hermes path ownership lifecycle contract", () => { knownGap: "source-removal-precedes-final-validation", }, }); - expect(HERMES_CONTRACT_GAPS).toEqual([ - { - id: "cron-jobs-schema-split", - currentPaths: ["cron/jobs.json"], - targetArtifactIds: ["cron-job-definitions", "cron-job-runtime-state"], - consumer: "pinned Hermes cron.jobs and cron.scheduler", - migration: "record-level", - failure: "retain-source", - }, - { - id: "curator-runtime-relocation", - currentPaths: ["skills/.curator_state", "skills/.curator_backups"], - targetArtifactIds: ["curator-state", "curator-recovery"], - consumer: "pinned Hermes agent.curator and agent.curator_backup", - migration: "path-relocation", - failure: "retain-source", - }, - { - id: "skills-protected-transition", - currentPaths: ["skills", "skills/.archive", "skills/.curator_suppressed"], - targetArtifactIds: ["skills", "curator-archive", "curator-suppression"], - consumer: - "pinned Hermes Curator, tools.skill_manager_tool, tools.skills_sync, and gateway startup", - migration: "privileged-transition", - failure: "retain-source", - }, - { - id: "lsp-install-privileged-transition", - currentPaths: ["lsp", "lsp/bin", "lsp/node_modules", "lsp/python-packages"], - targetArtifactIds: ["lsp-cache"], - consumer: "pinned Hermes agent.lsp.install and agent.lsp.servers", - migration: "privileged-transition", - failure: "retain-source", - }, - { - id: "pairing-runtime-relocation", - currentPaths: [ - "pairing/{platform}-pending.json", - "pairing/_rate_limits.json", - "platforms/pairing/{platform}-pending.json", - "platforms/pairing/_rate_limits.json", - ], - targetArtifactIds: ["pairing-runtime-state", "pairing-pending", "pairing-rate-limits"], - consumer: "pinned Hermes PairingStore pending request and rate-limit writers", - migration: "path-relocation", - failure: "retain-source", - }, - { - id: "pairing-approval-privileged-transition", - currentPaths: [ - "pairing/{platform}-approved.json", - "platforms/pairing/{platform}-approved.json", - "feishu_comment_pairing.json", - ], - targetArtifactIds: [ - "pairing-approved", - "pairing-approved-write-staging", - "feishu-comment-pairing", - "feishu-comment-pairing-write-staging", - ], - consumer: "pinned Hermes pairing approval, dashboard, and Feishu comment pairing writers", - migration: "privileged-transition", - failure: "retain-source", - }, - { - id: "whatsapp-session-secure-handoff", - currentPaths: ["platforms/whatsapp/session", "whatsapp/session"], - targetArtifactIds: ["whatsapp-session", "legacy-whatsapp-session"], - consumer: - "pinned Hermes WhatsApp bridge Baileys multi-file authentication state and NemoClaw snapshot/rebuild restore", - migration: "privileged-transition", - failure: "retain-source", - limitation: - "same-uid topology cannot isolate gateway session credentials from sandbox code", - }, - ]); + const gapIds = HERMES_CONTRACT_GAPS.map((gap) => gap.id); + expect(new Set(gapIds).size).toBe(gapIds.length); + expect(new Set(gapIds)).toEqual( + new Set([ + "cron-jobs-schema-split", + "curator-runtime-relocation", + "skills-protected-transition", + "lsp-install-privileged-transition", + "pairing-runtime-relocation", + "pairing-approval-privileged-transition", + "whatsapp-session-secure-handoff", + ]), + ); + for (const gap of HERMES_CONTRACT_GAPS) { + expect(gap.failure, gap.id).toBe("retain-source"); + for (const targetArtifactId of gap.targetArtifactIds) { + const targetArtifact = artifact(targetArtifactId); + const targetPath = materializePathPattern( + resolveHermesArtifactPath(targetArtifact, { kind: "default" }), + ); + expect( + findHermesManagedArtifact(targetPath), + gap.id + ":" + targetArtifactId, + ).toMatchObject({ + artifact: { id: targetArtifactId }, + pathRole: "target", + }); + } + for (const currentPath of gap.currentPaths) { + const materializedPath = materializePathPattern("/sandbox/.hermes/" + currentPath); + expect( + findHermesManagedArtifact(materializedPath), + gap.id + ":" + currentPath, + ).not.toBeNull(); + } + } expect( findHermesManagedArtifact("/sandbox/.hermes/platforms/pairing/slack-approved.json"), ).toMatchObject({ diff --git a/src/lib/agent/hermes-path-ownership.test.ts b/src/lib/agent/hermes-path-ownership.test.ts index 732e5e21ea9..a8208da8265 100644 --- a/src/lib/agent/hermes-path-ownership.test.ts +++ b/src/lib/agent/hermes-path-ownership.test.ts @@ -22,19 +22,38 @@ import { const TOPOLOGIES = ["root-separated", "same-uid"] as const; +type HermesTopology = (typeof TOPOLOGIES)[number]; +type HermesMigrationSource = NonNullable[number]; +type HermesUnsupportedResidual = (typeof HERMES_UNSUPPORTED_RESIDUAL_PATHS)[number]; + function artifact(id: string): HermesManagedArtifact { const result = HERMES_MANAGED_ARTIFACTS.find((candidate) => candidate.id === id); - if (!result) throw new Error("Missing Hermes artifact '" + id + "'"); - return result; + expect(result, "Missing Hermes artifact '" + id + "'").toBeDefined(); + return result as HermesManagedArtifact; } function homesFor(artifactRule: HermesManagedArtifact): HermesHome[] { - if (artifactRule.scope === "agent-home") { - return [{ kind: "default" }, { kind: "named-profile", name: "research" }]; + switch (artifactRule.scope) { + case "agent-home": + return [{ kind: "default" }, { kind: "named-profile", name: "research" }]; + case "default-home": + return [{ kind: "default" }]; + case "dashboard": + return [{ kind: "dashboard" }]; + case "named-profile": + return [{ kind: "named-profile", name: "research" }]; + } +} + +function homesForResidual(residual: HermesUnsupportedResidual): HermesHome[] { + switch (residual.scope) { + case "agent-home": + return [{ kind: "default" }, { kind: "named-profile", name: "research" }]; + case "default-home": + return [{ kind: "default" }]; + case "named-profile": + return [{ kind: "named-profile", name: "research" }]; } - if (artifactRule.scope === "default-home") return [{ kind: "default" }]; - if (artifactRule.scope === "dashboard") return [{ kind: "dashboard" }]; - return [{ kind: "named-profile", name: "research" }]; } function postures(requirement: HermesPostureRequirement): HermesPosture[] { @@ -45,8 +64,163 @@ function materializePattern(pattern: string): string { return pattern.replace(/\*\*/gu, "nested/item").replace(/\{[^/{}]+\}/gu, "sample"); } +function migrationSourcesFor( + artifactRule: HermesManagedArtifact, +): readonly HermesMigrationSource[] { + return artifactRule.migrationSources ?? []; +} + +function lifecyclePostures(required: HermesRequiredPostures): HermesPostureRequirement[] { + return [required.create, required.restore, required.shields?.up, required.shields?.down].filter( + (requirement): requirement is HermesPostureRequirement => + requirement !== undefined && requirement !== "absent", + ); +} + +function requiredPosture( + requirement: HermesPostureRequirement | "absent", + message: string, +): HermesPostureRequirement { + expect(requirement, message).not.toBe("absent"); + return requirement as HermesPostureRequirement; +} + +function contentOwner(posture: ReturnType): string { + switch (posture.kind) { + case "path": + return posture.owner; + case "tree": + return posture.descendantOwner; + } +} + +function expectProducerOwnership(entry: HermesManagedArtifact, topology: HermesTopology): void { + const producers = resolveHermesIdentities(entry.producers, topology); + const readers = resolveHermesIdentities(entry.readers, topology); + expect(producers.length, entry.id).toBeGreaterThan(0); + expect(readers.length, entry.id).toBeGreaterThan(0); + + for (const producer of producers.filter((candidate) => candidate !== "root")) { + const create = resolveHermesPosture(entry.required.create, topology, producer); + expect(readers, entry.id + ":" + producer).toContain(producer); + expect([producer, "preserve"], entry.id + ":" + producer).toContain(contentOwner(create)); + } +} + +function expectScopeRules(entry: HermesManagedArtifact): void { + switch (typeof entry.relativePath) { + case "object": + expect(entry.scope, entry.id).toBe("agent-home"); + break; + default: + break; + } + + for (const rule of [entry.backup, entry.restore]) { + switch (typeof rule) { + case "object": + switch ("default" in rule) { + case true: + expect(entry.scope, entry.id).toBe("agent-home"); + break; + case false: + break; + } + break; + default: + break; + } + } +} + +function expectPostureShape( + entry: HermesManagedArtifact, + requirement: HermesPostureRequirement, +): void { + for (const posture of postures(requirement)) { + switch (entry.match) { + case "subtree": + expect(posture.kind, entry.id).toBe("tree"); + break; + default: + break; + } + switch (posture.kind) { + case "path": + expect(posture.mode, entry.id).toMatch(/^0[0-7]{3,4}$/); + break; + case "tree": + expect(posture.directoryMode, entry.id).toMatch(/^0[0-7]{3,4}$/); + expect(posture.descendantOwner, entry.id).not.toBe("producer"); + expect(posture.symlinks, entry.id).toBe("ownership-only"); + break; + } + } +} + +function expectSelectiveBackup( + backup: ReturnType, + id: string, +): void { + switch (typeof backup) { + case "object": + expect(backup.kind, id).toBe("selective"); + expect(backup.selectors.length, id).toBeGreaterThan(0); + expect(new Set(backup.selectors.map((selector) => selector.relativePattern)).size).toBe( + backup.selectors.length, + ); + break; + default: + break; + } +} + +function expectMigrationFailurePolicy(source: HermesMigrationSource, sourcePath: string): void { + switch (source.action) { + case "migrate": + expect(source.onFailure, sourcePath).toBe("leave-source"); + break; + case "discard": + break; + } +} + +function expectShieldsContract(entry: HermesManagedArtifact): void { + switch (entry.shields) { + case "seal": + expect(entry.required.shields, entry.id).toBeDefined(); + break; + case "keep-writable": + case "unchanged": + break; + } +} + +function expectRecoveryContract(entry: HermesManagedArtifact, home: HermesHome): void { + const backup = resolveHermesArtifactBackup(entry, home); + const restore = resolveHermesArtifactRestore(entry, home); + + switch (backup) { + case "exclude": + expect(restore, entry.id).not.toBe("restore"); + break; + default: + break; + } + switch (restore) { + case "discard": + expect(entry.required.restore, entry.id).toBe("absent"); + break; + case "regenerate": + case "restore": + expect(entry.required.restore, entry.id).not.toBe("absent"); + break; + } + expectSelectiveBackup(backup, entry.id); +} + describe("Hermes path ownership contract", () => { - it("classifies every named artifact and defines complete lifecycle policy (#8006)", () => { + it("uses unique artifact ids and every ownership class (#8006)", () => { expect(new Set(HERMES_MANAGED_ARTIFACTS.map((entry) => entry.id)).size).toBe( HERMES_MANAGED_ARTIFACTS.length, ); @@ -59,89 +233,52 @@ describe("Hermes path ownership contract", () => { "derived-disposable-state", ]), ); + }); - const concreteTargets = new Set(); - for (const entry of HERMES_MANAGED_ARTIFACTS as readonly HermesManagedArtifact[]) { + it("names producer and reader ownership in every topology (#8006)", () => { + for (const entry of HERMES_MANAGED_ARTIFACTS) { for (const topology of TOPOLOGIES) { - const producers = resolveHermesIdentities(entry.producers, topology); - const readers = resolveHermesIdentities(entry.readers, topology); - expect(producers.length, entry.id).toBeGreaterThan(0); - expect(readers.length, entry.id).toBeGreaterThan(0); - for (const producer of producers) { - const create = resolveHermesPosture(entry.required.create, topology, producer); - if (producer === "root") continue; - expect(readers, entry.id + ":" + producer).toContain(producer); - if (create.kind === "path") { - expect(create.owner, entry.id + ":" + producer).toBe(producer); - } else if (create.descendantOwner !== "preserve") { - expect(create.descendantOwner, entry.id + ":" + producer).toBe(producer); - } - } + expectProducerOwnership(entry, topology); } expect(resolveHermesIdentities(entry.producers, "same-uid"), entry.id).not.toContain( "gateway", ); expect(resolveHermesIdentities(entry.readers, "same-uid"), entry.id).not.toContain("gateway"); - expect(entry.presence === "required" || entry.presence === "optional", entry.id).toBe(true); - expect(typeof entry.relativePath === "string" || entry.scope === "agent-home", entry.id).toBe( - true, - ); - expect( - typeof entry.backup === "object" && - "default" in entry.backup && - entry.scope !== "agent-home", - entry.id, - ).toBe(false); - expect(typeof entry.restore === "object" && entry.scope !== "agent-home", entry.id).toBe( - false, - ); + } + }); - const required: HermesRequiredPostures = entry.required; - const lifecycleRequirements = [ - required.create, - required.restore, - required.shields?.up, - required.shields?.down, - ]; - for (const requirement of lifecycleRequirements) { - if (!requirement || requirement === "absent") continue; - for (const posture of postures(requirement)) { - if (entry.match === "subtree") expect(posture.kind, entry.id).toBe("tree"); - if (posture.kind === "path") { - expect(posture.mode, entry.id).toMatch(/^0[0-7]{3,4}$/); - } else { - expect(posture.directoryMode, entry.id).toMatch(/^0[0-7]{3,4}$/); - expect(posture.descendantOwner, entry.id).not.toBe("producer"); - expect(posture.symlinks, entry.id).toBe("ownership-only"); - } - } + it("defines scope, lifecycle posture, and Shields policy for every artifact (#8006)", () => { + for (const entry of HERMES_MANAGED_ARTIFACTS) { + expectScopeRules(entry); + for (const requirement of lifecyclePostures(entry.required)) { + expectPostureShape(entry, requirement); } + expectShieldsContract(entry); + } + }); - if (entry.shields === "seal") expect(entry.required.shields, entry.id).toBeDefined(); - + it("aligns backup and restore policy for every home layout (#8006)", () => { + for (const entry of HERMES_MANAGED_ARTIFACTS) { for (const home of homesFor(entry)) { - const backup = resolveHermesArtifactBackup(entry, home); - const restore = resolveHermesArtifactRestore(entry, home); - expect(backup === "exclude" && restore === "restore", entry.id).toBe(false); - expect(entry.required.restore === "absent", entry.id).toBe(restore === "discard"); - if (typeof backup === "object") { - expect(backup.kind, entry.id).toBe("selective"); - expect(backup.selectors.length, entry.id).toBeGreaterThan(0); - expect(new Set(backup.selectors.map((selector) => selector.relativePattern)).size).toBe( - backup.selectors.length, - ); - } + expectRecoveryContract(entry, home); + } + } + }); + it("resolves unique targets and migration sources for every home layout (#8006)", () => { + const concreteTargets = new Map(); + for (const entry of HERMES_MANAGED_ARTIFACTS) { + for (const home of homesFor(entry)) { const target = resolveHermesArtifactPath(entry, home); const key = home.kind + ":" + target + ":" + entry.match; - expect(concreteTargets.has(key), entry.id).toBe(false); - concreteTargets.add(key); + expect(concreteTargets.get(key), entry.id).toBeUndefined(); + concreteTargets.set(key, entry.id); const resolvedTarget = findHermesManagedArtifact(materializePattern(target)); expect(resolvedTarget?.artifact.id, entry.id + ":" + home.kind).toBe(entry.id); expect(resolvedTarget?.pathRole, entry.id + ":" + home.kind).toBe("target"); - for (const source of entry.migrationSources ?? []) { + for (const source of migrationSourcesFor(entry)) { expect(source.relativePath, entry.id).not.toBe("."); expect(source.relativePath, entry.id).not.toMatch(/^\//u); const sourcePath = @@ -149,9 +286,7 @@ describe("Hermes path ownership contract", () => { const resolvedSource = findHermesManagedArtifact(sourcePath); expect(resolvedSource?.artifact.id, sourcePath).toBe(entry.id); expect(resolvedSource?.pathRole, sourcePath).toBe("migration-source"); - if (source.action === "migrate") { - expect(source.onFailure, sourcePath).toBe("leave-source"); - } + expectMigrationFailurePolicy(source, sourcePath); } } } @@ -260,78 +395,25 @@ describe("Hermes path ownership contract", () => { expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/kanban.db")).toBeNull(); }); - it("separates unsupported direct-lifecycle residuals from managed paths (#8006)", () => { - expect(HERMES_UNSUPPORTED_RESIDUAL_PATHS).toEqual([ - { - id: "legacy-gateway-pid", - scope: "default-home", - relativePath: "gateway.pid", - match: "exact", - disposition: "discard", - }, - { - id: "legacy-cron-pid", - scope: "default-home", - relativePath: "cron.pid", - match: "exact", - disposition: "discard", - }, - { - id: "named-gateway-pid", - scope: "named-profile", - relativePath: "gateway.pid", - match: "exact", - disposition: "discard", - }, - { - id: "named-cron-pid", - scope: "named-profile", - relativePath: "cron.pid", - match: "exact", - disposition: "discard", - }, - { - id: "named-gateway-lock", - scope: "named-profile", - relativePath: "gateway.lock", - match: "exact", - disposition: "discard", - }, - { - id: "named-gateway-status", - scope: "named-profile", - relativePath: "gateway_state.json", - match: "exact", - disposition: "discard", - }, - { - id: "upstream-weixin-account-credential", - scope: "agent-home", - relativePath: "weixin/accounts/{account}.json", - match: "pattern", - disposition: "discard", - }, - { - id: "legacy-skill-usage-lock", - scope: "agent-home", - relativePath: "skills/.usage.json.lock", - match: "exact", - disposition: "discard", - }, - ]); - expect(findHermesManagedArtifact("/sandbox/.hermes/gateway.pid")).toBeNull(); - expect(findHermesManagedArtifact("/sandbox/.hermes/cron.pid")).toBeNull(); - expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/gateway.pid")).toBeNull(); - expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/cron.pid")).toBeNull(); - expect(findHermesManagedArtifact("/sandbox/.hermes/profiles/research/gateway.lock")).toBeNull(); - expect( - findHermesManagedArtifact("/sandbox/.hermes/profiles/research/gateway_state.json"), - ).toBeNull(); - expect(findHermesManagedArtifact("/sandbox/.hermes/weixin/accounts/primary.json")).toBeNull(); - expect( - findHermesManagedArtifact("/sandbox/.hermes/profiles/research/weixin/accounts/primary.json"), - ).toBeNull(); - expect(findHermesManagedArtifact("/sandbox/.hermes/skills/.usage.json.lock")).toBeNull(); + it("keeps unsupported residuals discard-only and outside the managed resolver (#8006)", () => { + const residualIds = HERMES_UNSUPPORTED_RESIDUAL_PATHS.map((entry) => entry.id); + const managedIds = HERMES_MANAGED_ARTIFACTS.map((entry) => entry.id); + expect(new Set(residualIds).size).toBe(residualIds.length); + expect(new Set(HERMES_UNSUPPORTED_RESIDUAL_PATHS.map((entry) => entry.disposition))).toEqual( + new Set(["discard"]), + ); + + for (const residual of HERMES_UNSUPPORTED_RESIDUAL_PATHS) { + expect(managedIds, residual.id).not.toContain(residual.id); + expect(residual.relativePath, residual.id).not.toBe("."); + expect(residual.relativePath, residual.id).not.toMatch(/^\//u); + + for (const home of homesForResidual(residual)) { + const target = + resolveHermesHomePath(home) + "/" + materializePattern(residual.relativePath); + expect(findHermesManagedArtifact(target), residual.id + ":" + home.kind).toBeNull(); + } + } }); it("resolves the pinned core profile surface for default and named homes (#8006)", () => { @@ -556,8 +638,10 @@ describe("Hermes path ownership contract", () => { fileMode: "0600", }); - const restoredMainDatabase = artifact("main-state-database").required.restore; - if (restoredMainDatabase === "absent") throw new Error("Main state database must restore"); + const restoredMainDatabase = requiredPosture( + artifact("main-state-database").required.restore, + "Main state database must restore", + ); expect(resolveHermesPosture(restoredMainDatabase, "root-separated")).toEqual({ kind: "path", owner: "sandbox", diff --git a/src/lib/agent/hermes-path-ownership.ts b/src/lib/agent/hermes-path-ownership.ts index 726fb2d76e5..9908a5e02ee 100644 --- a/src/lib/agent/hermes-path-ownership.ts +++ b/src/lib/agent/hermes-path-ownership.ts @@ -275,7 +275,7 @@ export const HERMES_CONTRACT_GAPS = [ "pinned Hermes WhatsApp bridge Baileys multi-file authentication state and NemoClaw snapshot/rebuild restore", migration: "privileged-transition", failure: "retain-source", - limitation: "same-uid topology cannot isolate gateway session credentials from sandbox code", + limitation: "same-UID topology cannot isolate gateway session credentials from sandbox code", }, ] as const satisfies readonly HermesContractGap[]; @@ -3596,9 +3596,20 @@ function validateHermesRelativePattern(pattern: string): void { } } +const VALIDATED_HERMES_RELATIVE_PATTERNS = new Set(); +const HERMES_PATTERN_SEGMENT_MATCHERS = new Map(); + function matchesPatternSegment(patternSegment: string, candidateSegment: string): boolean { + const cached = HERMES_PATTERN_SEGMENT_MATCHERS.get(patternSegment); + if (cached !== undefined) { + return cached === null ? patternSegment === candidateSegment : cached.test(candidateSegment); + } + const placeholders = [...patternSegment.matchAll(/\{[^{}]+\}/gu)]; - if (placeholders.length === 0) return patternSegment === candidateSegment; + if (placeholders.length === 0) { + HERMES_PATTERN_SEGMENT_MATCHERS.set(patternSegment, null); + return patternSegment === candidateSegment; + } let expression = "^"; let cursor = 0; @@ -3609,11 +3620,16 @@ function matchesPatternSegment(patternSegment: string, candidateSegment: string) cursor = index + placeholder[0].length; } expression += patternSegment.slice(cursor).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&") + "$"; - return new RegExp(expression, "u").test(candidateSegment); + const matcher = new RegExp(expression, "u"); + HERMES_PATTERN_SEGMENT_MATCHERS.set(patternSegment, matcher); + return matcher.test(candidateSegment); } function matchesHermesRelativePattern(pattern: string, candidate: string): boolean { - validateHermesRelativePattern(pattern); + if (!VALIDATED_HERMES_RELATIVE_PATTERNS.has(pattern)) { + validateHermesRelativePattern(pattern); + VALIDATED_HERMES_RELATIVE_PATTERNS.add(pattern); + } const patternSegments = pattern.split("/"); const candidateSegments = candidate.split("/"); const memo = new Map();