Repository navigation
feat(agent): consume MCP tool registry (Operations profile), RBAC-filtered (#626) #1545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,16 +8,22 @@ | |
| * is realized lazily on first use, and the loop runs in-process. | ||
| * | ||
| * The component intentionally avoids `handleApplication`: it has nothing | ||
| * worker-thread-shaped to do. Operator-only tools (FS, schedule, fetch) are | ||
| * inline; registry-backed tools (#615/#617/#618) will fold in via toolset.ts | ||
| * once those land. | ||
| * worker-thread-shaped to do. Two tool sources compose: operator-only tools | ||
| * (FS, schedule, fetch, and the V8 inspector) that are inline, and RBAC-filtered | ||
| * registry tools (#615/#617) drained for the agent's configured user via | ||
| * `registryTools.ts`. | ||
| */ | ||
|
|
||
| import { dirname, isAbsolute, resolve as resolvePath } from 'node:path'; | ||
| import { CONFIG_PARAMS } from '../utility/hdbTerms.ts'; | ||
| import * as env from '../utility/environment/environmentManager.ts'; | ||
| import harperLogger from '../utility/logging/harper_logger.ts'; | ||
| import { Models } from '../resources/models/Models.ts'; | ||
| import type { AuthedUser } from '../components/mcp/toolRegistry.ts'; | ||
| import { workers } from '../server/threads/manageThreads.js'; | ||
| import { composeToolset } from './toolset.ts'; | ||
| import { buildInspectorTools } from './tools/inspectorTool.ts'; | ||
| import { composeRegistryTools, ensureOperationsToolsRegistered } from './registryTools.ts'; | ||
| import { buildOperations } from './operations.ts'; | ||
| import { runAgent, _resetInFlightForTests } from './loop.ts'; | ||
| import { appendMessage, getSession } from './session.ts'; | ||
|
|
@@ -37,6 +43,8 @@ const DEFAULT_CONFIG: AgentConfig = { | |
| interface StartOpts { | ||
| server: { | ||
| registerOperation: (def: { name: string; execute: (op: any) => any | Promise<any> }) => void; | ||
| /** Resolve a Harper user (with role/permissions) by username. Password/request unused here. */ | ||
| getUser?: (username: string, password: string | null, request: unknown) => Promise<AuthedUser> | AuthedUser; | ||
| }; | ||
| // Component-level config plumbed by componentLoader (`...componentConfig`). | ||
| enabled?: boolean; | ||
|
|
@@ -64,11 +72,46 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> { | |
| const models = new Models(); | ||
| const abortControllers = new Map<string, AbortController>(); | ||
| let liveConfig: AgentConfig = config; | ||
|
|
||
| // Populate the Operations MCP profile on the main thread. `agentIdentity` resolves the enforcement | ||
| // identity fresh on each call (so a live role change is honored, and an unresolvable non-default | ||
| // user fails closed); the startup snapshot below is only for `visibleTo` listing. | ||
| ensureOperationsToolsRegistered(); | ||
| const agentIdentity = () => resolveAgentIdentity(opts.server, liveConfig.user); | ||
| let registryTools = await composeRegistryToolsForListing(agentIdentity); | ||
|
|
||
| // Operator-only V8 inspector tools. Debug config is read once here — enabling threads_debug opens the | ||
| // worker inspector ports at thread boot, so it can't be toggled without a restart anyway. Worker | ||
| // count is a live closure over the pool, so attaches range-check against the current worker set. | ||
| const inspectorTools = buildInspectorTools({ | ||
| debugEnabled: env.get(CONFIG_PARAMS.THREADS_DEBUG) !== false, | ||
| startingPort: (env.get(CONFIG_PARAMS.THREADS_DEBUG_STARTINGPORT) as number | undefined) ?? undefined, | ||
| host: (env.get(CONFIG_PARAMS.THREADS_DEBUG_HOST) as string | undefined) ?? '127.0.0.1', | ||
| getWorkerCount: () => workers.length, | ||
| }); | ||
|
|
||
| let composed = composeToolset({ | ||
| allowDestructive: liveConfig.allowDestructive, | ||
| onFollowup: handleFollowup, | ||
| inspectorTools, | ||
| registryTools, | ||
| }); | ||
|
|
||
| // Build the listing snapshot best-effort: if the configured user can't be resolved (and isn't the | ||
| // default bootstrap user), `agentIdentity` fails closed — the agent then runs with only its | ||
| // operator-only tools until the operator fixes `agent.user`. Never falls back to an escalated list. | ||
| async function composeRegistryToolsForListing(resolveIdentity: () => Promise<AuthedUser>): Promise<AgentTool[]> { | ||
| try { | ||
| const listingUser = await resolveIdentity(); | ||
| return composeRegistryTools(listingUser, resolveIdentity); | ||
| } catch (err) { | ||
| log.error?.( | ||
| `Registry tools disabled — agent.user='${liveConfig.user}' could not be resolved: ${err instanceof Error ? err.message : String(err)}` | ||
| ); | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| // Only warn when the operator explicitly configured `maxCostUsd`. Logging on the default | ||
| // every boot would flood the log without telling anyone anything actionable. | ||
| if (opts.maxCostUsd !== undefined) { | ||
|
|
@@ -141,6 +184,8 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> { | |
| composed = composeToolset({ | ||
| allowDestructive: liveConfig.allowDestructive, | ||
| onFollowup: handleFollowup, | ||
| inspectorTools, | ||
| registryTools, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When Since function setConfig(patch: Partial<AgentConfig>): AgentConfig {
const previousAllowDestructive = liveConfig.allowDestructive;
const previousUser = liveConfig.user;
liveConfig = { ...liveConfig, ...patch };
const userChanged = liveConfig.user !== previousUser;
const destructiveChanged = liveConfig.allowDestructive !== previousAllowDestructive;
if (userChanged) {
composeRegistryToolsForListing(agentIdentity)
.then((tools) => {
registryTools = tools;
composed = composeToolset({
allowDestructive: liveConfig.allowDestructive,
onFollowup: handleFollowup,
registryTools,
});
})
.catch((err) => {
log.error?.(`Failed to update registry tools for new user: ${err instanceof Error ? err.message : String(err)}`);
});
} else if (destructiveChanged) {
composed = composeToolset({
allowDestructive: liveConfig.allowDestructive,
onFollowup: handleFollowup,
registryTools,
});
}
return liveConfig;
} |
||
| }); | ||
| } | ||
| // NOTE: an already in-flight run captured its toolset (and autoApprove) at start, so flipping | ||
|
|
@@ -183,6 +228,40 @@ function buildSystemPrompt(scopes: AgentScopes): string { | |
| ].join('\n'); | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the identity the agent's tool calls run as. Registry tools are enforced | ||
| * (`hdb_user` at call time) against this user, so an operator who sets `agent.user` | ||
| * to a restricted role gets a narrowed surface — and a later role change is honored, | ||
| * because this runs per call rather than once at startup. | ||
| * | ||
| * Failure policy — fail closed, with one bounded exception: | ||
| * - If `agent.user` resolves to a permissioned user, use it. | ||
| * - If it can't be resolved AND it's the *default* `hdb_agent` bootstrap user, | ||
| * fall back to a super_user identity (the system user isn't provisioned yet — | ||
| * #626 defers creating it). This is the documented default-agent behavior. | ||
| * - If it can't be resolved and the operator configured a *non-default* user, | ||
| * throw. Silently escalating a misconfigured/transient restricted account to | ||
| * super_user is the vulnerability we refuse to introduce. | ||
| */ | ||
| async function resolveAgentIdentity(server: StartOpts['server'], username: string): Promise<AuthedUser> { | ||
| let resolved: AuthedUser | undefined; | ||
| if (typeof server.getUser === 'function') { | ||
| try { | ||
| const user = await server.getUser(username, null, null); | ||
| if (user?.role?.permission) resolved = user; | ||
| } catch (err) { | ||
| log.warn?.(`Failed to resolve agent.user='${username}': ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| } | ||
| if (resolved) return resolved; | ||
|
|
||
| if (username === DEFAULT_CONFIG.user) { | ||
| log.warn?.(`Default agent user '${username}' is not provisioned yet; using super_user bootstrap identity`); | ||
| return { username, role: { permission: { super_user: true } } }; | ||
| } | ||
| throw new Error(`agent.user '${username}' could not be resolved to a permissioned user; failing closed`); | ||
| } | ||
|
|
||
| function resolveScopes( | ||
| config: AgentConfig, | ||
| getConfigPath: (param: string) => string | undefined, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,117 @@ | ||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Registry tool composition for the built-in agent (#626). | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Folds the unified MCP tool registry (#615) — specifically the Operations | ||||||||||||||||||||||||||
| * profile (#617) — into the agent's toolset. The agent consumes the *same* | ||||||||||||||||||||||||||
| * registry the MCP HTTP server exposes, RBAC-filtered for its configured user, | ||||||||||||||||||||||||||
| * so operators get one tool surface with one permission model. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Two-step, mirroring the registry's own security model: | ||||||||||||||||||||||||||
| * 1. `visibleTo(agentUser)` narrows *listing* — the LLM only sees tools it is | ||||||||||||||||||||||||||
| * likely allowed to call. This is NOT a security boundary. | ||||||||||||||||||||||||||
| * 2. Real enforcement runs in the operation handler at call time: each call | ||||||||||||||||||||||||||
| * dispatches through Harper's operations runtime with `hdb_user` set to the | ||||||||||||||||||||||||||
| * agent's configured identity, so `verifyPerms` gates it regardless of what | ||||||||||||||||||||||||||
| * the LLM was shown. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * The Application profile (#618) is intentionally out of scope here; it can fold | ||||||||||||||||||||||||||
| * in the same way once per-Resource tool generation is wired for the agent. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import { registerOperationsTools } from '../components/mcp/tools/operations.ts'; | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| snapshotProfileTools, | ||||||||||||||||||||||||||
| type AuthedUser, | ||||||||||||||||||||||||||
| type ToolCallContext, | ||||||||||||||||||||||||||
| type ToolDef as RegistryToolDef, | ||||||||||||||||||||||||||
| type ToolResult, | ||||||||||||||||||||||||||
| } from '../components/mcp/toolRegistry.ts'; | ||||||||||||||||||||||||||
| import harperLogger from '../utility/logging/harper_logger.ts'; | ||||||||||||||||||||||||||
| import type { AgentTool, AgentToolContext } from './types.ts'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const log = harperLogger.loggerWithTag('agent'); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let operationsRegistered = false; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Populate the Operations profile on the main thread. Idempotent: guarded so a | ||||||||||||||||||||||||||
| * second call (e.g. an `agent.enabled` flip via `set_agent_config`) is a no-op | ||||||||||||||||||||||||||
| * rather than re-walking `OPERATION_FUNCTION_MAP` and re-logging. The registry's | ||||||||||||||||||||||||||
| * `addTool` is `Map.set`-backed, so this stays correct whether or not the | ||||||||||||||||||||||||||
| * operator also enabled the `mcp:` HTTP surface. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export function ensureOperationsToolsRegistered(): void { | ||||||||||||||||||||||||||
| if (operationsRegistered) return; | ||||||||||||||||||||||||||
| registerOperationsTools(); | ||||||||||||||||||||||||||
| operationsRegistered = true; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Adapt every Operations-profile registry tool into the agent's {@link AgentTool} | ||||||||||||||||||||||||||
| * shape. Call {@link ensureOperationsToolsRegistered} first so the profile is | ||||||||||||||||||||||||||
| * populated. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Two identities, deliberately split: | ||||||||||||||||||||||||||
| * - `listingUser` filters `visibleTo` at compose time — which tools the LLM is | ||||||||||||||||||||||||||
| * *shown*. Listing is not a security boundary, so a startup snapshot is fine. | ||||||||||||||||||||||||||
| * - `resolveIdentity()` is called *per tool call* to set `hdb_user`, so the | ||||||||||||||||||||||||||
| * operation is enforced against the agent user's *current* role. This is what | ||||||||||||||||||||||||||
| * honors a role revocation/change without a restart — the enforcement identity | ||||||||||||||||||||||||||
| * is never cached in the handler closure. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export function composeRegistryTools(listingUser: AuthedUser, resolveIdentity: () => Promise<AuthedUser>): AgentTool[] { | ||||||||||||||||||||||||||
| const defs = snapshotProfileTools('operations'); | ||||||||||||||||||||||||||
| const out: AgentTool[] = []; | ||||||||||||||||||||||||||
| for (const def of defs) { | ||||||||||||||||||||||||||
| if (!def.visibleTo(listingUser)) continue; | ||||||||||||||||||||||||||
| out.push(adaptRegistryTool(def, resolveIdentity)); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| log.info?.(`Agent composed ${out.length} operations tool(s) for user '${listingUser?.username ?? 'unknown'}'`); | ||||||||||||||||||||||||||
| return out; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function adaptRegistryTool(def: RegistryToolDef, resolveIdentity: () => Promise<AuthedUser>): AgentTool { | ||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| def: { name: def.name, description: def.description, parameters: def.inputSchema }, | ||||||||||||||||||||||||||
| // The loop's approval gate keys off `destructive`; the registry expresses the same | ||||||||||||||||||||||||||
| // intent as the MCP `destructiveHint` annotation. | ||||||||||||||||||||||||||
| destructive: def.annotations?.destructiveHint === true, | ||||||||||||||||||||||||||
| handler: async (args: object, ctx: AgentToolContext) => { | ||||||||||||||||||||||||||
| // Resolve the enforcement identity fresh so a live role change is honored. If the | ||||||||||||||||||||||||||
| // configured user can no longer be resolved, `resolveIdentity` throws (fail closed) — | ||||||||||||||||||||||||||
| // the loop turns that into a recoverable tool error rather than running with stale or | ||||||||||||||||||||||||||
| // escalated privileges. | ||||||||||||||||||||||||||
| const user = await resolveIdentity(); | ||||||||||||||||||||||||||
| const context: ToolCallContext = { | ||||||||||||||||||||||||||
| user, | ||||||||||||||||||||||||||
| profile: 'operations', | ||||||||||||||||||||||||||
| sessionId: ctx.sessionId, | ||||||||||||||||||||||||||
| ...(ctx.signal ? { signal: ctx.signal } : {}), | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| const result = await def.handler(args, context); | ||||||||||||||||||||||||||
| // The loop records a tool observation from the return value and turns a thrown error | ||||||||||||||||||||||||||
| // into a structured `{ ok: false, error }` note for the next model turn. Mapping | ||||||||||||||||||||||||||
| // `isError` → throw keeps a Harper operation failure a recoverable observation rather | ||||||||||||||||||||||||||
| // than aborting the whole run. | ||||||||||||||||||||||||||
| if (result.isError) throw new Error(errorText(result)); | ||||||||||||||||||||||||||
| return result.structuredContent ?? textOf(result); | ||||||||||||||||||||||||||
|
Comment on lines
+91
to
+97
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To prevent potential const result = await def.handler(args, context);
if (!result) {
throw new Error('Operation returned an empty or invalid result');
}
// The loop records a tool observation from the return value and turns a thrown error
// into a structured '{ ok: false, error }' note for the next model turn. Mapping
// 'isError' -> throw keeps a Harper operation failure a recoverable observation rather
// than aborting the whole run.
if (result.isError) throw new Error(errorText(result));
return result.structuredContent ?? textOf(result);References
|
||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function textOf(result: ToolResult): string { | ||||||||||||||||||||||||||
| return (result.content ?? []) | ||||||||||||||||||||||||||
| .filter((c) => c.type === 'text' && typeof c.text === 'string') | ||||||||||||||||||||||||||
| .map((c) => c.text) | ||||||||||||||||||||||||||
| .join('\n'); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+102
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function errorText(result: ToolResult): string { | ||||||||||||||||||||||||||
| const text = textOf(result); | ||||||||||||||||||||||||||
| return text || 'operation failed'; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** Test-only: reset the one-time registration guard between specs. */ | ||||||||||||||||||||||||||
| export function _resetRegistryToolsForTests(): void { | ||||||||||||||||||||||||||
| operationsRegistered = false; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be token aware too? And if so, having a third parameter with an unknown type seems rather dangerous.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From the other PR #1546