Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/loopover-miner/lib/chat-action-dispatch.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ChatActionHandlerResult } from "./chat-action-registry.js";

export function isChatActionDispatchEnabled(
env?: Record<string, string | undefined>,
): boolean;

export const CHAT_ACTION_DISPATCH_STATUS: {
readonly DISABLED: "chat_action_dispatch_disabled";
readonly UNKNOWN_ACTION: "chat_action_unknown";
readonly INVALID_PARAMS: "chat_action_invalid_params";
readonly DISPATCHED: "chat_action_dispatched";
};

export type ChatActionRequest = { action?: unknown; params?: unknown };

export type ChatActionDispatchResult =
| { ok: false; status: "chat_action_dispatch_disabled" }
| { ok: false; status: "chat_action_unknown"; action: string }
| {
ok: false;
status: "chat_action_invalid_params";
action: string;
errors: string[];
}
| {
ok: true;
status: "chat_action_dispatched";
action: string;
result: ChatActionHandlerResult;
};

export function dispatchChatAction(
request: ChatActionRequest,
options?: { env?: Record<string, string | undefined> },
): Promise<ChatActionDispatchResult>;
64 changes: 64 additions & 0 deletions packages/loopover-miner/lib/chat-action-dispatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Chat action-dispatch single entry point (#6519). Every action a chat input issues MUST go through this
// function — it is never a parallel or bypass route around the miner's existing safety path. The actual write
// runs inside a handler that was registered via `chat-action-registry.js`'s `createChokepointRoutedHandler`,
// which routes through `packages/loopover-miner/lib/governor-chokepoint.js`'s `evaluateGovernorChokepointGate`
// (the stateful wrapper around `packages/loopover-engine/src/governor/chokepoint.ts`'s fail-closed precedence
// ladder). This module adds no second, competing safety check; it only gates, looks up, validates, and invokes.
//
// This issue ships the scaffolding only: the flag defaults OFF and the registry is empty, so no action can run
// yet. The discover/attempt, portfolio release/requeue, and governor pause/resume child issues register handlers.

import { getChatAction } from "./chat-action-registry.js";

// Explicit enable values only — anything else (unset, empty, "off", "0", a typo) is disabled. Fail closed.
const ENABLE_VALUE = /^(1|true|yes|on|enabled)$/i;

/** True only when the per-install flag is explicitly enabled; every other value (incl. unset/empty) is OFF. */
export function isChatActionDispatchEnabled(env = process.env) {
return ENABLE_VALUE.test((env.MINER_CHAT_ACTIONS ?? "").trim());
}

export const CHAT_ACTION_DISPATCH_STATUS = Object.freeze({
DISABLED: "chat_action_dispatch_disabled",
UNKNOWN_ACTION: "chat_action_unknown",
INVALID_PARAMS: "chat_action_invalid_params",
DISPATCHED: "chat_action_dispatched",
});

/**
* Dispatch a chat-issued action request `{ action, params }`. Fail-closed order: the flag is checked FIRST —
* when disabled it short-circuits before the registry is touched or any params are validated. Otherwise it
* rejects an unknown action, then runs the action's own params-validator (rejecting, never coercing, on
* failure), and only then invokes the registered (chokepoint-routed) handler.
*/
export async function dispatchChatAction(request, options = {}) {
const env = options.env ?? process.env;
if (!isChatActionDispatchEnabled(env)) {
return { ok: false, status: CHAT_ACTION_DISPATCH_STATUS.DISABLED };
}
const action = typeof request?.action === "string" ? request.action : "";
const registered = getChatAction(action);
if (!registered) {
return {
ok: false,
status: CHAT_ACTION_DISPATCH_STATUS.UNKNOWN_ACTION,
action,
};
}
const validation = registered.paramsValidator(request?.params);
if (!validation || validation.ok !== true) {
return {
ok: false,
status: CHAT_ACTION_DISPATCH_STATUS.INVALID_PARAMS,
action,
errors: Array.isArray(validation?.errors) ? validation.errors : [],
};
}
const result = await registered.handler({ action, params: request?.params });
return {
ok: true,
status: CHAT_ACTION_DISPATCH_STATUS.DISPATCHED,
action,
result,
};
}
56 changes: 56 additions & 0 deletions packages/loopover-miner/lib/chat-action-registry.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { GovernorChokepointInput } from "@loopover/engine";

export type ChatActionParamsValidation = { ok: boolean; errors?: string[] };
export type ChatActionParamsValidator = (
params: unknown,
) => ChatActionParamsValidation;

// The dispatch layer only reads the gate verdict's `stage`; the full GovernorDecision it echoes back is opaque
// here (`decision: unknown`) so a child issue's handler and a test fake can both satisfy the contract.
export type ChatActionGateResult = { decision: { stage: string } };
export type ChatActionGate = (
input: GovernorChokepointInput,
) => ChatActionGateResult;

export type ChatActionHandlerResult =
| { ok: false; denied: true; stage: string; decision: unknown }
| { ok: true; stage: string; decision: unknown; result: unknown };

export type ChatActionHandler = (request: {
action: string;
params: unknown;
}) => Promise<ChatActionHandlerResult>;

export type ChatActionEntry = {
paramsValidator: ChatActionParamsValidator;
handler: ChatActionHandler;
};

export type ChatActionBuild = (request: {
action: string;
params: unknown;
}) => {
chokepointInput: GovernorChokepointInput;
perform: () => unknown | Promise<unknown>;
};

export function createChokepointRoutedHandler(
build: ChatActionBuild,
options?: { evaluateGate?: ChatActionGate },
): ChatActionHandler;

export function isChokepointRoutedHandler(handler: unknown): boolean;

export function registerChatAction(
name: string,
definition: {
paramsValidator: ChatActionParamsValidator;
handler: ChatActionHandler;
},
): ChatActionEntry;

export function getChatAction(name: string): ChatActionEntry | null;

export function listChatActionNames(): string[];

export function clearChatActionRegistry(): void;
107 changes: 107 additions & 0 deletions packages/loopover-miner/lib/chat-action-registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Chat action-dispatch allowlist registry (#6519). The shared scaffolding the three chat action-family child
// issues (discover/attempt, portfolio release/requeue, governor pause/resume) register their handlers into.
//
// SAFETY CONTRACT — this registry makes it structurally impossible to register a write that bypasses the
// Governor chokepoint. A handler is accepted ONLY if it was produced by `createChokepointRoutedHandler`, which
// routes the effect through `packages/loopover-miner/lib/governor-chokepoint.js`'s `evaluateGovernorChokepointGate`
// (the stateful wrapper around `packages/loopover-engine/src/governor/chokepoint.ts`'s fail-closed precedence
// ladder). A raw, unwrapped function is rejected at REGISTRATION time — not caught later by review discipline.
//
// This module ships with ZERO actions registered; the child issues add them.

import { evaluateGovernorChokepointGate } from "./governor-chokepoint.js";

// Non-enumerable brand marking a handler as chokepoint-routed. Only createChokepointRoutedHandler sets it, so a
// plain function can never satisfy the registration contract.
const CHOKEPOINT_ROUTED = Symbol("loopover.chatAction.chokepointRouted");

/**
* Wrap a chat action's effect so it is evaluated through the Governor chokepoint gate before it runs, and brand
* the result so `registerChatAction` will accept it. `build(request)` returns `{ chokepointInput, perform }`:
* `chokepointInput` is fed to the gate, and `perform` (the actual local write) runs ONLY on a final `"allow"`
* verdict. Any non-allow stage denies without performing. The gate is injectable for tests; it defaults to the
* real `evaluateGovernorChokepointGate` so production always routes through it.
*/
export function createChokepointRoutedHandler(build, options = {}) {
if (typeof build !== "function") {
throw new Error(
"createChokepointRoutedHandler requires a build(request) => { chokepointInput, perform } function",
);
}
/* v8 ignore next -- default routes through the real governor-chokepoint wrapper; every test injects a fake evaluateGate to avoid constructing a full GovernorChokepointInput (that wiring is covered by governor-chokepoint's own tests). */
const evaluateGate = options.evaluateGate ?? evaluateGovernorChokepointGate;
const handler = async (request) => {
const { chokepointInput, perform } = build(request);
const gate = evaluateGate(chokepointInput);
if (gate.decision.stage !== "allow") {
return {
ok: false,
denied: true,
stage: gate.decision.stage,
decision: gate.decision,
};
}
const result = await perform();
return {
ok: true,
stage: gate.decision.stage,
decision: gate.decision,
result,
};
};
Object.defineProperty(handler, CHOKEPOINT_ROUTED, {
value: true,
enumerable: false,
});
return handler;
}

/** True only for a handler produced by `createChokepointRoutedHandler`. */
export function isChokepointRoutedHandler(handler) {
return typeof handler === "function" && handler[CHOKEPOINT_ROUTED] === true;
}

const registry = new Map();

/**
* Register a chat action. Rejects (throws) — never silently succeeds — when the name is empty, the
* paramsValidator is missing, the handler was not produced by `createChokepointRoutedHandler`, or the name is
* already registered.
*/
export function registerChatAction(name, definition = {}) {
if (typeof name !== "string" || name.trim() === "") {
throw new Error("registerChatAction requires a non-empty action name");
}
const { paramsValidator, handler } = definition;
if (typeof paramsValidator !== "function") {
throw new Error(
`chat action "${name}" must supply a paramsValidator function`,
);
}
if (!isChokepointRoutedHandler(handler)) {
throw new Error(
`chat action "${name}" handler must be produced by createChokepointRoutedHandler so every write routes through the Governor chokepoint`,
);
}
if (registry.has(name)) {
throw new Error(`chat action "${name}" is already registered`);
}
const entry = Object.freeze({ paramsValidator, handler });
registry.set(name, entry);
return entry;
}

/** The registered action entry, or null when the name is not registered. */
export function getChatAction(name) {
return registry.get(name) ?? null;
}

/** Registered action names, sorted, for a stable allowlist view. */
export function listChatActionNames() {
return [...registry.keys()].sort();
}

/* v8 ignore next 3 -- test support: reset the module-level registry between tests so state never leaks. */
export function clearChatActionRegistry() {
registry.clear();
}
Loading
Loading