From 4b21da753ea2bbb3174e470452db914f688586d3 Mon Sep 17 00:00:00 2001 From: Galin Iliev Date: Sat, 8 Aug 2026 22:08:54 -0700 Subject: [PATCH] feat(memory): wire authorization shadow interfaces --- src/agents/project-memory-bootstrap.test.ts | 16 +- src/agents/project-memory-bootstrap.ts | 4 +- ...emory-authorization-path-inventory.test.ts | 496 +++++++++++++++ .../memory-authorization-path-inventory.ts | 575 ++++++++++++++++++ .../memory-authorization-runtime.test.ts | 258 ++++++++ src/plugins/memory-authorization-runtime.ts | 159 +++++ src/plugins/memory-authorization-shadow.ts | 55 ++ src/plugins/memory-runtime.test.ts | 52 ++ src/plugins/memory-runtime.ts | 41 +- 9 files changed, 1648 insertions(+), 8 deletions(-) create mode 100644 src/plugins/memory-authorization-path-inventory.test.ts create mode 100644 src/plugins/memory-authorization-path-inventory.ts create mode 100644 src/plugins/memory-authorization-runtime.test.ts create mode 100644 src/plugins/memory-authorization-runtime.ts create mode 100644 src/plugins/memory-authorization-shadow.ts diff --git a/src/agents/project-memory-bootstrap.test.ts b/src/agents/project-memory-bootstrap.test.ts index 7ef1b43479d5..3d5858fba4ce 100644 --- a/src/agents/project-memory-bootstrap.test.ts +++ b/src/agents/project-memory-bootstrap.test.ts @@ -6,17 +6,21 @@ import { } from "./project-memory-bootstrap.js"; const runtimeMocks = vi.hoisted(() => ({ + getSelectedRuntime: vi.fn(), getManager: vi.fn(), listCurated: vi.fn(), search: vi.fn(), })); -vi.mock("../plugins/memory-state.js", () => ({ - getMemoryRuntime: () => ({ getMemorySearchManager: runtimeMocks.getManager }), +vi.mock("../plugins/memory-runtime.js", () => ({ + getSelectedMemoryRuntime: runtimeMocks.getSelectedRuntime, })); describe("project memory bootstrap", () => { beforeEach(() => { + runtimeMocks.getSelectedRuntime + .mockReset() + .mockReturnValue({ getMemorySearchManager: runtimeMocks.getManager }); runtimeMocks.getManager.mockReset(); runtimeMocks.listCurated.mockReset(); runtimeMocks.search.mockReset(); @@ -97,10 +101,18 @@ describe("project memory bootstrap", () => { it("keeps sessions without an active repository unchanged", async () => { await expect(prepareEntries(entries, [])).resolves.toEqual([]); + expect(runtimeMocks.getSelectedRuntime).not.toHaveBeenCalled(); expect(runtimeMocks.getManager).not.toHaveBeenCalled(); expect(buildProjectMemoryWriteInstruction(undefined)).toBe(""); }); + it("acquires project bootstrap memory through the selected-runtime seam", async () => { + await prepareEntries(entries); + + expect(runtimeMocks.getSelectedRuntime).toHaveBeenCalledOnce(); + expect(runtimeMocks.getManager).toHaveBeenCalledOnce(); + }); + it("filters tagged raw entries fail-closed with the all-keys rule", () => { const contextFiles = [ { diff --git a/src/agents/project-memory-bootstrap.ts b/src/agents/project-memory-bootstrap.ts index bf140d572cdf..96ca5ea6f0f4 100644 --- a/src/agents/project-memory-bootstrap.ts +++ b/src/agents/project-memory-bootstrap.ts @@ -6,7 +6,7 @@ import { } from "../../packages/memory-host-sdk/src/engine-storage.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { MemorySearchResult } from "../memory-host-sdk/host/types.js"; -import { getMemoryRuntime } from "../plugins/memory-state.js"; +import { getSelectedMemoryRuntime } from "../plugins/memory-runtime.js"; import type { EmbeddedContextFile } from "./embedded-agent-helpers.js"; const PROJECT_MEMORY_BOOTSTRAP_MAX_CHARS = 2_000; @@ -123,7 +123,7 @@ export async function prepareProjectMemoryBootstrap(params: { if (params.activeProjectKeys.length === 0) { return []; } - const runtime = getMemoryRuntime(); + const runtime = getSelectedMemoryRuntime(); if (!runtime) { return []; } diff --git a/src/plugins/memory-authorization-path-inventory.test.ts b/src/plugins/memory-authorization-path-inventory.test.ts new file mode 100644 index 000000000000..6a17409d9353 --- /dev/null +++ b/src/plugins/memory-authorization-path-inventory.test.ts @@ -0,0 +1,496 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { listGitTrackedFiles } from "../test-utils/repo-files.js"; +import { + MEMORY_AUTHORIZATION_PATH_DISPOSITIONS, + MEMORY_AUTHORIZATION_PATH_INVENTORY, + type MemoryAuthorizationPathInventoryEntry, +} from "./memory-authorization-path-inventory.js"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +const REQUIRED_PHASE_0_PATH_IDS = [ + "selected-runtime-manager-acquisition", + "selected-runtime-backend-resolution", + "selected-runtime-startup-warmup-and-sync", + "bootstrap-memory-and-user-files", + "startup-recent-memory-context", + "memory-search-tool", + "memory-get-tool", + "session-transcript-search", + "active-memory-trigger-recall", + "memory-wiki-prompt-supplement-registration", + "memory-wiki-prompt-preparation-registration", + "memory-wiki-corpus-supplement-registration", + "memory-wiki-agent-query-read", + "memory-wiki-agent-mutation", + "memory-wiki-operator-query-read", + "memory-wiki-operator-mutation", + "memory-wiki-bridge-artifact-read", + "memory-wiki-bridge-artifact-import", + "memory-wiki-status", + "lancedb-tool-recall", + "lancedb-tool-store", + "lancedb-tool-forget", + "lancedb-auto-recall", + "lancedb-auto-capture", + "lancedb-cli-read", + "memory-prompt-supplements", + "memory-prompt-preparations", + "memory-corpus-supplements", + "talk-fast-context", + "project-memory-bootstrap", + "memory-status-command", + "memory-cli-search-and-get", + "memory-cli-sync-and-reindex", + "memory-doctor-inspection-and-repair", + "gateway-memory-search", + "memory-import", + "memory-migration-import", + "memory-export", + "memory-public-artifact-provider-list", + "generic-file-read", + "generic-file-write", + "generic-file-edit", + "generic-file-apply-patch", + "sandbox-workspace-mounts", + "unsandboxed-exec", + "post-compaction-session-memory-sync", + "memory-flush", + "transcript-event-write", + "transcript-history-and-replay", + "compaction-summary", + "compaction-checkpoint", + "compaction-checkpoint-operator-branch-and-restore", + "dreaming-source-recall", + "dreaming-derived-artifacts", + "profile-and-short-term-promotion", + "child-agent-delegation", + "child-agent-completion-handoff", + "cron-triggered-run", + "heartbeat-triggered-run", + "webhook-triggered-run", + "system-triggered-run", + "final-reply-delivery", + "message-tool-delivery", + "session-send-delivery", + "plugin-and-mcp-outbound-actions", +] as const; + +const MEMORY_MANAGER_CALL_NAMES = new Set([ + "getActiveMemorySearchManager", + "getMemorySearchManager", +]); + +const SUPPLEMENTAL_PATH_DIRECTIONS = { + "memory-wiki-prompt-supplement-registration": "egress", + "memory-wiki-prompt-preparation-registration": "egress", + "memory-wiki-corpus-supplement-registration": "egress", + "memory-wiki-agent-query-read": "egress", + "memory-wiki-agent-mutation": "ingress", + "memory-wiki-operator-query-read": "egress", + "memory-wiki-operator-mutation": "ingress", + "memory-wiki-bridge-artifact-read": "egress", + "memory-wiki-bridge-artifact-import": "ingress", + "lancedb-tool-recall": "egress", + "lancedb-tool-store": "ingress", + "lancedb-tool-forget": "ingress", + "lancedb-auto-recall": "egress", + "lancedb-auto-capture": "ingress", + "lancedb-cli-read": "egress", +} as const; + +const MEMORY_MIGRATION_IMPORT_ROUTE_SURFACES = [ + "src/cli/program/register.migrate.ts", + "src/commands/migrate.ts", + "src/commands/migrate/memory-import.ts", + "src/commands/migrate/apply.ts", + "extensions/migrate-claude/provider.ts", + "extensions/migrate-claude/plan.ts", + "extensions/migrate-claude/memory.ts", + "extensions/migrate-claude/apply.ts", + "extensions/migrate-hermes/provider.ts", + "extensions/migrate-hermes/plan.ts", + "extensions/migrate-hermes/memory.ts", + "extensions/migrate-hermes/apply.ts", + "extensions/codex/src/migration/provider.ts", + "extensions/codex/src/migration/plan.ts", + "extensions/codex/src/migration/apply.ts", +] as const; + +const MEMORY_MIGRATION_IMPORT_ROOTS = [ + "src/cli/program/register.migrate.ts", + "src/commands/migrate.ts", + "src/commands/migrate", + "extensions/migrate-claude", + "extensions/migrate-hermes", + "extensions/codex/src/migration", +] as const; + +function isProductionTypeScript(file: string): boolean { + return ( + file.endsWith(".ts") && + !file.endsWith(".d.ts") && + !file.includes(".test.") && + !file.includes(".spec.") && + !file.includes(".test-") && + !/(^|\/)test[-.]/u.test(file) && + !/(^|\/)tests?\//u.test(file) + ); +} + +function listContextFreeMemoryManagerCalls(file: string, sourceText: string): string[] { + const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + const callableBindings = new Set(MEMORY_MANAGER_CALL_NAMES); + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + const bindings = statement.importClause?.namedBindings; + if (!bindings || statement.importClause?.isTypeOnly || ts.isNamespaceImport(bindings)) { + continue; + } + for (const element of bindings.elements) { + const imported = element.propertyName?.text ?? element.name.text; + if (!element.isTypeOnly && MEMORY_MANAGER_CALL_NAMES.has(imported)) { + callableBindings.add(element.name.text); + } + } + } + const calls: string[] = []; + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + const expression = node.expression; + if ( + (ts.isIdentifier(expression) && callableBindings.has(expression.text)) || + (ts.isPropertyAccessExpression(expression) && + MEMORY_MANAGER_CALL_NAMES.has(expression.name.text)) || + (ts.isElementAccessExpression(expression) && + expression.argumentExpression !== undefined && + ts.isStringLiteral(expression.argumentExpression) && + MEMORY_MANAGER_CALL_NAMES.has(expression.argumentExpression.text)) + ) { + calls.push(expression.getText(source)); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(source, visit); + return calls; +} + +function listImportedCallBindings(source: ts.SourceFile, importedName: string): Set { + const bindings = new Set([importedName]); + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + const namedBindings = statement.importClause?.namedBindings; + if ( + !namedBindings || + statement.importClause?.isTypeOnly || + ts.isNamespaceImport(namedBindings) + ) { + continue; + } + for (const element of namedBindings.elements) { + const imported = element.propertyName?.text ?? element.name.text; + if (!element.isTypeOnly && imported === importedName) { + bindings.add(element.name.text); + } + } + } + return bindings; +} + +function isNamedCall( + expression: ts.LeftHandSideExpression, + bindings: ReadonlySet, +): boolean { + return ( + (ts.isIdentifier(expression) && bindings.has(expression.text)) || + (ts.isPropertyAccessExpression(expression) && bindings.has(expression.name.text)) + ); +} + +function isMigrationProviderApplyCall(expression: ts.LeftHandSideExpression): boolean { + if (!ts.isPropertyAccessExpression(expression) || expression.name.text !== "apply") { + return false; + } + const receiver = expression.expression; + return ( + (ts.isIdentifier(receiver) && receiver.text === "provider") || + (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "provider") + ); +} + +function containsMemoryStringLiteral(node: ts.Node): boolean { + let found = false; + const visit = (child: ts.Node) => { + if (ts.isStringLiteral(child) && child.text === "memory") { + found = true; + return; + } + ts.forEachChild(child, visit); + }; + visit(node); + return found; +} + +function isMemoryKindPropertyOfMigrationItem( + node: ts.PropertyAssignment, + createItemBindings: ReadonlySet, +): boolean { + if ( + !ts.isIdentifier(node.name) || + node.name.text !== "kind" || + !containsMemoryStringLiteral(node.initializer) + ) { + return false; + } + const object = node.parent; + const call = object.parent; + return ts.isObjectLiteralExpression(object) && ts.isCallExpression(call) + ? isNamedCall(call.expression, createItemBindings) + : false; +} + +function listMemoryMigrationIngressMarkers(file: string, sourceText: string): string[] { + const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + const createItemBindings = listImportedCallBindings(source, "createMigrationItem"); + const copyMemoryBindings = listImportedCallBindings(source, "copyMemoryMigrationFileItem"); + const defaultCommandBindings = listImportedCallBindings(source, "migrateDefaultCommand"); + const applyCommandBindings = listImportedCallBindings(source, "migrateApplyCommand"); + const applyBindings = listImportedCallBindings(source, "runMigrationApply"); + const markers: string[] = []; + const visit = (node: ts.Node) => { + if ( + ts.isPropertyAssignment(node) && + isMemoryKindPropertyOfMigrationItem(node, createItemBindings) + ) { + markers.push(node.getText(source)); + } + if (ts.isCallExpression(node)) { + if (isNamedCall(node.expression, copyMemoryBindings)) { + markers.push(node.getText(source)); + } + if (isNamedCall(node.expression, defaultCommandBindings)) { + markers.push("migration-default-command-dispatch"); + } + if (isNamedCall(node.expression, applyCommandBindings)) { + markers.push("migration-apply-command-dispatch"); + } + if (isNamedCall(node.expression, applyBindings)) { + // Generic migration application admits provider plans containing memory items, so every + // caller is an ingress route until an owning Phase records its authorization boundary. + markers.push("migration-plan-apply"); + } + if (isMigrationProviderApplyCall(node.expression)) { + markers.push("migration-provider-apply"); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return markers; +} + +describe("memory authorization path inventory", () => { + const inventory: readonly MemoryAuthorizationPathInventoryEntry[] = + MEMORY_AUTHORIZATION_PATH_INVENTORY; + + it("records every required ingress and egress path with one owner and disposition", () => { + const ids = inventory.map((entry) => entry.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toEqual(expect.arrayContaining([...REQUIRED_PHASE_0_PATH_IDS])); + expect(inventory.length).toBeGreaterThanOrEqual(55); + for (const item of inventory) { + expect(item.owner.length).toBeGreaterThan(0); + expect(MEMORY_AUTHORIZATION_PATH_DISPOSITIONS).toContain(item.disposition); + expect(item.surfaces.length).toBeGreaterThan(0); + } + }); + + it("keeps Phase 0 shadow-only and explicitly fails enforced bypasses closed", () => { + expect(inventory.filter((item) => item.disposition === "authorized")).toEqual([]); + expect(inventory.filter((item) => item.disposition === "operator-only-authenticated")).toEqual( + [], + ); + expect(inventory.some((item) => item.disposition === "legacy-only")).toBe(true); + expect(inventory.some((item) => item.disposition === "blocked-in-enforced-mode")).toBe(true); + }); + + it("keeps supplemental reads and mutations as distinct enforced-mode paths", () => { + const entriesById = new Map(inventory.map((item) => [item.id, item])); + for (const [id, direction] of Object.entries(SUPPLEMENTAL_PATH_DIRECTIONS)) { + expect(entriesById.get(id)).toMatchObject({ + direction, + disposition: "blocked-in-enforced-mode", + }); + } + }); + + it("keeps post-compaction session sync separate from memory-flush derivation", () => { + const entriesById = new Map(inventory.map((item) => [item.id, item])); + + expect(entriesById.get("post-compaction-session-memory-sync")).toMatchObject({ + direction: "ingress", + owner: "core-agent-runtime", + disposition: "blocked-in-enforced-mode", + surfaces: expect.arrayContaining([ + "src/agents/embedded-agent-runner/compaction-hooks.ts", + "src/agents/embedded-agent-runner/compaction-session-execution.ts", + "src/agents/embedded-agent-runner/compact.queued.ts", + "src/agents/embedded-agent-runner/run/timeout-context-recovery.ts", + ]), + }); + expect(entriesById.get("memory-flush")).toMatchObject({ + direction: "derive", + }); + }); + + it("records the complete operator migration memory-import route", () => { + const migrationImport = inventory.find((item) => item.id === "memory-migration-import"); + + expect(migrationImport).toMatchObject({ + direction: "ingress", + owner: "operator-memory-host", + disposition: "blocked-in-enforced-mode", + surfaces: expect.arrayContaining([...MEMORY_MIGRATION_IMPORT_ROUTE_SURFACES]), + }); + }); + + it("names only existing production surfaces", () => { + const missing = inventory.flatMap((item) => + item.surfaces + .filter((surface) => !fs.existsSync(path.join(REPO_ROOT, surface))) + .map((surface) => `${item.id}:${surface}`), + ); + expect(missing).toEqual([]); + }); + + it("finds direct and aliased context-free manager acquisition calls", () => { + expect( + listContextFreeMemoryManagerCalls( + "fixture.ts", + ` + import { getActiveMemorySearchManager as active } from "./memory-runtime.js"; + import { getMemorySearchManager as manager } from "./manager.js"; + import * as runtime from "./memory-runtime.js"; + import type { getMemorySearchManager as TypeOnly } from "./manager.js"; + interface Runtime { getMemorySearchManager(params: unknown): Promise; } + async function acquire(value: Runtime) { + await active({}); await manager({}); await runtime.getActiveMemorySearchManager({}); + await value.getMemorySearchManager({}); await value["getMemorySearchManager"]({}); + } + `, + ), + ).toEqual([ + "active", + "manager", + "runtime.getActiveMemorySearchManager", + "value.getMemorySearchManager", + 'value["getMemorySearchManager"]', + ]); + }); + + it("finds the generic migration provider apply consumer without matching unrelated apply calls", () => { + expect( + listMemoryMigrationIngressMarkers( + "fixture.ts", + ` + async function apply(params: { provider: { apply: Function } }, ctx: unknown) { + await params.provider.apply(ctx, {}); + await other.apply(ctx, {}); + } + `, + ), + ).toEqual(["migration-provider-apply"]); + }); + + it("finds generic migration plan application consumers that can apply memory items", () => { + expect( + listMemoryMigrationIngressMarkers( + "fixture.ts", + ` + import { runMigrationApply as apply } from "./apply.js"; + async function migrate() { + await apply({}); + await unrelated({}); + } + `, + ), + ).toEqual(["migration-plan-apply"]); + }); + + it("recognizes the generic CLI migration apply ingress", () => { + const command = "src/cli/program/register.migrate.ts"; + const source = fs.readFileSync(path.join(REPO_ROOT, command), "utf8"); + + expect(listMemoryMigrationIngressMarkers(command, source)).toEqual( + expect.arrayContaining([ + "migration-default-command-dispatch", + "migration-apply-command-dispatch", + ]), + ); + }); + + it("does not treat test-only source helpers as production manager paths", () => { + expect( + isProductionTypeScript("extensions/memory-core/src/memory/test-manager-helpers.ts"), + ).toBe(false); + expect(isProductionTypeScript("extensions/memory-core/src/memory/search-manager.ts")).toBe( + true, + ); + }); + + it("does not allow an unrecorded production context-free manager acquisition", () => { + const tracked = listGitTrackedFiles({ + repoRoot: REPO_ROOT, + pathspecs: ["src", "extensions", "packages"], + }); + if (!tracked) { + throw new Error("could not list tracked files for the authorization-path inventory"); + } + const inventoried = new Set(inventory.flatMap((item) => item.surfaces)); + const missing = tracked + .filter(isProductionTypeScript) + .filter( + (file) => + listContextFreeMemoryManagerCalls( + file, + fs.readFileSync(path.join(REPO_ROOT, file), "utf8"), + ).length > 0, + ) + .filter((file) => !inventoried.has(file)); + expect(missing).toEqual([]); + }); + + it("does not allow an unrecorded production migration memory-import producer or consumer", () => { + const tracked = listGitTrackedFiles({ + repoRoot: REPO_ROOT, + pathspecs: [...MEMORY_MIGRATION_IMPORT_ROOTS], + }); + if (!tracked) { + throw new Error( + "could not list tracked files for the migration authorization-path inventory", + ); + } + const inventoried = new Set(inventory.flatMap((item) => item.surfaces)); + const missing = tracked + .filter(isProductionTypeScript) + .filter( + (file) => + listMemoryMigrationIngressMarkers( + file, + fs.readFileSync(path.join(REPO_ROOT, file), "utf8"), + ).length > 0, + ) + .filter((file) => !inventoried.has(file)); + + expect(missing).toEqual([]); + }); +}); diff --git a/src/plugins/memory-authorization-path-inventory.ts b/src/plugins/memory-authorization-path-inventory.ts new file mode 100644 index 000000000000..a65cb11c0b35 --- /dev/null +++ b/src/plugins/memory-authorization-path-inventory.ts @@ -0,0 +1,575 @@ +/** Phase-0 inventory of every known path that can ingest, expose, or derive memory. */ +export const MEMORY_AUTHORIZATION_PATH_DISPOSITIONS = [ + "authorized", + "blocked-in-enforced-mode", + "legacy-only", + "operator-only-authenticated", +] as const; + +export type MemoryAuthorizationPathDisposition = + (typeof MEMORY_AUTHORIZATION_PATH_DISPOSITIONS)[number]; +export type MemoryAuthorizationPathDirection = "control" | "ingress" | "egress" | "derive"; +export type MemoryAuthorizationPathOwner = + | "core-access-host" + | "core-agent-runtime" + | "core-session-runtime" + | "core-tool-runtime" + | "operator-memory-host" + | "selected-memory-plugin" + | "supplemental-memory-plugin" + | "transport-egress-host" + | "autonomous-run-host"; + +export type MemoryAuthorizationPathInventoryEntry = Readonly<{ + id: string; + direction: MemoryAuthorizationPathDirection; + owner: MemoryAuthorizationPathOwner; + disposition: MemoryAuthorizationPathDisposition; + surfaces: readonly [string, ...string[]]; +}>; + +function entry( + id: string, + direction: MemoryAuthorizationPathDirection, + owner: MemoryAuthorizationPathOwner, + disposition: MemoryAuthorizationPathDisposition, + ...surfaces: [string, ...string[]] +): MemoryAuthorizationPathInventoryEntry { + return Object.freeze({ id, direction, owner, disposition, surfaces: Object.freeze(surfaces) }); +} + +/** + * `authorized` is intentionally absent in Phase 0: the rollout is shadow-only. Every path keeps + * its current legacy behavior until its owning phase converts it, and enforced mode then blocks + * the explicitly listed bypasses rather than silently falling through to context-free access. + */ +export const MEMORY_AUTHORIZATION_PATH_INVENTORY = Object.freeze([ + entry( + "selected-runtime-manager-acquisition", + "control", + "core-access-host", + "legacy-only", + "src/plugins/memory-runtime.ts", + "src/plugins/memory-state.ts", + "src/plugin-sdk/memory-host-search.ts", + "extensions/memory-core/index.ts", + "extensions/memory-core/src/runtime-provider.ts", + "extensions/memory-core/src/memory/search-manager.ts", + ), + entry( + "selected-runtime-backend-resolution", + "control", + "core-access-host", + "legacy-only", + "src/plugins/memory-runtime.ts", + ), + entry( + "selected-runtime-startup-warmup-and-sync", + "control", + "selected-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-core/index.ts", + "src/gateway/server-startup-memory.ts", + ), + entry( + "bootstrap-memory-and-user-files", + "egress", + "core-agent-runtime", + "legacy-only", + "src/agents/bootstrap-files.ts", + "src/agents/workspace-bootstrap-read.ts", + ), + entry( + "startup-recent-memory-context", + "egress", + "core-agent-runtime", + "legacy-only", + "src/auto-reply/reply/startup-context.ts", + ), + entry( + "memory-search-tool", + "egress", + "selected-memory-plugin", + "legacy-only", + "extensions/memory-core/src/tools.ts", + "extensions/memory-core/src/tools.shared.ts", + ), + entry( + "memory-get-tool", + "egress", + "selected-memory-plugin", + "legacy-only", + "extensions/memory-core/src/tools.ts", + ), + entry( + "session-transcript-search", + "egress", + "selected-memory-plugin", + "legacy-only", + "extensions/memory-core/src/session-search-visibility.ts", + "extensions/memory-core/src/tools.ts", + ), + entry( + "active-memory-trigger-recall", + "egress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/active-memory/trigger-recall.ts", + ), + entry( + "active-memory-session-recall", + "egress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/active-memory/recall.ts", + "extensions/active-memory/recall-run.ts", + ), + entry( + "memory-wiki-prompt-supplement-registration", + "egress", + "core-access-host", + "blocked-in-enforced-mode", + "extensions/memory-wiki/index.ts", + "extensions/memory-wiki/src/prompt-section.ts", + "src/plugins/memory-state.ts", + ), + entry( + "memory-wiki-prompt-preparation-registration", + "egress", + "core-access-host", + "blocked-in-enforced-mode", + "extensions/memory-wiki/index.ts", + "extensions/memory-wiki/src/prompt-section.ts", + "src/plugins/memory-state.ts", + ), + entry( + "memory-wiki-corpus-supplement-registration", + "egress", + "core-access-host", + "blocked-in-enforced-mode", + "extensions/memory-wiki/index.ts", + "extensions/memory-wiki/src/corpus-supplement.ts", + "extensions/memory-core/src/tools.shared.ts", + ), + entry( + "memory-wiki-agent-query-read", + "egress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-wiki/index.ts", + "extensions/memory-wiki/src/tool.ts", + "extensions/memory-wiki/src/query.ts", + ), + entry( + "memory-wiki-agent-mutation", + "ingress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-wiki/index.ts", + "extensions/memory-wiki/src/tool.ts", + ), + entry( + "memory-wiki-operator-query-read", + "egress", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-wiki/src/gateway.ts", + "extensions/memory-wiki/src/cli.ts", + "extensions/memory-wiki/src/query.ts", + ), + entry( + "memory-wiki-operator-mutation", + "ingress", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-wiki/src/gateway.ts", + "extensions/memory-wiki/src/cli.ts", + "extensions/memory-wiki/src/ingest.ts", + ), + entry( + "memory-wiki-bridge-artifact-read", + "egress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-wiki/src/bridge.ts", + ), + entry( + "memory-wiki-bridge-artifact-import", + "ingress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-wiki/src/bridge.ts", + "extensions/memory-wiki/src/source-page-shared.ts", + ), + entry( + "memory-wiki-status", + "egress", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-wiki/src/status.ts", + ), + entry( + "lancedb-tool-recall", + "egress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-lancedb/index.ts", + ), + entry( + "lancedb-tool-store", + "ingress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-lancedb/index.ts", + ), + entry( + "lancedb-tool-forget", + "ingress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-lancedb/index.ts", + ), + entry( + "lancedb-auto-recall", + "egress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-lancedb/index.ts", + ), + entry( + "lancedb-auto-capture", + "ingress", + "supplemental-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-lancedb/index.ts", + ), + entry( + "lancedb-cli-read", + "egress", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-lancedb/index.ts", + "extensions/memory-lancedb/memory-cli.ts", + ), + entry( + "memory-prompt-supplements", + "egress", + "core-access-host", + "blocked-in-enforced-mode", + "src/plugins/memory-state.ts", + ), + entry( + "memory-prompt-preparations", + "egress", + "core-access-host", + "blocked-in-enforced-mode", + "src/plugins/memory-state.ts", + ), + entry( + "memory-corpus-supplements", + "egress", + "core-access-host", + "blocked-in-enforced-mode", + "src/plugins/memory-state.ts", + "extensions/memory-core/src/tools.shared.ts", + ), + entry( + "talk-fast-context", + "egress", + "core-agent-runtime", + "blocked-in-enforced-mode", + "src/talk/fast-context-runtime.ts", + ), + entry( + "project-memory-bootstrap", + "egress", + "core-agent-runtime", + "blocked-in-enforced-mode", + "src/agents/project-memory-bootstrap.ts", + ), + entry( + "memory-status-command", + "egress", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-core/src/cli-status.runtime.ts", + "src/commands/status.scan-memory.ts", + "src/commands/status.scan.deps.runtime.ts", + "src/commands/status.scan.shared.ts", + ), + entry( + "memory-cli-search-and-get", + "egress", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-core/src/cli-index-search.runtime.ts", + "extensions/memory-core/src/cli-runtime-common.ts", + ), + entry( + "memory-cli-sync-and-reindex", + "control", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-core/src/cli-index-search.runtime.ts", + ), + entry( + "memory-doctor-inspection-and-repair", + "control", + "operator-memory-host", + "blocked-in-enforced-mode", + "src/commands/doctor-memory-search.ts", + "src/gateway/server-methods/doctor.ts", + ), + entry( + "gateway-memory-search", + "egress", + "operator-memory-host", + "blocked-in-enforced-mode", + "src/gateway/server-methods/memory-search.ts", + ), + entry( + "memory-import", + "ingress", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-core/src/cli-rem.runtime.ts", + ), + entry( + "memory-migration-import", + "ingress", + "operator-memory-host", + "blocked-in-enforced-mode", + "src/cli/program/register.migrate.ts", + "src/commands/migrate.ts", + "src/commands/migrate/memory-import.ts", + "src/commands/migrate/apply.ts", + "extensions/migrate-claude/provider.ts", + "extensions/migrate-claude/plan.ts", + "extensions/migrate-claude/memory.ts", + "extensions/migrate-claude/apply.ts", + "extensions/migrate-hermes/provider.ts", + "extensions/migrate-hermes/plan.ts", + "extensions/migrate-hermes/memory.ts", + "extensions/migrate-hermes/apply.ts", + "extensions/codex/src/migration/provider.ts", + "extensions/codex/src/migration/plan.ts", + "extensions/codex/src/migration/apply.ts", + ), + entry( + "memory-export", + "egress", + "operator-memory-host", + "blocked-in-enforced-mode", + "extensions/memory-core/src/cli-index-search.runtime.ts", + ), + entry( + "memory-public-artifact-provider-list", + "egress", + "selected-memory-plugin", + "blocked-in-enforced-mode", + "src/plugins/memory-state.ts", + "extensions/memory-core/index.ts", + "extensions/memory-lancedb/index.ts", + ), + entry( + "generic-file-read", + "egress", + "core-tool-runtime", + "blocked-in-enforced-mode", + "src/agents/agent-tools.read.ts", + "src/agents/tool-fs-policy.ts", + ), + entry( + "generic-file-write", + "ingress", + "core-tool-runtime", + "blocked-in-enforced-mode", + "src/agents/agent-tools.read.ts", + "src/agents/tool-fs-policy.ts", + ), + entry( + "generic-file-edit", + "ingress", + "core-tool-runtime", + "blocked-in-enforced-mode", + "src/agents/agent-tools.read.ts", + "src/agents/tool-fs-policy.ts", + ), + entry( + "generic-file-apply-patch", + "ingress", + "core-tool-runtime", + "blocked-in-enforced-mode", + "src/agents/apply-patch.ts", + "src/agents/tool-fs-policy.ts", + ), + entry( + "sandbox-workspace-mounts", + "control", + "core-tool-runtime", + "blocked-in-enforced-mode", + "src/agents/sandbox/workspace-mounts.ts", + ), + entry( + "unsandboxed-exec", + "egress", + "core-tool-runtime", + "blocked-in-enforced-mode", + "src/agents/bash-tools.ts", + "src/agents/tool-fs-policy.ts", + ), + entry( + "post-compaction-session-memory-sync", + "ingress", + "core-agent-runtime", + "blocked-in-enforced-mode", + "src/agents/embedded-agent-runner/compaction-hooks.ts", + "src/agents/embedded-agent-runner/compaction-session-execution.ts", + "src/agents/embedded-agent-runner/compact.queued.ts", + "src/agents/embedded-agent-runner/run/timeout-context-recovery.ts", + ), + entry( + "memory-flush", + "derive", + "core-agent-runtime", + "blocked-in-enforced-mode", + "src/auto-reply/reply/agent-runner-memory.ts", + "src/agents/embedded-agent-runner/compaction-hooks.ts", + "extensions/memory-core/src/flush-plan.ts", + ), + entry( + "transcript-event-write", + "ingress", + "core-session-runtime", + "blocked-in-enforced-mode", + "src/agents/sessions/session-manager-persistence.ts", + "src/config/sessions/session-accessor.sqlite-transcript-write.ts", + "src/config/sessions/transcript-write-context.ts", + ), + entry( + "transcript-history-and-replay", + "egress", + "core-session-runtime", + "blocked-in-enforced-mode", + "src/gateway/session-transcript-readers.ts", + "src/agents/embedded-agent-runner/transcript-rewrite.ts", + ), + entry( + "compaction-summary", + "derive", + "core-agent-runtime", + "blocked-in-enforced-mode", + "src/agents/compaction.ts", + "src/agents/embedded-agent-runner/compaction-session-execution.ts", + ), + entry( + "compaction-checkpoint", + "derive", + "core-agent-runtime", + "blocked-in-enforced-mode", + "src/agents/embedded-agent-runner/compaction-checkpoint.ts", + "src/agents/main-session-restart-recovery-checkpoint.ts", + "src/gateway/session-compaction-checkpoints.ts", + ), + entry( + "compaction-checkpoint-operator-branch-and-restore", + "control", + "operator-memory-host", + "blocked-in-enforced-mode", + "src/gateway/server-methods/sessions-compaction-checkpoints.ts", + ), + entry( + "dreaming-source-recall", + "egress", + "selected-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-core/src/dreaming-phases.ts", + ), + entry( + "dreaming-derived-artifacts", + "derive", + "selected-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-core/src/dreaming-narrative.ts", + "extensions/memory-core/src/dreaming-markdown.ts", + ), + entry( + "profile-and-short-term-promotion", + "derive", + "selected-memory-plugin", + "blocked-in-enforced-mode", + "extensions/memory-core/src/short-term-promotion-apply.ts", + ), + entry( + "child-agent-delegation", + "egress", + "core-agent-runtime", + "blocked-in-enforced-mode", + "src/agents/subagent-registry.ts", + "src/agents/openclaw-tools.ts", + ), + entry( + "child-agent-completion-handoff", + "egress", + "core-agent-runtime", + "blocked-in-enforced-mode", + "src/agents/subagent-announce-delivery.ts", + ), + entry( + "cron-triggered-run", + "control", + "autonomous-run-host", + "blocked-in-enforced-mode", + "src/cron/service/timer-execution.ts", + ), + entry( + "heartbeat-triggered-run", + "control", + "autonomous-run-host", + "blocked-in-enforced-mode", + "src/infra/heartbeat-runner.ts", + ), + entry( + "webhook-triggered-run", + "control", + "autonomous-run-host", + "blocked-in-enforced-mode", + "src/gateway/server/hooks-request-handler.ts", + ), + entry( + "system-triggered-run", + "control", + "autonomous-run-host", + "blocked-in-enforced-mode", + "src/gateway/server-methods/system-agent.ts", + ), + entry( + "final-reply-delivery", + "egress", + "transport-egress-host", + "blocked-in-enforced-mode", + "src/auto-reply/reply/reply-delivery.ts", + ), + entry( + "message-tool-delivery", + "egress", + "transport-egress-host", + "blocked-in-enforced-mode", + "src/agents/tools/message-tool.ts", + ), + entry( + "session-send-delivery", + "egress", + "transport-egress-host", + "blocked-in-enforced-mode", + "src/agents/tools/sessions-send-tool.ts", + ), + entry( + "plugin-and-mcp-outbound-actions", + "egress", + "transport-egress-host", + "blocked-in-enforced-mode", + "src/plugins/tools.ts", + "src/agents/mcp-transport.ts", + ), +] satisfies readonly MemoryAuthorizationPathInventoryEntry[]); diff --git a/src/plugins/memory-authorization-runtime.test.ts b/src/plugins/memory-authorization-runtime.test.ts new file mode 100644 index 000000000000..4ad3d5fb3a75 --- /dev/null +++ b/src/plugins/memory-authorization-runtime.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from "vitest"; +import { + COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES, + LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES, + MEMORY_AUTHORIZATION_CAPABILITY_NAMES, +} from "../memory-host-sdk/host/authorization.js"; +import { inspectMemoryAuthorizationRuntime } from "./memory-authorization-runtime.js"; +import { observeMemoryAuthorizationShadowSurface } from "./memory-authorization-shadow.js"; + +const AUTHORIZED_METHOD_NAMES = [ + "authorize", + "searchAuthorized", + "readAuthorized", + "writeAuthorized", + "importAuthorized", + "syncAuthorized", + "exportAuthorized", + "statusAuthorized", +] as const; + +function createRuntime(capabilities = COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES) { + const notCalled = vi.fn(() => { + throw new Error("runtime methods must not execute in shadow mode"); + }); + return { + authorization: capabilities, + authorize: notCalled, + searchAuthorized: notCalled, + readAuthorized: notCalled, + writeAuthorized: notCalled, + importAuthorized: notCalled, + syncAuthorized: notCalled, + exportAuthorized: notCalled, + statusAuthorized: notCalled, + legacyManager: { search: notCalled }, + }; +} + +class PrototypeRuntime { + readonly authorization = COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES; + + authorize() { + throw new Error("runtime methods must not execute in shadow mode"); + } + + searchAuthorized() { + throw new Error("runtime methods must not execute in shadow mode"); + } + + readAuthorized() { + throw new Error("runtime methods must not execute in shadow mode"); + } + + writeAuthorized() { + throw new Error("runtime methods must not execute in shadow mode"); + } + + importAuthorized() { + throw new Error("runtime methods must not execute in shadow mode"); + } + + syncAuthorized() { + throw new Error("runtime methods must not execute in shadow mode"); + } + + exportAuthorized() { + throw new Error("runtime methods must not execute in shadow mode"); + } + + statusAuthorized() { + throw new Error("runtime methods must not execute in shadow mode"); + } +} + +describe("memory authorization runtime inspection", () => { + it("reports a complete declared surface without calling an authorized or legacy method", () => { + const runtime = createRuntime(); + const inspection = inspectMemoryAuthorizationRuntime(runtime); + + expect(inspection).toMatchObject({ + version: 1, + capabilityDeclaration: "complete", + declaredCapabilityCount: MEMORY_AUTHORIZATION_CAPABILITY_NAMES.length, + implementedMethodCount: AUTHORIZED_METHOD_NAMES.length, + surfaceComplete: true, + reasonCode: "surface-complete", + }); + expect(inspection.missingCapabilities).toEqual([]); + expect(inspection.missingMethods).toEqual([]); + expect(Object.isFrozen(inspection)).toBe(true); + expect(Object.isFrozen(inspection.missingMethods)).toBe(true); + expect(runtime.authorize).not.toHaveBeenCalled(); + expect(runtime.legacyManager.search).not.toHaveBeenCalled(); + }); + + it("reports all-false and incomplete declarations as nonconforming", () => { + const legacy = inspectMemoryAuthorizationRuntime( + createRuntime(LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES), + ); + const incomplete = inspectMemoryAuthorizationRuntime({ + authorization: { ...COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES, scopedSync: false }, + ...Object.fromEntries(AUTHORIZED_METHOD_NAMES.map((name) => [name, () => undefined])), + }); + + expect(legacy).toMatchObject({ + capabilityDeclaration: "partial", + declaredCapabilityCount: 0, + surfaceComplete: false, + reasonCode: "backend-nonconforming", + }); + expect(legacy.missingCapabilities).toEqual(MEMORY_AUTHORIZATION_CAPABILITY_NAMES); + expect(incomplete).toMatchObject({ + capabilityDeclaration: "partial", + declaredCapabilityCount: MEMORY_AUTHORIZATION_CAPABILITY_NAMES.length - 1, + surfaceComplete: false, + reasonCode: "backend-nonconforming", + }); + expect(incomplete.missingCapabilities).toEqual(["scopedSync"]); + }); + + it("uses the SDK's exact capability-declaration rules for shadow reporting", () => { + const unexpectedCapability = inspectMemoryAuthorizationRuntime({ + ...createRuntime(), + authorization: { ...COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES, unexpected: true }, + }); + const symbolicCapability = inspectMemoryAuthorizationRuntime({ + ...createRuntime(), + authorization: { + ...COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES, + [Symbol("plugin-framework-metadata")]: true, + }, + }); + + expect(unexpectedCapability).toMatchObject({ + capabilityDeclaration: "malformed", + declaredCapabilityCount: 0, + reasonCode: "backend-nonconforming", + }); + expect(symbolicCapability).toMatchObject({ + capabilityDeclaration: "malformed", + declaredCapabilityCount: 0, + reasonCode: "backend-nonconforming", + }); + }); + + it("accepts data descriptors from class prototypes and ignores unrelated symbols", () => { + const runtime = new PrototypeRuntime(); + Object.defineProperty(runtime, Symbol("plugin-framework-metadata"), { + value: "not an authorization capability", + }); + + expect(inspectMemoryAuthorizationRuntime(runtime)).toMatchObject({ + capabilityDeclaration: "complete", + implementedMethodCount: AUTHORIZED_METHOD_NAMES.length, + surfaceComplete: true, + reasonCode: "surface-complete", + }); + }); + + it("fails closed on accessor and proxy surfaces without evaluating their getters or methods", () => { + let getterCalls = 0; + const accessorRuntime = Object.create(null) as Record; + Object.defineProperty(accessorRuntime, "authorization", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("must not read authorization getter"); + }, + }); + for (const name of AUTHORIZED_METHOD_NAMES) { + Object.defineProperty(accessorRuntime, name, { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("must not read method getter"); + }, + }); + } + const proxyRuntime = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error("hostile proxy"); + }, + }, + ); + const declarationProxyRuntime = { + authorization: new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("hostile capability declaration"); + }, + }, + ), + }; + + const accessor = inspectMemoryAuthorizationRuntime(accessorRuntime); + const undefinedDeclaration = inspectMemoryAuthorizationRuntime({ authorization: undefined }); + const proxy = inspectMemoryAuthorizationRuntime(proxyRuntime); + const declarationProxy = inspectMemoryAuthorizationRuntime(declarationProxyRuntime); + + expect(getterCalls).toBe(0); + expect(accessor).toMatchObject({ + capabilityDeclaration: "malformed", + reasonCode: "backend-nonconforming", + }); + expect(undefinedDeclaration).toMatchObject({ + capabilityDeclaration: "malformed", + reasonCode: "backend-nonconforming", + }); + expect(proxy).toMatchObject({ + capabilityDeclaration: "malformed", + reasonCode: "backend-nonconforming", + }); + expect(declarationProxy).toMatchObject({ + capabilityDeclaration: "malformed", + reasonCode: "backend-nonconforming", + }); + }); +}); + +describe("memory authorization shadow inspection", () => { + it("returns one bounded, content-free observation per selected runtime", () => { + const runtime = Object.assign(createRuntime(LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES), { + content: "private content sentinel", + prompt: "private prompt sentinel", + query: "private query sentinel", + principalId: "private principal sentinel", + }); + const first = observeMemoryAuthorizationShadowSurface(runtime); + const second = observeMemoryAuthorizationShadowSurface(runtime); + + expect(first).toMatchObject({ + mode: "shadow", + capabilityDeclaration: "partial", + reasonCode: "backend-nonconforming", + }); + expect(second).toBeUndefined(); + expect(JSON.stringify(first)).not.toMatch(/private|content|prompt|query|principal/u); + }); + + it("does not let a hostile proxy change a selected runtime path", () => { + const runtime = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error("hostile proxy"); + }, + }, + ); + const observation = observeMemoryAuthorizationShadowSurface(runtime); + + expect(observation).toEqual( + expect.objectContaining({ reasonCode: "backend-nonconforming", surfaceComplete: false }), + ); + }); +}); diff --git a/src/plugins/memory-authorization-runtime.ts b/src/plugins/memory-authorization-runtime.ts new file mode 100644 index 000000000000..d55cf083984c --- /dev/null +++ b/src/plugins/memory-authorization-runtime.ts @@ -0,0 +1,159 @@ +import { + MEMORY_AUTHORIZATION_CAPABILITY_NAMES, + isMemoryAuthorizationCapabilities, + listMissingMemoryAuthorizationCapabilities, + type AuthorizedMemoryRuntime, + type MemoryAuthorizationCapabilityName, +} from "../memory-host-sdk/host/authorization.js"; + +const AUTHORIZED_MEMORY_RUNTIME_METHODS = [ + "authorize", + "searchAuthorized", + "readAuthorized", + "writeAuthorized", + "importAuthorized", + "syncAuthorized", + "exportAuthorized", + "statusAuthorized", +] as const satisfies readonly (keyof AuthorizedMemoryRuntime)[]; + +type AuthorizedMemoryRuntimeMethodName = (typeof AUTHORIZED_MEMORY_RUNTIME_METHODS)[number]; + +export type MemoryAuthorizationRuntimeInspection = Readonly<{ + version: 1; + capabilityDeclaration: "missing" | "malformed" | "partial" | "complete"; + declaredCapabilityCount: number; + requiredCapabilityCount: number; + implementedMethodCount: number; + requiredMethodCount: number; + missingCapabilities: readonly MemoryAuthorizationCapabilityName[]; + missingMethods: readonly AuthorizedMemoryRuntimeMethodName[]; + surfaceComplete: boolean; + reasonCode: "surface-complete" | "backend-nonconforming"; +}>; + +// Runtime interfaces are shallow; the bound prevents hostile prototype chains from extending a +// shadow-only inspection beyond its fixed metadata budget. +const MAXIMUM_RUNTIME_PROTOTYPE_DEPTH = 8; + +function isObjectReference(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +type DataPropertyLookup = + | { kind: "data"; value: unknown } + | { kind: "missing" | "accessor" | "unavailable" }; + +/** + * Reads a data descriptor without evaluating the corresponding property. Runtime interfaces may + * use class methods, so the bounded prototype walk accepts data descriptors there too. A getter + * or hostile reflection failure remains nonconforming without touching an authorized or legacy + * runtime method. + */ +function readDataProperty(value: unknown, key: string): DataPropertyLookup { + if (!isObjectReference(value)) { + return { kind: "missing" }; + } + try { + let current: object | null = value; + for (let depth = 0; current && depth < MAXIMUM_RUNTIME_PROTOTYPE_DEPTH; depth += 1) { + const descriptor = Object.getOwnPropertyDescriptor(current, key); + if (descriptor) { + return "value" in descriptor + ? { kind: "data", value: descriptor.value } + : { kind: "accessor" }; + } + current = Object.getPrototypeOf(current); + } + } catch { + // A Proxy can reject reflection. Treat it as a nonconforming declaration. + return { kind: "unavailable" }; + } + return { kind: "missing" }; +} + +/** + * The SDK validator deliberately enforces exact descriptor shape. A plugin runtime can still be + * a hostile Proxy, so shadow inspection turns any reflection failure into a nonconforming result. + */ +function inspectCapabilityDeclaration(value: unknown): { + hasWellFormedDeclaration: boolean; + missingCapabilities: readonly MemoryAuthorizationCapabilityName[]; +} { + try { + if (!isMemoryAuthorizationCapabilities(value)) { + return { + hasWellFormedDeclaration: false, + missingCapabilities: MEMORY_AUTHORIZATION_CAPABILITY_NAMES, + }; + } + return { + hasWellFormedDeclaration: true, + missingCapabilities: listMissingMemoryAuthorizationCapabilities(value), + }; + } catch { + return { + hasWellFormedDeclaration: false, + missingCapabilities: MEMORY_AUTHORIZATION_CAPABILITY_NAMES, + }; + } +} + +function freezeInspection(params: Omit) { + return Object.freeze({ + version: 1 as const, + ...params, + missingCapabilities: Object.freeze([...params.missingCapabilities]), + missingMethods: Object.freeze([...params.missingMethods]), + }); +} + +/** + * Produces content-free shape metadata only. It is intentionally not an admission decision and + * does not retain, invoke, or wrap the inspected runtime. + */ +export function inspectMemoryAuthorizationRuntime( + runtime: unknown, +): MemoryAuthorizationRuntimeInspection { + const authorization = readDataProperty(runtime, "authorization"); + const hasDeclaration = authorization.kind === "data"; + // Use the shared contract validator so shadow reporting and enforced-mode admission agree on + // the exact declaration shape. This boundary catches reflection traps as malformed. + const { hasWellFormedDeclaration, missingCapabilities } = inspectCapabilityDeclaration( + hasDeclaration ? authorization.value : undefined, + ); + const declaredCapabilityCount = + MEMORY_AUTHORIZATION_CAPABILITY_NAMES.length - missingCapabilities.length; + const methods = AUTHORIZED_MEMORY_RUNTIME_METHODS.map((name) => ({ + name, + property: readDataProperty(runtime, name), + })); + const implementedMethodCount = methods.filter( + ({ property }) => property.kind === "data" && typeof property.value === "function", + ).length; + const missingMethods = methods + .filter(({ property }) => property.kind !== "data" || typeof property.value !== "function") + .map(({ name }) => name); + const capabilityDeclaration = !hasDeclaration + ? authorization.kind === "missing" + ? "missing" + : "malformed" + : !hasWellFormedDeclaration + ? "malformed" + : missingCapabilities.length === 0 + ? "complete" + : "partial"; + const surfaceComplete = capabilityDeclaration === "complete" && missingMethods.length === 0; + + return freezeInspection({ + capabilityDeclaration, + declaredCapabilityCount, + requiredCapabilityCount: MEMORY_AUTHORIZATION_CAPABILITY_NAMES.length, + implementedMethodCount, + requiredMethodCount: AUTHORIZED_MEMORY_RUNTIME_METHODS.length, + missingCapabilities, + missingMethods, + surfaceComplete, + reasonCode: surfaceComplete ? "surface-complete" : "backend-nonconforming", + }); +} diff --git a/src/plugins/memory-authorization-shadow.ts b/src/plugins/memory-authorization-shadow.ts new file mode 100644 index 000000000000..ae489bee9cd5 --- /dev/null +++ b/src/plugins/memory-authorization-shadow.ts @@ -0,0 +1,55 @@ +import { MEMORY_AUTHORIZATION_CONTRACT_VERSION } from "../memory-host-sdk/host/authorization.js"; +import { inspectMemoryAuthorizationRuntime } from "./memory-authorization-runtime.js"; + +const inspectedRuntimes = new WeakSet(); + +export type MemoryAuthorizationShadowMetadata = Readonly<{ + event: "memory-authorization-backend-surface"; + mode: "shadow"; + contractVersion: 1; + capabilityDeclaration: "missing" | "malformed" | "partial" | "complete"; + declaredCapabilityCount: number; + requiredCapabilityCount: number; + implementedMethodCount: number; + requiredMethodCount: number; + surfaceComplete: boolean; + reasonCode: "surface-complete" | "backend-nonconforming"; +}>; + +function isObjectReference(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +/** + * Shadow mode returns bounded surface counts once per selected runtime. Reflection failures are + * nonconforming observations; logging remains with the runtime owner and cannot change selection. + */ +export function observeMemoryAuthorizationShadowSurface( + runtime: unknown, +): MemoryAuthorizationShadowMetadata | undefined { + if (!isObjectReference(runtime)) { + return undefined; + } + try { + if (inspectedRuntimes.has(runtime)) { + return undefined; + } + inspectedRuntimes.add(runtime); + const inspection = inspectMemoryAuthorizationRuntime(runtime); + const metadata = Object.freeze({ + event: "memory-authorization-backend-surface" as const, + mode: "shadow" as const, + contractVersion: MEMORY_AUTHORIZATION_CONTRACT_VERSION, + capabilityDeclaration: inspection.capabilityDeclaration, + declaredCapabilityCount: inspection.declaredCapabilityCount, + requiredCapabilityCount: inspection.requiredCapabilityCount, + implementedMethodCount: inspection.implementedMethodCount, + requiredMethodCount: inspection.requiredMethodCount, + surfaceComplete: inspection.surfaceComplete, + reasonCode: inspection.reasonCode, + }); + return metadata; + } catch { + return undefined; + } +} diff --git a/src/plugins/memory-runtime.test.ts b/src/plugins/memory-runtime.test.ts index 7d88726ee1bf..b1d7b85e51f0 100644 --- a/src/plugins/memory-runtime.test.ts +++ b/src/plugins/memory-runtime.test.ts @@ -10,6 +10,8 @@ type AuthorizeSearchHits = NonNullable ({ getMemoryRuntime: vi.fn(), loadPluginRegistryHandle: vi.fn(), + logDebug: vi.fn(), + observeMemoryAuthorizationShadowSurface: vi.fn(), resolvePluginRegistryLoadCacheKey: vi.fn((options: unknown) => JSON.stringify(options)), resolveAgentWorkspaceDir: vi.fn(), })); @@ -23,6 +25,14 @@ vi.mock("./loader.js", () => ({ resolvePluginRegistryLoadCacheKey: mocks.resolvePluginRegistryLoadCacheKey, })); +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: vi.fn(() => ({ debug: mocks.logDebug })), +})); + +vi.mock("./memory-authorization-shadow.js", () => ({ + observeMemoryAuthorizationShadowSurface: mocks.observeMemoryAuthorizationShadowSurface, +})); + vi.mock("./memory-state.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getMemoryRuntime: mocks.getMemoryRuntime }; @@ -33,6 +43,7 @@ import { closeActiveMemorySearchManagerCore, closeActiveMemorySearchManagersCore, getActiveMemorySearchManagerCore, + getSelectedMemoryRuntime, resolveActiveMemoryBackendConfig, } from "./memory-runtime.js"; import { resetStandaloneMemoryRegistrySlot } from "./memory-runtime.test-support.js"; @@ -72,6 +83,8 @@ describe("memory runtime handles", () => { resetStandaloneMemoryRegistrySlot(); mocks.getMemoryRuntime.mockReset().mockReturnValue(undefined); mocks.loadPluginRegistryHandle.mockReset(); + mocks.logDebug.mockReset(); + mocks.observeMemoryAuthorizationShadowSurface.mockReset(); mocks.resolvePluginRegistryLoadCacheKey.mockClear(); mocks.resolveAgentWorkspaceDir .mockReset() @@ -227,6 +240,45 @@ describe("memory runtime handles", () => { expect(mocks.loadPluginRegistryHandle).not.toHaveBeenCalled(); }); + it("inspects direct selected-runtime acquisition through the canonical seam", () => { + const runtime = createRuntime(); + mocks.getMemoryRuntime.mockReturnValue(runtime); + + expect(getSelectedMemoryRuntime()).toBe(runtime); + expect(mocks.observeMemoryAuthorizationShadowSurface).toHaveBeenCalledOnce(); + expect(mocks.observeMemoryAuthorizationShadowSurface).toHaveBeenCalledWith(runtime); + }); + + it("wires each legacy resolution through shadow inspection without changing legacy resolution", () => { + const { registry, runtime } = createRegistry(); + mocks.loadPluginRegistryHandle.mockReturnValue(registry); + + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ + backend: "builtin", + }); + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ + backend: "builtin", + }); + + expect(mocks.observeMemoryAuthorizationShadowSurface).toHaveBeenCalledTimes(2); + expect(mocks.observeMemoryAuthorizationShadowSurface).toHaveBeenCalledWith(runtime); + expect(runtime.resolveMemoryBackendConfig).toHaveBeenCalledTimes(2); + }); + + it("keeps legacy resolution when shadow logging fails", () => { + const { registry, runtime } = createRegistry(); + mocks.loadPluginRegistryHandle.mockReturnValue(registry); + mocks.observeMemoryAuthorizationShadowSurface.mockReturnValue({ mode: "shadow" }); + mocks.logDebug.mockImplementation(() => { + throw new Error("logger unavailable"); + }); + + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ + backend: "builtin", + }); + expect(runtime.resolveMemoryBackendConfig).toHaveBeenCalledTimes(1); + }); + it("authorizes raw hits inside the selected plugin runtime scope", async () => { const { registry, runtime } = createRegistry(); runtime.authorizeSearchHits.mockImplementationOnce(async ({ hits }) => { diff --git a/src/plugins/memory-runtime.ts b/src/plugins/memory-runtime.ts index 905802ff44de..59e1f80e6834 100644 --- a/src/plugins/memory-runtime.ts +++ b/src/plugins/memory-runtime.ts @@ -1,9 +1,11 @@ // Runtime bridge for plugin-owned memory hooks and state. import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; import { normalizePluginsConfig } from "./config-state.js"; import { loadPluginRegistryHandle, resolvePluginRegistryLoadCacheKey } from "./loader.js"; +import { observeMemoryAuthorizationShadowSurface } from "./memory-authorization-shadow.js"; import { getMemoryRuntime, resolveSelectedMemoryCapabilityRegistration, @@ -20,6 +22,7 @@ type MemorySearchAuthorization = Parameters< NonNullable >[0]; type MemoryRuntimeOwner = { runtime: MemoryRuntime; registry?: PluginRegistry }; +const log = createSubsystemLogger("plugins/memory-authorization"); let standaloneMemoryRegistrySlot: | { key: string; registry: PluginRegistry; retiredRuntimes: Map } | undefined; @@ -54,7 +57,7 @@ function resolveMemoryRuntimeFromRegistry(registry: PluginRegistry) { } function listCurrentMemoryRuntimeOwners(): MemoryRuntimeOwner[] { - const current = getMemoryRuntime(); + const current = getSelectedMemoryRuntime(); const owners = new Map(); for (const [runtime, registry] of standaloneMemoryRegistrySlot?.retiredRuntimes ?? []) { owners.set(runtime, { runtime, registry }); @@ -78,11 +81,39 @@ function withMemoryRuntimeOwner( return withPluginRuntimeRegistryScope(owner.registry, () => run(owner.runtime)); } +function inspectSelectedMemoryRuntime(runtime: MemoryRuntime): MemoryRuntime { + // The inspection has no result-path effect: it only emits bounded shadow metadata once per + // selected runtime object and deliberately tolerates malformed/plugin-hostile surfaces. + const metadata = observeMemoryAuthorizationShadowSurface(runtime); + if (metadata) { + try { + log.debug("memory authorization backend surface evaluated", metadata); + } catch { + // Shadow logging must not change selected runtime resolution or a legacy result path. + } + } + return runtime; +} + +/** Reads the selected registered runtime through the canonical shadow-inspected seam. */ +export function getSelectedMemoryRuntime(): MemoryRuntime | undefined { + const runtime = getMemoryRuntime(); + return runtime ? inspectSelectedMemoryRuntime(runtime) : undefined; +} + +function toMemoryRuntimeOwner( + runtime: MemoryRuntime, + registry?: PluginRegistry, +): MemoryRuntimeOwner { + inspectSelectedMemoryRuntime(runtime); + return registry ? { runtime, registry } : { runtime }; +} + function ensureMemoryRuntime(params?: { cfg: OpenClawConfig; agentId: string; }): MemoryRuntimeOwner | undefined { - const current = getMemoryRuntime(); + const current = getSelectedMemoryRuntime(); if (current || !params) { return current ? { runtime: current } : undefined; } @@ -100,7 +131,9 @@ function ensureMemoryRuntime(params?: { const key = resolvePluginRegistryLoadCacheKey(loadOptions); if (standaloneMemoryRegistrySlot?.key === key) { const runtime = resolveMemoryRuntimeFromRegistry(standaloneMemoryRegistrySlot.registry); - return runtime ? { runtime, registry: standaloneMemoryRegistrySlot.registry } : undefined; + return runtime + ? toMemoryRuntimeOwner(runtime, standaloneMemoryRegistrySlot.registry) + : undefined; } const registry = loadPluginRegistryHandle(loadOptions); if (!registry) { @@ -116,7 +149,7 @@ function ensureMemoryRuntime(params?: { retiredRuntimes.set(previousRuntime, previousSlot.registry); } standaloneMemoryRegistrySlot = { key, registry, retiredRuntimes }; - return runtime ? { runtime, registry } : undefined; + return runtime ? toMemoryRuntimeOwner(runtime, registry) : undefined; } /** Returns the active plugin-backed memory search manager for an agent. */