Skip to content
Merged
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
21 changes: 21 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,21 @@
import type { ChatActionRegistry, ChatActionRequest } from "./chat-action-registry.js";

export const CHAT_ACTION_DISPATCH_FLAG: string;
export const CHAT_ACTION_DISPATCH_ENABLE_VALUE: string;

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

export type ChatActionDispatchResult = {
ok: boolean;
status: string;
action: string | null;
[key: string]: unknown;
};

export function dispatchChatAction(
request: ChatActionRequest,
options?: {
env?: Record<string, string | undefined>;
registry?: ChatActionRegistry;
},
): Promise<ChatActionDispatchResult>;
81 changes: 81 additions & 0 deletions packages/loopover-miner/lib/chat-action-dispatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Chat action-dispatch chokepoint (#6519).
//
// SINGLE ENTRY POINT, NEVER BYPASS: every action a miner-chat message issues MUST go through
// `dispatchChatAction` here -- never a parallel or direct call into a registered handler, an HTTP endpoint,
// or a local-write tool. This function is the one place the config flag is checked and the one place a
// registered handler is looked up and invoked. It adds NO second safety check of its own: the real
// fail-closed enforcement lives in packages/loopover-engine/src/governor/chokepoint.ts (the precedence
// ladder) reached through the packages/loopover-miner/lib/governor-chokepoint.js stateful wrapper, which the
// registry's `governorGatedHandler` contract forces every registered handler through. Dispatch only gates on
// the flag, rejects unknown actions, and runs the registered params-validator before invoking the handler.
//
// Disabled by default: the flag fails closed (off unless explicitly enabled), and the shared registry
// (chat-action-registry.js) ships empty, so no action can execute until a child issue registers a handler
// AND an operator flips the flag on.

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

/** Env var an operator sets to turn the chat-action dispatch layer on. */
export const CHAT_ACTION_DISPATCH_FLAG = "LOOPOVER_MINER_CHAT_ACTIONS";
/** The one and only value that enables dispatch. Anything else (unset, empty, "true", "1", ...) stays off. */
export const CHAT_ACTION_DISPATCH_ENABLE_VALUE = "enabled";

/**
* Fail-closed config-flag gate: enabled only when the flag is set to exactly the enable value (trimmed).
* Unset, empty, or any other value -- including truthy-looking ones like "true"/"1" -- reads as disabled.
*
* @param {Record<string, string | undefined>} [env]
* @returns {boolean}
*/
export function isChatActionDispatchEnabled(env = process.env) {
const raw = env?.[CHAT_ACTION_DISPATCH_FLAG];
return typeof raw === "string" && raw.trim() === CHAT_ACTION_DISPATCH_ENABLE_VALUE;
}

/**
* The single entry point every chat-issued action goes through. In order:
* 1. Check the config flag FIRST -- before touching the registry or validating params. When disabled,
* return a clearly-typed `"disabled"` result and look up nothing.
* 2. Reject an unknown (unregistered) action.
* 3. Run the action's own registered params-validator; reject on failure without coercing or dropping
* fields (the caller's `params` is passed through unchanged).
* 4. Invoke the registered (governor-gated) handler and return its result.
*
* @param {{ action?: string, params?: unknown, governorInput?: unknown }} request
* @param {{ env?: Record<string, string | undefined>, registry?: typeof chatActionRegistry }} [options]
* @returns {Promise<{ ok: boolean, status: string, action: string | null, [k: string]: unknown }>}
*/
export async function dispatchChatAction(request, options = {}) {
const env = options.env ?? process.env;

// Flag first -- before touching the registry or validating params. Fail closed.
if (!isChatActionDispatchEnabled(env)) {
return { ok: false, status: "disabled", action: readAction(request) };
}

const registry = options.registry ?? chatActionRegistry;
const action = readAction(request);
if (action === null || !registry.has(action)) {
return { ok: false, status: "unknown_action", action };
}

const registered = registry.get(action);
let valid;
try {
valid = registered.paramsValidator(request?.params) === true;
} catch (error) {
// A validator that throws is treated as a rejection (fail closed), not as a dispatch error.
return { ok: false, status: "invalid_params", action, error: error instanceof Error ? error.message : String(error) };
}
if (!valid) {
return { ok: false, status: "invalid_params", action };
}

const result = await registered.handler(request);
return { ok: true, status: "dispatched", action, result };
}

/** The requested action name, or null when the request omits a string action. */
function readAction(request) {
return request && typeof request.action === "string" ? request.action : null;
}
42 changes: 42 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,42 @@
export type ChatActionRequest = {
action?: string;
params?: unknown;
governorInput?: unknown;
};

/** A handler produced by {@link governorGatedHandler}; the only shape {@link ChatActionRegistry.register} accepts. */
export type GovernorGatedHandler = (request: ChatActionRequest) => Promise<Record<string, unknown>>;

export type ChatActionDefinition = {
paramsValidator: (params: unknown) => boolean;
handler: GovernorGatedHandler;
};

export type ChatActionEntry = {
paramsValidator: (params: unknown) => boolean;
handler: GovernorGatedHandler;
};

export type ChatActionRegistry = {
register(name: string, definition: ChatActionDefinition): ChatActionEntry;
get(name: string): ChatActionEntry | undefined;
has(name: string): boolean;
names(): string[];
readonly size: number;
};

export function governorGatedHandler(
run: (request: ChatActionRequest, gate: unknown) => unknown,
options?: {
evaluateGate?: (input: unknown, gateOptions?: unknown) => unknown;
gateOptions?: unknown;
},
): GovernorGatedHandler;

export function isGovernorGatedHandler(handler: unknown): boolean;

export function createChatActionRegistry(): ChatActionRegistry;

export const chatActionRegistry: ChatActionRegistry;

export function registerChatAction(name: string, definition: ChatActionDefinition): ChatActionEntry;
103 changes: 103 additions & 0 deletions packages/loopover-miner/lib/chat-action-registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Allowlist registry + governor-gated handler contract for chat-issued miner actions (#6519).
//
// Shared scaffolding ONLY: this module ships with ZERO registered actions. The three action-family child
// issues (portfolio release/requeue, governor pause/resume, discover/attempt) register their handlers into
// this registry -- none are added here, and the default `chatActionRegistry` instance starts empty.
//
// The registration contract is the safety boundary. `register` refuses any handler that was not produced by
// `governorGatedHandler()`, and `governorGatedHandler()` routes every invocation through
// `evaluateGovernorChokepointGate` (packages/loopover-miner/lib/governor-chokepoint.js) and, through it, the
// fail-closed precedence ladder in packages/loopover-engine/src/governor/chokepoint.ts. Because a raw
// function can never be registered, a chat action can never perform a write on a path that bypasses the
// Governor chokepoint -- the contract enforces it structurally, not by review discipline. This module adds
// no second, competing safety check; it only forces every registered handler onto the existing one.

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

// Private brand. Not exported, so external code cannot forge a "gated" marker onto a raw function: the only
// way to obtain a handler that passes `isGovernorGatedHandler` is to build it through `governorGatedHandler`.
const GOVERNOR_GATED = Symbol("loopover.chat-action.governor-gated");

/**
* Wrap a local-write `run` function into a Governor-gated chat-action handler. The returned handler
* evaluates the write against the full precedence ladder (via `evaluateGovernorChokepointGate`) BEFORE
* running `run`, and only invokes `run` on a final `"allow"` verdict -- any other stage returns a gated
* result and `run` never executes. This is the ONLY factory that produces a handler `register` accepts.
*
* @param {(request: unknown, gate: object) => unknown} run the local write to perform once the gate allows
* @param {{ evaluateGate?: typeof evaluateGovernorChokepointGate, gateOptions?: object }} [options]
* @returns {((request: { governorInput?: unknown }) => Promise<object>)}
*/
export function governorGatedHandler(run, options = {}) {
if (typeof run !== "function") {
throw new TypeError("governorGatedHandler(run): run must be a function");
}
const evaluateGate = options.evaluateGate ?? evaluateGovernorChokepointGate;
if (typeof evaluateGate !== "function") {
throw new TypeError("governorGatedHandler: options.evaluateGate must be a function when supplied");
}

const handler = async (request) => {
const gate = evaluateGate(request?.governorInput, options.gateOptions);
if (gate?.decision?.stage !== "allow") {
return { ok: false, status: "gated", decision: gate?.decision ?? null };
}
const result = await run(request, gate);
return { ok: true, status: "executed", decision: gate.decision, result };
};
Object.defineProperty(handler, GOVERNOR_GATED, { value: true });
return handler;
}

/** True only for a handler produced by {@link governorGatedHandler}. */
export function isGovernorGatedHandler(handler) {
return typeof handler === "function" && handler[GOVERNOR_GATED] === true;
}

/**
* Build an isolated chat-action registry. Child issues register into the shared {@link chatActionRegistry};
* this factory exists so tests (and any future multi-registry consumer) can register without polluting it.
*/
export function createChatActionRegistry() {
const actions = new Map();

function register(name, definition = {}) {
if (typeof name !== "string" || name.trim() === "") {
throw new TypeError("registerChatAction(name): name must be a non-empty string");
}
if (actions.has(name)) {
throw new Error(`registerChatAction: action "${name}" is already registered`);
}
const { paramsValidator, handler } = definition;
if (typeof paramsValidator !== "function") {
throw new TypeError(`registerChatAction("${name}"): paramsValidator must be a function`);
}
if (!isGovernorGatedHandler(handler)) {
throw new Error(
`registerChatAction("${name}"): handler must be produced by governorGatedHandler() so every ` +
"chat-triggered write routes through governor-chokepoint.js -- a raw handler is rejected.",
);
}
const entry = { paramsValidator, handler };
actions.set(name, entry);
return entry;
}

return {
register,
get: (name) => actions.get(name),
has: (name) => actions.has(name),
names: () => [...actions.keys()],
get size() {
return actions.size;
},
};
}

/** The single shared registry the dispatch layer reads. Ships EMPTY (#6519); child issues register into it. */
export const chatActionRegistry = createChatActionRegistry();

/** Register a chat action on the shared {@link chatActionRegistry}. */
export function registerChatAction(name, definition) {
return chatActionRegistry.register(name, definition);
}
Loading