mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
fa03d9b913
* refactor: consolidate coercion helpers * fix: remove duplicate coercion imports * fix: preserve serialized coercion guard * chore: ratchet coercion helper carve-outs * fix(test): keep gauntlet subprocess startup lean * fix: preserve imported session timestamp semantics * fix: preserve catalog timestamp string semantics * chore: align plugin SDK surface ratchet * fix: preserve trajectory and SDK string contracts * fix(test): preserve QA record assertion semantics * fix: complete standalone record guard rename * refactor(cron): use canonical string coercion * fix(acpx): preserve Pi timestamp parsing * test(channels): adapt custody test harnesses * test(telegram): classify media harness as test support * test(acpx): split timestamp contract coverage * test(channels): support generated custody contracts * chore: ban the full coercion helper name set Extends the declaration guard to all eleven consolidated helper names and renames the cron schedule-identity readNumber wrapper to readScheduleInteger so the banned generic name cannot regrow. * fix(scripts): repair release-validation guard drift and lint cause Restores the renamed isJsonRecord guard in assertTrustedWorkflowHarness after main added isRecord call sites in parallel, and attaches the caught YAML error as the thrown error cause (preserve-caught-error was red on main). * fix: preserve Claude timestamp string semantics * fix: preserve persisted timestamp string semantics * fix: preserve date-first timestamp contracts * fix(openai): harden delegation failure formatting * chore: close coercion helper guard gaps * test(openai): model non-error delegation rejection * chore: refresh plugin SDK API contract * fix(tasks): use canonical string field reader * fix(ai): use canonical provider error field coercion * fix(browser): migrate native bootstrap coercion * docs(plugin-sdk): clarify text record export compatibility * fix(gateway): normalize approval execution identity * test(outbound): isolate message action poll harness
417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
import type { WorkboardWorkspace, WorkboardWorkspaceAccess } from "@openclaw/workboard-contract";
|
|
// Workboard workspace access follows the caller's canonical filesystem boundary.
|
|
import {
|
|
listAgentIds,
|
|
resolveAgentConfig,
|
|
resolveAgentWorkspaceDir,
|
|
resolveDefaultAgentId,
|
|
} from "openclaw/plugin-sdk/agent-runtime";
|
|
import type {
|
|
AnyAgentTool,
|
|
OpenClawPluginApi,
|
|
OpenClawPluginToolContext,
|
|
} from "openclaw/plugin-sdk/plugin-entry";
|
|
import {
|
|
canonicalPathFromExistingAncestor,
|
|
isPathInside,
|
|
} from "openclaw/plugin-sdk/security-runtime";
|
|
import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
|
|
export type { WorkboardWorkspaceAccess } from "@openclaw/workboard-contract";
|
|
|
|
type WorkboardConfig = NonNullable<OpenClawPluginToolContext["config"]>;
|
|
type ResolveSandboxWorkspaceAuthority =
|
|
OpenClawPluginApi["runtime"]["sandbox"]["resolveWorkspaceAuthority"];
|
|
type PrepareSandboxWorkspaceAuthority =
|
|
OpenClawPluginApi["runtime"]["sandbox"]["prepareWorkspaceAuthority"];
|
|
|
|
export const WORKBOARD_TOOL_NAMES = [
|
|
"workboard_list",
|
|
"workboard_create",
|
|
"workboard_link",
|
|
"workboard_read",
|
|
"workboard_claim",
|
|
"workboard_heartbeat",
|
|
"workboard_complete",
|
|
"workboard_attachment_add",
|
|
"workboard_attachment_read",
|
|
"workboard_attachment_delete",
|
|
"workboard_block",
|
|
"workboard_boards",
|
|
"workboard_board_create",
|
|
"workboard_board_archive",
|
|
"workboard_board_delete",
|
|
"workboard_stats",
|
|
"workboard_runs",
|
|
"workboard_specify",
|
|
"workboard_decompose",
|
|
"workboard_notify_subscribe",
|
|
"workboard_notify_list",
|
|
"workboard_notify_events",
|
|
"workboard_notify_advance",
|
|
"workboard_notify_unsubscribe",
|
|
"workboard_promote",
|
|
"workboard_reassign",
|
|
"workboard_reclaim",
|
|
"workboard_dispatch",
|
|
"workboard_release",
|
|
"workboard_comment",
|
|
"workboard_proof",
|
|
"workboard_worker_log",
|
|
"workboard_protocol_violation",
|
|
"workboard_unblock",
|
|
"workboard_move",
|
|
] as const;
|
|
|
|
export const WORKBOARD_REQUIRED_WORKER_TOOLS = [
|
|
"workboard_heartbeat",
|
|
"workboard_complete",
|
|
"workboard_block",
|
|
] as const;
|
|
|
|
export function resolveWorkboardAgentWorkspace(config: WorkboardConfig, agentId?: string): string {
|
|
return resolveAgentWorkspaceDir(config, agentId ?? resolveDefaultAgentId(config));
|
|
}
|
|
|
|
export function resolveConfiguredWorkboardWorkspaceAccess(params: {
|
|
config: WorkboardConfig;
|
|
unrestricted: boolean;
|
|
}): WorkboardWorkspaceAccess {
|
|
if (params.unrestricted) {
|
|
return { unrestricted: true };
|
|
}
|
|
return {
|
|
unrestricted: false,
|
|
writable: true,
|
|
roots: listAgentIds(params.config).map((agentId) =>
|
|
resolveAgentWorkspaceDir(params.config, agentId),
|
|
),
|
|
};
|
|
}
|
|
|
|
export type WorkboardTargetWorkspaceRuntime = {
|
|
sandboxed: boolean;
|
|
workspaceAccess: WorkboardWorkspaceAccess;
|
|
confinementError?: string;
|
|
};
|
|
|
|
export async function resolveAgentWorkboardWorkspaceRuntime(params: {
|
|
config: WorkboardConfig;
|
|
agentId?: string;
|
|
sessionKey: string;
|
|
workspaceDir: string;
|
|
modelProvider?: string;
|
|
modelId?: string;
|
|
prepareSandboxWorkspaceAuthority: PrepareSandboxWorkspaceAuthority;
|
|
}): Promise<WorkboardTargetWorkspaceRuntime> {
|
|
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
|
|
const sandboxRuntime = await params.prepareSandboxWorkspaceAuthority({
|
|
config: params.config,
|
|
agentId,
|
|
confinedToolNames: WORKBOARD_TOOL_NAMES,
|
|
requiredToolNames: WORKBOARD_REQUIRED_WORKER_TOOLS,
|
|
modelProvider: params.modelProvider,
|
|
modelId: params.modelId,
|
|
sessionKey: params.sessionKey,
|
|
workspaceDir: params.workspaceDir,
|
|
});
|
|
return {
|
|
sandboxed: sandboxRuntime.sandboxed,
|
|
workspaceAccess: sandboxRuntime.sandboxed
|
|
? {
|
|
unrestricted: false,
|
|
roots: [resolveAgentWorkspaceDir(params.config, agentId)],
|
|
writable: sandboxRuntime.workspaceAccess === "rw",
|
|
}
|
|
: { unrestricted: true },
|
|
...(sandboxRuntime.confinementError
|
|
? { confinementError: sandboxRuntime.confinementError }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function resolveCommandWorkboardWorkspaceAccess(params: {
|
|
config: WorkboardConfig;
|
|
agentId?: string;
|
|
sessionKey?: string;
|
|
gatewayClientScopes?: readonly string[];
|
|
resolveSandboxWorkspaceAuthority?: ResolveSandboxWorkspaceAuthority;
|
|
}): WorkboardWorkspaceAccess {
|
|
if (params.gatewayClientScopes) {
|
|
return resolveConfiguredWorkboardWorkspaceAccess({
|
|
config: params.config,
|
|
unrestricted: params.gatewayClientScopes.includes("operator.admin"),
|
|
});
|
|
}
|
|
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
|
|
const sandboxRuntime =
|
|
params.sessionKey && params.resolveSandboxWorkspaceAuthority
|
|
? params.resolveSandboxWorkspaceAuthority({
|
|
config: params.config,
|
|
agentId,
|
|
sessionKey: params.sessionKey,
|
|
})
|
|
: undefined;
|
|
if (sandboxRuntime?.sandboxed) {
|
|
return {
|
|
unrestricted: false,
|
|
roots: [resolveAgentWorkspaceDir(params.config, agentId)],
|
|
writable: sandboxRuntime.workspaceAccess === "rw",
|
|
};
|
|
}
|
|
const workspaceOnly =
|
|
resolveAgentConfig(params.config, agentId)?.tools?.fs?.workspaceOnly ??
|
|
params.config.tools?.fs?.workspaceOnly;
|
|
return workspaceOnly === true
|
|
? {
|
|
unrestricted: false,
|
|
roots: [resolveAgentWorkspaceDir(params.config, agentId)],
|
|
writable: true,
|
|
}
|
|
: { unrestricted: true };
|
|
}
|
|
|
|
function resolveToolWorkboardWorkspaceAccess(
|
|
context: OpenClawPluginToolContext | undefined,
|
|
resolveSandboxWorkspaceAuthority?: ResolveSandboxWorkspaceAuthority,
|
|
): WorkboardWorkspaceAccess {
|
|
if (!context?.sandboxed && context?.fsPolicy?.workspaceOnly !== true) {
|
|
return { unrestricted: true };
|
|
}
|
|
const config = context.runtimeConfig ?? context.getRuntimeConfig?.() ?? context.config;
|
|
const sandboxRuntime =
|
|
context.sandboxed && config && context.sessionKey && resolveSandboxWorkspaceAuthority
|
|
? resolveSandboxWorkspaceAuthority({
|
|
config,
|
|
agentId: context.agentId,
|
|
sessionKey: context.sessionKey,
|
|
})
|
|
: undefined;
|
|
return {
|
|
unrestricted: false,
|
|
roots: context.workspaceDir ? [context.workspaceDir] : [],
|
|
writable: sandboxRuntime ? sandboxRuntime.workspaceAccess === "rw" : !context.sandboxed,
|
|
};
|
|
}
|
|
|
|
export async function canonicalizeWorkboardWorkspaceAccess(
|
|
access: WorkboardWorkspaceAccess,
|
|
): Promise<WorkboardWorkspaceAccess> {
|
|
if (access.unrestricted) {
|
|
return access;
|
|
}
|
|
const roots = Array.from(
|
|
new Set(
|
|
await Promise.all(
|
|
access.roots.map(async (root) => await canonicalPathFromExistingAncestor(root)),
|
|
),
|
|
),
|
|
);
|
|
if (roots.length === 0) {
|
|
throw new Error("restricted workspace access has no allowed roots.");
|
|
}
|
|
return { unrestricted: false, roots, writable: access.writable };
|
|
}
|
|
|
|
export function intersectWorkboardWorkspaceAccess(
|
|
left: WorkboardWorkspaceAccess,
|
|
right: WorkboardWorkspaceAccess,
|
|
): WorkboardWorkspaceAccess {
|
|
if (left.unrestricted) {
|
|
return right;
|
|
}
|
|
if (right.unrestricted) {
|
|
return left;
|
|
}
|
|
const roots = new Set<string>();
|
|
for (const leftRoot of left.roots) {
|
|
for (const rightRoot of right.roots) {
|
|
if (leftRoot === rightRoot || isPathInside(leftRoot, rightRoot)) {
|
|
roots.add(rightRoot);
|
|
} else if (isPathInside(rightRoot, leftRoot)) {
|
|
roots.add(leftRoot);
|
|
}
|
|
}
|
|
}
|
|
if (roots.size === 0) {
|
|
throw new Error("workspace access does not overlap the card's persisted authority.");
|
|
}
|
|
return {
|
|
unrestricted: false,
|
|
roots: Array.from(roots),
|
|
writable: left.writable && right.writable,
|
|
};
|
|
}
|
|
|
|
async function assertCanonicalWorkboardPathAccess(
|
|
candidate: string,
|
|
access: WorkboardWorkspaceAccess,
|
|
): Promise<string> {
|
|
if (access.unrestricted) {
|
|
return candidate;
|
|
}
|
|
for (const root of access.roots) {
|
|
const canonicalRoot = await canonicalPathFromExistingAncestor(root);
|
|
if (isPathInside(canonicalRoot, candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
throw new Error("workspace path is outside the caller's allowed workspaces.");
|
|
}
|
|
|
|
export async function assertCanonicalWorkboardRootAccess(
|
|
candidate: string,
|
|
access: WorkboardWorkspaceAccess,
|
|
): Promise<string> {
|
|
if (access.unrestricted) {
|
|
return candidate;
|
|
}
|
|
for (const root of access.roots) {
|
|
const canonicalRoot = await canonicalPathFromExistingAncestor(root);
|
|
if (canonicalRoot === candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
throw new Error("workspace path must equal one of the caller's allowed workspace roots.");
|
|
}
|
|
|
|
async function assertPathAllowed(
|
|
value: unknown,
|
|
access: WorkboardWorkspaceAccess,
|
|
): Promise<string | undefined> {
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
return undefined;
|
|
}
|
|
const candidate = await canonicalPathFromExistingAncestor(value.trim());
|
|
return await assertCanonicalWorkboardPathAccess(candidate, access);
|
|
}
|
|
|
|
async function assertWorkspaceAllowed(
|
|
value: unknown,
|
|
access: WorkboardWorkspaceAccess,
|
|
options?: { sourceOnly?: boolean },
|
|
): Promise<string | undefined> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const workspace = value as Record<string, unknown>;
|
|
if (options?.sourceOnly) {
|
|
return await assertPathAllowed(workspace.sourcePath ?? workspace.path, access);
|
|
}
|
|
await assertPathAllowed(workspace.path, access);
|
|
await assertPathAllowed(workspace.sourcePath, access);
|
|
return undefined;
|
|
}
|
|
|
|
export function containsWorkboardWorkspaceMutation(value: unknown): boolean {
|
|
const record = asOptionalRecord(value);
|
|
if (!record) {
|
|
return false;
|
|
}
|
|
if (Object.hasOwn(record, "workspace") || Object.hasOwn(record, "defaultWorkspace")) {
|
|
return true;
|
|
}
|
|
return (
|
|
containsWorkboardWorkspaceMutation(record.patch) ||
|
|
containsWorkboardWorkspaceMutation(asOptionalRecord(record.metadata)?.automation) ||
|
|
(Array.isArray(record.children) &&
|
|
record.children.some((child) => containsWorkboardWorkspaceMutation(child)))
|
|
);
|
|
}
|
|
|
|
export function withWorkboardWorkspaceAccess(
|
|
value: unknown,
|
|
access: WorkboardWorkspaceAccess,
|
|
): Record<string, unknown> {
|
|
return { ...withoutWorkboardWorkspaceAccess(value), workspaceAccess: access };
|
|
}
|
|
|
|
export function withoutWorkboardWorkspaceAccess(value: unknown): Record<string, unknown> {
|
|
const record = asOptionalRecord(value) ?? {};
|
|
const { workspaceAccess: _untrustedWorkspaceAccess, ...rest } = record;
|
|
return rest;
|
|
}
|
|
|
|
export function withWorkboardDecomposeWorkspaceAccess(
|
|
value: unknown,
|
|
access: WorkboardWorkspaceAccess,
|
|
): Record<string, unknown> {
|
|
const record = withoutWorkboardWorkspaceAccess(value);
|
|
return {
|
|
...record,
|
|
...(Array.isArray(record.children)
|
|
? {
|
|
children: record.children.map((child) => withWorkboardWorkspaceAccess(child, access)),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export async function assertWorkboardWorkspaceMutationAccess(
|
|
value: unknown,
|
|
access: WorkboardWorkspaceAccess,
|
|
): Promise<void> {
|
|
if (access.unrestricted) {
|
|
return;
|
|
}
|
|
const record = asOptionalRecord(value);
|
|
if (!record) {
|
|
return;
|
|
}
|
|
// Card creation and decomposition persist only explicit workspace fields;
|
|
// board defaults and parent workspaces are metadata, not inherited inputs.
|
|
await assertWorkspaceAllowed(record.workspace, access);
|
|
await assertWorkspaceAllowed(record.defaultWorkspace, access);
|
|
|
|
const patch = asOptionalRecord(record.patch);
|
|
if (patch) {
|
|
await assertWorkboardWorkspaceMutationAccess(patch, access);
|
|
}
|
|
const metadata = asOptionalRecord(record.metadata);
|
|
const automation = asOptionalRecord(metadata?.automation);
|
|
if (automation) {
|
|
await assertWorkboardWorkspaceMutationAccess(automation, access);
|
|
}
|
|
if (Array.isArray(record.children)) {
|
|
for (const child of record.children) {
|
|
await assertWorkboardWorkspaceMutationAccess(child, access);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function assertWorkboardWorkspaceSourceAccess(
|
|
workspace: WorkboardWorkspace | undefined,
|
|
access: WorkboardWorkspaceAccess,
|
|
): Promise<string | undefined> {
|
|
return await assertWorkspaceAllowed(workspace, access, { sourceOnly: true });
|
|
}
|
|
|
|
export function guardWorkboardToolsForWorkspaceAccess(
|
|
tools: AnyAgentTool[],
|
|
context: OpenClawPluginToolContext | undefined,
|
|
resolveSandboxWorkspaceAuthority?: ResolveSandboxWorkspaceAuthority,
|
|
): AnyAgentTool[] {
|
|
const workspaceAccess = resolveToolWorkboardWorkspaceAccess(
|
|
context,
|
|
resolveSandboxWorkspaceAuthority,
|
|
);
|
|
return tools.map((tool) => ({
|
|
...tool,
|
|
execute: async (toolCallId, rawParams, signal, onUpdate) => {
|
|
const canonicalAccess = await canonicalizeWorkboardWorkspaceAccess(workspaceAccess);
|
|
await assertWorkboardWorkspaceMutationAccess(rawParams, canonicalAccess);
|
|
const sanitizedParams = withoutWorkboardWorkspaceAccess(rawParams);
|
|
const constrainedParams =
|
|
tool.name === "workboard_create"
|
|
? withWorkboardWorkspaceAccess(sanitizedParams, canonicalAccess)
|
|
: tool.name === "workboard_decompose"
|
|
? withWorkboardDecomposeWorkspaceAccess(sanitizedParams, canonicalAccess)
|
|
: tool.name === "workboard_specify" &&
|
|
containsWorkboardWorkspaceMutation(sanitizedParams)
|
|
? withWorkboardWorkspaceAccess(sanitizedParams, canonicalAccess)
|
|
: sanitizedParams;
|
|
return await tool.execute(toolCallId, constrainedParams, signal, onUpdate);
|
|
},
|
|
}));
|
|
}
|