From 0c5fe5a6c4e89240e86faedde7d65a99cb873fa3 Mon Sep 17 00:00:00 2001
From: Bindy-lbb <70745012+Bindy-lbb@users.noreply.github.com>
Date: Sun, 20 Sep 2026 14:27:12 +0800
Subject: [PATCH 1/2] feat(approvals): govern once and run scopes
---
apps/desktop/renderer/src/App.tsx | 9 +--
.../src/approvals/ApprovalDetailDrawer.tsx | 51 +++++++++-----
apps/desktop/renderer/src/approvals/types.ts | 7 +-
.../renderer/src/approvals/useApprovals.ts | 6 +-
.../desktop/renderer/src/turns/TurnThread.tsx | 13 +++-
apps/desktop/renderer/src/turns/adapter.ts | 1 +
apps/desktop/renderer/src/turns/types.ts | 1 +
.../renderer/test/approval-queue.test.tsx | 10 +++
.../renderer/test/approvals-state.test.tsx | 17 +++++
apps/server/src/approvals/context.ts | 1 +
apps/server/src/approvals/service.ts | 34 ++++++---
apps/server/src/approvals/store.ts | 10 ++-
apps/server/src/engine/driver-cli.ts | 9 ++-
apps/server/src/turns/store.ts | 7 +-
apps/server/test/approval.test.ts | 8 ++-
apps/server/test/approvals-center.test.ts | 69 ++++++++++++++++++-
docs/approvals-p0.md | 12 +++-
packages/shared/src/approval-scope.ts | 30 ++++++++
packages/shared/src/approvals.ts | 4 +-
packages/shared/src/index.ts | 1 +
packages/shared/src/turns.ts | 3 +
packages/ui/src/locales/en.ts | 5 ++
packages/ui/src/locales/zh.ts | 5 ++
23 files changed, 261 insertions(+), 52 deletions(-)
create mode 100644 packages/shared/src/approval-scope.ts
diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx
index d6c6aba7..a441307f 100644
--- a/apps/desktop/renderer/src/App.tsx
+++ b/apps/desktop/renderer/src/App.tsx
@@ -353,7 +353,8 @@ function AppInner({
approvalId: a.id, positionId: a.source.positionId, positionName: positionNames[a.source.positionId],
category: a.action.kind, description: a.action.description, target: a.action.target,
requestedAt: a.requestedAt, expiresAt: a.expiresAt,
- decision: a.status === "granted" ? { kind: "granted", scope: "once", decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy, reason: a.decision?.reason } : a.status === "denied" ? { kind: "denied", reason: a.decision?.reason, decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy } : { kind: a.status },
+ decision: a.status === "granted" ? { kind: "granted", scope: a.decision?.scope ?? "once", decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy, reason: a.decision?.reason } : a.status === "denied" ? { kind: "denied", reason: a.decision?.reason, decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy } : { kind: a.status },
+ scopeAllowed: a.context?.scope.allowed ?? ["once"],
canDecide: a.canDecide, busy: approvalState.busy.has(a.id), error: approvalState.errors[a.id],
unavailableReason: a.unavailableReason, executionPhase: a.execution.phase,
requestReason: a.requestReason, context: a.context, source: a.source, executionTurnId: a.execution.turnId, executionErrorCode: a.execution.errorCode,
@@ -1284,9 +1285,9 @@ function AppInner({
/** Both approval entry points use the same durable server-owned decision. */
const verdictTurn = useCallback(
- async (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => {
+ async (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope: "once" | "run" = "once") => {
const approval = approvalState.items.find(a => a.source.turnId === turn.id && a.source.positionId === turn.positionId && a.approvalId === turn.approvalRequest?.approvalId);
- if (approval) await approvalState.decide(approval.id, decision, reason);
+ if (approval) await approvalState.decide(approval.id, decision, reason, scope);
},
[approvalState.items, approvalState.decide],
);
@@ -2051,7 +2052,7 @@ function AppInner({
loading={approvalState.loading && !approvalState.ready}
errorMessage={approvalState.error}
onNavigateToOrg={() => setActiveModule("org")}
- onApprove={(id, reason) => { void approvalState.decide(id, "granted", reason); }}
+ onApprove={(id, reason, scope) => { void approvalState.decide(id, "granted", reason, scope); }}
onDeny={(id, reason) => { void approvalState.decide(id, "denied", reason); }}
onOpenSource={openApprovalSource}
onOpenEvidence={openApprovalEvidence}
diff --git a/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx b/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx
index 97c27062..f7ad715e 100644
--- a/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx
+++ b/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx
@@ -14,7 +14,7 @@
* the operator's verdict; audit details remain in the run history.
*/
import { useEffect, useState } from "react";
-import { Alert, Button, Drawer, Input, Space, Tag } from "antd";
+import { Alert, Button, Drawer, Input, Radio, Space, Tag } from "antd";
import { useT } from "@roleweave/ui";
import {
approvalExpiryState,
@@ -50,11 +50,13 @@ export function ApprovalDetailDrawer({
}: ApprovalDetailDrawerProps) {
const t = useT();
const [reason, setReason] = useState("");
+ const [scope, setScope] = useState<"once" | "run">("once");
useEffect(() => {
// Reset the reason field whenever the drawer switches to a different
// approval or closes; do not leak reasons across items.
setReason("");
+ setScope("once");
}, [item?.approvalId, open]);
if (!item) {
@@ -88,7 +90,8 @@ export function ApprovalDetailDrawer({
const handleApprove = () => {
if (disabled) return;
- onApprove(item.approvalId, reasonForCallback);
+ if (scope === "run") onApprove(item.approvalId, reasonForCallback, scope);
+ else onApprove(item.approvalId, reasonForCallback);
};
const handleDeny = () => {
if (disabled) return;
@@ -234,29 +237,39 @@ export function ApprovalDetailDrawer({
? t("apr.alertDenied")
: t(`apr.status.${item.decision.kind}`)
}
- description={
- item.decision.kind === "denied" && item.decision.reason
+ description={item.decision.kind === "granted"
+ ? t("apr.effectiveScope", { scope: t(`apr.scope.${item.decision.scope}`) })
+ : item.decision.kind === "denied" && item.decision.reason
? t("apr.reasonPrefix", { reason: safeApprovalText(item.decision.reason) })
- : undefined
- }
+ : undefined}
showIcon
/>
) : expired ? (
) : (
-
- {t("apr.reasonOptional")}
- setReason(event.target.value)}
- placeholder={t("apr.reasonPh")}
- autoSize={{ minRows: 2, maxRows: 4 }}
- maxLength={MAX_APPROVAL_REASON_BYTES}
- showCount
- data-testid="approval-reason-input"
- disabled={disabled}
- />
-
+ <>
+ {item.scopeAllowed?.includes("run") ?
+ {t("apr.scopeTitle")}
+ setScope(event.target.value)} disabled={disabled}>
+ {t("apr.scope.once")}
+ {t("apr.scope.run")}
+
+ {t("apr.scopeRunHint")}
+ : null}
+
+ {t("apr.reasonOptional")}
+ setReason(event.target.value)}
+ placeholder={t("apr.reasonPh")}
+ autoSize={{ minRows: 2, maxRows: 4 }}
+ maxLength={MAX_APPROVAL_REASON_BYTES}
+ showCount
+ data-testid="approval-reason-input"
+ disabled={disabled}
+ />
+
+ >
)}
diff --git a/apps/desktop/renderer/src/approvals/types.ts b/apps/desktop/renderer/src/approvals/types.ts
index 84c14154..e9ae93bc 100644
--- a/apps/desktop/renderer/src/approvals/types.ts
+++ b/apps/desktop/renderer/src/approvals/types.ts
@@ -47,6 +47,8 @@ export interface ApprovalQueueItem {
category: ApprovalCategory;
description: string;
target?: string;
+ /** The server's projection of the engine-declared eligible grant scopes. */
+ scopeAllowed?: Array<"once" | "run">;
requestedAt?: string;
expiresAt?: string;
/** Snapshot of the position permissions.toolDeny list; only used for the
@@ -59,8 +61,9 @@ export interface ApprovalQueueItem {
}
export interface ApprovalQueueCallbacks {
- /** granted defaults to scope=once (contract default per §5.1); reason optional. */
- onApprove: (approvalId: string, reason?: string) => void;
+ /** granted defaults to scope=once; run is available only when the server
+ * projected it as eligible. */
+ onApprove: (approvalId: string, reason?: string, scope?: "once" | "run") => void;
/** denied MUST allow an empty reason (contract permits absent reason). */
onDeny: (approvalId: string, reason?: string) => void;
/** Open the persisted source conversation when the source is addressable. */
diff --git a/apps/desktop/renderer/src/approvals/useApprovals.ts b/apps/desktop/renderer/src/approvals/useApprovals.ts
index 9ab1eee5..a92d6125 100644
--- a/apps/desktop/renderer/src/approvals/useApprovals.ts
+++ b/apps/desktop/renderer/src/approvals/useApprovals.ts
@@ -62,15 +62,15 @@ export function useApprovals(workspacePath: string | undefined) {
return () => { if (current()) owner.current = {}; clearInterval(timer); window.removeEventListener("focus", focus); };
}, [workspacePath, t]);
- const decide = useCallback(async (id: string, decision: "granted" | "denied", reason?: string) => {
+ const decide = useCallback(async (id: string, decision: "granted" | "denied", reason?: string, scope: "once" | "run" = "once") => {
const generation = owner.current;
const item = cache.current.items.find(a => a.id === id);
if (!item || !cache.current.token || !item.canDecide || inFlight.current.has(id)) return;
const prior = pending.current.get(id);
- if (prior && (prior.decision !== decision || prior.reason !== reason)) {
+ if (prior && (prior.decision !== decision || prior.reason !== reason || prior.scope !== scope)) {
setErrors(e => ({ ...e, [id]: t("apr.retrySameDecision") })); return;
}
- const request = prior ?? { requestId: crypto.randomUUID(), expectedVersion: item.version, decision, ...(reason ? { reason } : {}) };
+ const request = prior ?? { requestId: crypto.randomUUID(), expectedVersion: item.version, decision, scope, ...(reason ? { reason } : {}) };
pending.current.set(id, request); inFlight.current.add(id); setBusy(new Set(inFlight.current));
setErrors(e => { const next = { ...e }; delete next[id]; return next; });
try {
diff --git a/apps/desktop/renderer/src/turns/TurnThread.tsx b/apps/desktop/renderer/src/turns/TurnThread.tsx
index 3cbf788d..7eacc6f5 100644
--- a/apps/desktop/renderer/src/turns/TurnThread.tsx
+++ b/apps/desktop/renderer/src/turns/TurnThread.tsx
@@ -24,7 +24,7 @@ export interface TurnThreadProps {
canRetry?: (turn: TurnRecord) => boolean;
onRetry?: (turn: TurnRecord) => void;
/** Operator verdict for a turn settled as engine.approval_required. */
- onVerdict?: (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => void;
+ onVerdict?: (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope?: "once" | "run") => void;
/** Approval ids whose verdict was already dispatched; their cards settle
* into a decided state so the operator cannot submit duplicate or
* contradictory verdicts after a history reload. */
@@ -183,7 +183,7 @@ function ApprovalCard({
turn: TurnRecord;
busy: boolean;
decided: boolean;
- onVerdict: (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => void;
+ onVerdict: (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope?: "once" | "run") => void;
}) {
const t = useT();
const kindCopy: Record
= {
@@ -193,6 +193,7 @@ function ApprovalCard({
tool: t("apr.kind.tool"),
};
const [reason, setReason] = useState("");
+ const [scope, setScope] = useState<"once" | "run">("once");
const request = turn.approvalRequest;
if (request === undefined) return null;
const trimmedReason = reason.trim();
@@ -225,11 +226,17 @@ function ApprovalCard({
onChange={(event) => setReason(event.target.value)}
/>
+ {request.scopeAllowed?.includes("run") ?
: null}
diff --git a/apps/desktop/renderer/src/turns/adapter.ts b/apps/desktop/renderer/src/turns/adapter.ts
index 4de963d1..f30b8aa3 100644
--- a/apps/desktop/renderer/src/turns/adapter.ts
+++ b/apps/desktop/renderer/src/turns/adapter.ts
@@ -32,6 +32,7 @@ function approvalRequest(record: ApiTurnRecord): TurnApprovalRequest | undefined
kind: event.action.kind,
description: event.action.description,
...(event.action.target !== undefined ? { target: event.action.target } : {}),
+ ...(event.action.scope !== undefined ? { scopeAllowed: event.action.scope.allowed } : {}),
...(event.expiresAt !== undefined ? { expiresAt: event.expiresAt } : {}),
};
}
diff --git a/apps/desktop/renderer/src/turns/types.ts b/apps/desktop/renderer/src/turns/types.ts
index cad13aad..9a2d7a28 100644
--- a/apps/desktop/renderer/src/turns/types.ts
+++ b/apps/desktop/renderer/src/turns/types.ts
@@ -53,6 +53,7 @@ export interface TurnApprovalRequest {
kind: string;
description: string;
target?: string;
+ scopeAllowed?: Array<"once" | "run">;
expiresAt?: string;
}
diff --git a/apps/desktop/renderer/test/approval-queue.test.tsx b/apps/desktop/renderer/test/approval-queue.test.tsx
index 36b939a7..307245c9 100644
--- a/apps/desktop/renderer/test/approval-queue.test.tsx
+++ b/apps/desktop/renderer/test/approval-queue.test.tsx
@@ -135,6 +135,16 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => {
expect(onDeny).toHaveBeenCalledWith("appr-abc", "\u8d85\u51fa Context Scope");
});
+ it("only offers server-eligible run scope and records the selected boundary", async () => {
+ const onApprove = vi.fn();
+ render(
);
+ fireEvent.click(screen.getByTestId("approval-card-appr-abc"));
+ expect(await screen.findByTestId("approval-scope-choice")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("radio", { name: "仅本回合" }));
+ fireEvent.click(screen.getByTestId("approval-approve-button"));
+ expect(onApprove).toHaveBeenCalledWith("appr-abc", undefined, "run");
+ });
+
it("\u5df2\u88c1\u51b3\u9879\u9501\u5b9a\uff1a\u4e0d\u80fd\u91cd\u590d\u88c1\u51b3\uff0c\u62d2\u7edd\u8bc1\u636e\u63d0\u793a\u4fdd\u7559", async () => {
const onApprove = vi.fn();
const onDeny = vi.fn();
diff --git a/apps/desktop/renderer/test/approvals-state.test.tsx b/apps/desktop/renderer/test/approvals-state.test.tsx
index 35c30598..c0ea03ce 100644
--- a/apps/desktop/renderer/test/approvals-state.test.tsx
+++ b/apps/desktop/renderer/test/approvals-state.test.tsx
@@ -44,6 +44,23 @@ describe("authoritative approval state", () => {
await act(() => result.current.decide(row.id, "denied", "reason"));
expect(decideApproval.mock.calls[0]![0].requestId).toBe(decideApproval.mock.calls[1]![0].requestId);
});
+ it("sends a run boundary only when the caller selected it and keeps it for retry", async () => {
+ const eligible = { ...row, context: {
+ risk: "high" as const, requestedCapability: "write" as const, impact: "workspace_write" as const,
+ permissions: { mode: "approval_required" as const, allowedTools: [], deniedTools: [] },
+ preview: { status: "unavailable" as const, reason: "engine_preview_not_supplied" as const },
+ scope: { allowed: ["once", "run"] as Array<"once" | "run"> },
+ } };
+ const decideApproval = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ status: 202, body: { ...eligible, status: "granted", decision: { scope: "run" } } });
+ bridge({ listApprovals: vi.fn(async () => page([eligible])), decideApproval });
+ const { result } = renderHook(() => useApprovals("/a"));
+ await waitFor(() => expect(result.current.ready).toBe(true));
+ await act(() => result.current.decide(row.id, "granted", undefined, "run"));
+ await act(() => result.current.decide(row.id, "granted", undefined, "run"));
+ expect(decideApproval.mock.calls[0]![0].scope).toBe("run");
+ expect(decideApproval.mock.calls[1]![0].scope).toBe("run");
+ expect(decideApproval.mock.calls[0]![0].requestId).toBe(decideApproval.mock.calls[1]![0].requestId);
+ });
it("drops responses from a prior workspace generation including A → B → A", async () => {
let release!: (value: unknown) => void;
const listApprovals = vi.fn().mockImplementationOnce(() => new Promise(resolve => { release = resolve; })).mockImplementation(async () => page([], "new"));
diff --git a/apps/server/src/approvals/context.ts b/apps/server/src/approvals/context.ts
index b680778f..e3adfa3c 100644
--- a/apps/server/src/approvals/context.ts
+++ b/apps/server/src/approvals/context.ts
@@ -36,5 +36,6 @@ export function buildApprovalContext(
deniedTools: role.toolDeny.slice(0, 128),
},
preview: { status: "unavailable", reason: "engine_preview_not_supplied" },
+ scope: { allowed: action.scope?.allowed ?? ["once"] },
};
}
diff --git a/apps/server/src/approvals/service.ts b/apps/server/src/approvals/service.ts
index 61b277c4..83816379 100644
--- a/apps/server/src/approvals/service.ts
+++ b/apps/server/src/approvals/service.ts
@@ -1,5 +1,5 @@
import crypto from "node:crypto";
-import { OrgApiError, errorCodes, validatePendingApproval, type ApprovalRecord, type ApprovalView, type ApprovalDecisionRequest, type TurnRecord, type WorkbenchSession } from "@roleweave/shared";
+import { OrgApiError, approvalRunScopeBindingInput, errorCodes, validatePendingApproval, type ApprovalRecord, type ApprovalView, type ApprovalDecisionRequest, type TurnRecord, type WorkbenchSession } from "@roleweave/shared";
import type { ControlPlaneContext } from "../context.js";
import type { OpenWorkspace } from "../workspace-state.js";
import { assertTurnWorkspace, executeTurn } from "../routes/turns.js";
@@ -18,12 +18,29 @@ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function parseDecision(value: unknown): ApprovalDecisionRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new OrgApiError(errorCodes.approval_request_invalid, 400, "Invalid decision");
const d = value as ApprovalDecisionRequest;
- if (Object.keys(d).some(k => !["requestId", "expectedVersion", "decision", "reason"].includes(k)) ||
+ if (Object.keys(d).some(k => !["requestId", "expectedVersion", "decision", "reason", "scope"].includes(k)) ||
typeof d.requestId !== "string" || !uuid.test(d.requestId) || !Number.isSafeInteger(d.expectedVersion) || d.expectedVersion < 1 ||
+ (d.scope !== undefined && d.scope !== "once" && d.scope !== "run") ||
!validatePendingApproval({ approvalId: "validation", decision: d.decision, decidedBy: "operator", ...(d.reason === undefined ? {} : { reason: d.reason }) }).ok) {
throw new OrgApiError(errorCodes.approval_request_invalid, 400, "Invalid decision or reason (maximum 1024 UTF-8 bytes)");
}
- return d;
+ return { ...d, scope: d.scope ?? "once" };
+}
+
+function validRunScopeBinding(record: ApprovalRecord): boolean {
+ const offer = record.action.scope;
+ if (!offer?.allowed.includes("run") || !offer.runBinding) return false;
+ const input = approvalRunScopeBindingInput(record.approvalId, record.source.runId, record.action, record.expiresAt);
+ const expected = `sha256:${crypto.createHash("sha256").update(input).digest("hex")}`;
+ return crypto.timingSafeEqual(Buffer.from(offer.runBinding), Buffer.from(expected));
+}
+
+function approvalContext(record: ApprovalRecord, role: Parameters
[1]) {
+ const context = buildApprovalContext(record.action, role);
+ // The engine may declare a syntactically valid offer, but it becomes visible
+ // as a selectable boundary only after the control plane verifies that it is
+ // bound to this exact approval, source run, action and expiry.
+ return { ...context, scope: { allowed: validRunScopeBinding(record) ? ["once", "run"] as Array<"once" | "run"> : ["once"] as Array<"once" | "run"> } };
}
function publicView(record: ApprovalRecord): ApprovalRecord {
@@ -90,11 +107,11 @@ export class ApprovalService {
const record: ApprovalRecord = {
schemaVersion: "workbench-approval.v1", id, version: 1, approvalId: event.approvalId, source,
action: event.action, ...(event.reason ? { requestReason: event.reason } : {}),
- ...(role ? { context: buildApprovalContext(event.action, role) } : {}),
requestedAt: event.timestamp, ...(event.expiresAt ? { expiresAt: event.expiresAt } : {}),
status: turn.error?.code === "engine.approval_required" ? "pending" : "indeterminate",
execution: { phase: "not_started" }, createdAt: now, updatedAt: now,
};
+ if (role) record.context = approvalContext(record, role);
await this.save(ws, record); byId.set(id, record);
}
}
@@ -102,7 +119,7 @@ export class ApprovalService {
const before = JSON.stringify(a);
if (!a.context) {
const role = ws.organization.roles.find(entry => entry.id === a.source.positionId);
- if (role) a.context = buildApprovalContext(a.action, role);
+ if (role) a.context = approvalContext(a, role);
}
if (a.execution.turnId && !this.active.has(`${ws.dir}\0${a.id}`)) {
const result = turns.find(t => t.turnId === a.execution.turnId && t.positionId === a.source.positionId && t.conversationId === a.source.conversationId);
@@ -171,10 +188,11 @@ export class ApprovalService {
if (!a) throw new OrgApiError(errorCodes.approval_missing, 404, "Approval not found");
const prior = items.find(x => x.decision?.requestId === request.requestId);
if (prior) {
- if (prior.id !== id || prior.decision!.decision !== request.decision || prior.decision!.reason !== request.reason || prior.decision!.expectedVersion !== request.expectedVersion) throw conflict("Request id was already used for another decision");
+ if (prior.id !== id || prior.decision!.decision !== request.decision || prior.decision!.scope !== request.scope || prior.decision!.reason !== request.reason || prior.decision!.expectedVersion !== request.expectedVersion) throw conflict("Request id was already used for another decision");
return { status: 200, record: await this.view(ws, prior) };
}
if (a.status === "expired") throw new OrgApiError(errorCodes.approval_expired, 410, "Approval expired");
+ if (request.scope === "run" && (request.decision !== "granted" || !validRunScopeBinding(a))) throw conflict("This approval is not eligible for run scope");
const view = await this.view(ws, a);
if (!view.canDecide || a.version !== request.expectedVersion) throw conflict(view.unavailableReason ?? "Approval changed; refresh before deciding");
const source = turns.find(t => t.turnId === a.source.turnId && t.positionId === a.source.positionId && (t.conversationRef ?? t.conversationId) === a.source.conversationId);
@@ -189,7 +207,7 @@ export class ApprovalService {
this.assertToken(ws, token);
if (a.expiresAt && Date.now() >= Date.parse(a.expiresAt)) throw new OrgApiError(errorCodes.approval_expired, 410, "Approval expired");
a.status = request.decision;
- a.decision = { ...request, scope: "once", decidedBy: "operator", decidedAt: new Date().toISOString() };
+ a.decision = { ...request, decidedBy: "operator", decidedAt: new Date().toISOString() };
// Starting is persisted BEFORE any dispatch. A crash here is deliberately
// indeterminate: replaying an external side effect would be unsafe.
a.execution = { phase: "starting", turnId };
@@ -208,7 +226,7 @@ export class ApprovalService {
const result = await executeTurn(this.ctx, undefined, {
positionId: a.source.positionId, engine: a.source.engine,
input: `${request.decision === "granted" ? "[审批裁决] 请继续执行以下原任务中已批准的动作" : "[审批裁决] 已拒绝以下原任务的动作,不得执行"}\n${source.input}`,
- pendingApproval: { approvalId: a.approvalId, decision: request.decision, decidedBy: "operator", scope: "once", ...(request.reason ? { reason: request.reason } : {}), ...(a.expiresAt ? { expiresAt: a.expiresAt } : {}) },
+ pendingApproval: { approvalId: a.approvalId, decision: request.decision, decidedBy: "operator", scope: request.scope, ...(request.reason ? { reason: request.reason } : {}), ...(a.expiresAt ? { expiresAt: a.expiresAt } : {}) },
}, session, undefined, undefined, ws, {
turnId, reservation,
beforeRun: async () => {
diff --git a/apps/server/src/approvals/store.ts b/apps/server/src/approvals/store.ts
index 463e85d8..b7d099db 100644
--- a/apps/server/src/approvals/store.ts
+++ b/apps/server/src/approvals/store.ts
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import fs from "node:fs/promises";
import { constants } from "node:fs";
-import { OrgApiError, errorCodes, turnEngines, validatePendingApproval, type ApprovalRecord } from "@roleweave/shared";
+import { OrgApiError, errorCodes, isApprovalScopeOffer, turnEngines, validatePendingApproval, type ApprovalRecord } from "@roleweave/shared";
import { atomicWriteJson, nodeAtomicTurnWriteOperations } from "../turns/store.js";
import { redactApprovalText } from "./context.js";
@@ -61,6 +61,7 @@ function valid(value: unknown): value is ApprovalRecord {
![s.positionId, s.conversationId, s.turnId, s.runId, a.approvalId].every(text) || !turnEngines.includes(s.engine) ||
a.id !== approvalIdentity(s, a.approvalId) || !a.action || !["write", "exec", "network", "tool"].includes(a.action.kind) ||
!text(a.action.description) || (a.action.target !== undefined && !text(a.action.target)) ||
+ (a.action.scope !== undefined && !isApprovalScopeOffer(a.action.scope)) ||
(a.requestReason !== undefined && !text(a.requestReason)) || !time(a.requestedAt) || !time(a.createdAt) || !time(a.updatedAt) ||
(a.expiresAt !== undefined && !time(a.expiresAt)) ||
!["pending", "granted", "denied", "expired", "cancelled", "indeterminate"].includes(a.status) ||
@@ -69,7 +70,7 @@ function valid(value: unknown): value is ApprovalRecord {
if (a.decision) {
const d = a.decision;
if (!/^[a-f0-9-]{36}$/.test(d.requestId) || !Number.isSafeInteger(d.expectedVersion) || d.expectedVersion < 1 ||
- !time(d.decidedAt) || d.scope !== "once" || d.decision !== a.status ||
+ !time(d.decidedAt) || (d.scope !== "once" && d.scope !== "run") || d.decision !== a.status ||
!validatePendingApproval({ approvalId: a.approvalId, decision: d.decision, decidedBy: d.decidedBy, scope: d.scope, ...(d.reason === undefined ? {} : { reason: d.reason }) }).ok) return false;
}
if (a.context !== undefined) {
@@ -81,7 +82,10 @@ function valid(value: unknown): value is ApprovalRecord {
!["workspace_write", "command_execution", "external_network", "restricted_tool"].includes(c.impact) ||
!c.permissions || !["read_only", "approval_required"].includes(c.permissions.mode) ||
!boundedList(c.permissions.allowedTools) || !boundedList(c.permissions.deniedTools) ||
- !c.preview || c.preview.status !== "unavailable" || c.preview.reason !== "engine_preview_not_supplied") return false;
+ !c.preview || c.preview.status !== "unavailable" || c.preview.reason !== "engine_preview_not_supplied" ||
+ (c.scope !== undefined && (!Array.isArray(c.scope.allowed) || c.scope.allowed.length < 1 || c.scope.allowed.length > 2 ||
+ c.scope.allowed[0] !== "once" || new Set(c.scope.allowed).size !== c.scope.allowed.length ||
+ c.scope.allowed.some(scope => scope !== "once" && scope !== "run")))) return false;
}
return true;
}
diff --git a/apps/server/src/engine/driver-cli.ts b/apps/server/src/engine/driver-cli.ts
index b9081e9f..010a3307 100644
--- a/apps/server/src/engine/driver-cli.ts
+++ b/apps/server/src/engine/driver-cli.ts
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
+import { isApprovalScopeOffer } from "@roleweave/shared";
import type {
EngineOrgApplySuccess,
EngineEvent,
@@ -223,7 +224,7 @@ function parseEngineEvent(line: string): EngineEvent {
if (!isRecord(unknownEvent.action)) {
throw new EngineProtocolError("engine.v1 approval.requested action is invalid");
}
- exactKeys(unknownEvent.action, ["kind", "description"], ["target"]);
+ exactKeys(unknownEvent.action, ["kind", "description"], ["target", "scope"]);
if (
!boundedApprovalId(unknownEvent.approvalId) ||
!APPROVAL_ACTION_KINDS.has(unknownEvent.action.kind as string) ||
@@ -232,7 +233,8 @@ function parseEngineEvent(line: string): EngineEvent {
!boundedNonEmptyText(unknownEvent.action.target, APPROVAL_TARGET_MAX_BYTES)) ||
(unknownEvent.reason !== undefined &&
!boundedNonEmptyText(unknownEvent.reason, APPROVAL_DESCRIPTION_MAX_BYTES)) ||
- !optionalIsoTimestamp(unknownEvent.expiresAt)
+ !optionalIsoTimestamp(unknownEvent.expiresAt) ||
+ (unknownEvent.action.scope !== undefined && !isApprovalScopeOffer(unknownEvent.action.scope))
) {
throw new EngineProtocolError("engine.v1 approval.requested fields are invalid or unbounded");
}
@@ -246,6 +248,9 @@ function parseEngineEvent(line: string): EngineEvent {
...(unknownEvent.action.target !== undefined
? { target: unknownEvent.action.target }
: {}),
+ ...(unknownEvent.action.scope !== undefined
+ ? { scope: unknownEvent.action.scope }
+ : {}),
},
...(unknownEvent.reason !== undefined ? { reason: unknownEvent.reason } : {}),
...(unknownEvent.expiresAt !== undefined
diff --git a/apps/server/src/turns/store.ts b/apps/server/src/turns/store.ts
index b44fec6c..f48b529e 100644
--- a/apps/server/src/turns/store.ts
+++ b/apps/server/src/turns/store.ts
@@ -6,6 +6,7 @@ import {
TURN_HISTORY_SCHEMA_VERSION,
TURN_RECORD_SCHEMA_VERSION,
errorCodes,
+ isApprovalScopeOffer,
isPositionId,
isEngineModelId,
turnEngines,
@@ -695,7 +696,7 @@ function validateEngineEvent(raw: unknown): EngineEvent | null {
["reason", "expiresAt"],
) ||
!isObjectRecord(value.action) ||
- !hasExactKeys(value.action, ["kind", "description"], ["target"]) ||
+ !hasExactKeys(value.action, ["kind", "description"], ["target", "scope"]) ||
!isBoundedApprovalId(value.approvalId) ||
!APPROVAL_ACTION_KINDS.has(value.action.kind as string) ||
!isBoundedNonEmptyText(value.action.description, APPROVAL_DESCRIPTION_MAX_BYTES) ||
@@ -703,7 +704,8 @@ function validateEngineEvent(raw: unknown): EngineEvent | null {
!isBoundedNonEmptyText(value.action.target, APPROVAL_TARGET_MAX_BYTES)) ||
(value.reason !== undefined &&
!isBoundedNonEmptyText(value.reason, APPROVAL_DESCRIPTION_MAX_BYTES)) ||
- !isOptionalIsoTimestamp(value.expiresAt)
+ !isOptionalIsoTimestamp(value.expiresAt) ||
+ (value.action.scope !== undefined && !isApprovalScopeOffer(value.action.scope))
) return null;
return {
...base,
@@ -713,6 +715,7 @@ function validateEngineEvent(raw: unknown): EngineEvent | null {
kind: value.action.kind as "exec" | "write" | "network" | "tool",
description: value.action.description,
...(value.action.target !== undefined ? { target: value.action.target } : {}),
+ ...(value.action.scope !== undefined ? { scope: value.action.scope } : {}),
},
...(value.reason !== undefined ? { reason: value.reason } : {}),
...(value.expiresAt !== undefined ? { expiresAt: value.expiresAt as string } : {}),
diff --git a/apps/server/test/approval.test.ts b/apps/server/test/approval.test.ts
index b75f3a98..79020422 100644
--- a/apps/server/test/approval.test.ts
+++ b/apps/server/test/approval.test.ts
@@ -107,7 +107,7 @@ test("CLI driver mirrors the engine.v1 approval events verbatim into a trusted s
...base,
type: "approval.requested",
approvalId: "appr-1",
- action: { kind: "exec", description: "rm -rf build", target: "scripts/clean.sh" },
+ action: { kind: "exec", description: "rm -rf build", target: "scripts/clean.sh", scope: { version: "approval-scope-offer.v1", allowed: ["once", "run"], runBinding: "sha256:${"0".repeat(64)}" } },
reason: "destructive command",
expiresAt: "2026-08-24T01:00:00.000Z",
}));
@@ -138,6 +138,7 @@ test("CLI driver mirrors the engine.v1 approval events verbatim into a trusted s
kind: "exec",
description: "rm -rf build",
target: "scripts/clean.sh",
+ scope: { version: "approval-scope-offer.v1", allowed: ["once", "run"], runBinding: `sha256:${"0".repeat(64)}` },
});
assert.equal(requested.reason, "destructive command");
assert.equal(requested.expiresAt, "2026-08-24T01:00:00.000Z");
@@ -156,6 +157,11 @@ test("CLI driver fails closed on malformed approval events without faking a term
runId: "run-1", timestamp: "2026-08-24T00:00:00.000Z", type: "approval.requested",
approvalId: "appr-1", action: { kind: "spawn", description: "run" },
}),
+ // A run offer without an exact binding is unsafe.
+ JSON.stringify({
+ runId: "run-1", timestamp: "2026-08-24T00:00:00.000Z", type: "approval.requested",
+ approvalId: "appr-1", action: { kind: "exec", description: "run", scope: { version: "approval-scope-offer.v1", allowed: ["once", "run"] } },
+ }),
// approvalId beyond the 256 bound
JSON.stringify({
runId: "run-1", timestamp: "2026-08-24T00:00:00.000Z", type: "approval.requested",
diff --git a/apps/server/test/approvals-center.test.ts b/apps/server/test/approvals-center.test.ts
index b0905168..065ad4bf 100644
--- a/apps/server/test/approvals-center.test.ts
+++ b/apps/server/test/approvals-center.test.ts
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import type { ApprovalList, ApprovalView, EngineEvent, GroupConversation, TurnRunDriver, TurnRunRequest, TurnRunResult } from "@roleweave/shared";
+import { approvalRunScopeBindingInput } from "@roleweave/shared";
import { api, copyExampleWorkspace, startTestServer, type TestServer } from "./helpers.js";
import { approvals } from "../src/approvals/service.js";
@@ -12,6 +13,8 @@ class ApprovalDriver implements TurnRunDriver {
expiresAt = new Date(Date.now() + 60000).toISOString();
target = "report.md";
reason = "The write needs operator approval";
+ runScope = false;
+ invalidRunBinding = false;
hold?: Promise;
async turnRun(request: TurnRunRequest): Promise {
this.calls.push(request);
@@ -21,10 +24,17 @@ class ApprovalDriver implements TurnRunDriver {
const events: EngineEvent[] = [{ ...base, type: "run.started" }];
if (d) {
await this.hold;
- if (d.decision === "granted") events.push({ ...base, type: "approval.granted", approvalId: d.approvalId, grantedBy: "operator", scope: "once" }, { ...base, type: "run.completed", output: "done", terminalReason: "goal_met" });
+ if (d.decision === "granted") events.push({ ...base, type: "approval.granted", approvalId: d.approvalId, grantedBy: "operator", scope: d.scope ?? "once" }, { ...base, type: "run.completed", output: "done", terminalReason: "goal_met" });
else events.push({ ...base, type: "approval.denied", approvalId: d.approvalId, deniedBy: "operator" }, { ...base, type: "run.failed", error: { code: "engine.approval_denied", message: "denied", retryable: false, terminalReason: "cancelled" } });
- } else events.push({ ...base, type: "approval.requested", approvalId: "same-engine-id", action: { kind: "write", description: "write report", target: this.target }, reason: this.reason, expiresAt: this.expiresAt },
+ } else {
+ const action = { kind: "write" as const, description: "write report", target: this.target };
+ const binding = `sha256:${crypto.createHash("sha256").update(approvalRunScopeBindingInput("same-engine-id", runId, action, this.expiresAt)).digest("hex")}`;
+ events.push({ ...base, type: "approval.requested", approvalId: "same-engine-id", action: {
+ ...action,
+ ...(this.runScope ? { scope: { version: "approval-scope-offer.v1" as const, allowed: ["once", "run"] as Array<"once" | "run">, runBinding: this.invalidRunBinding ? `sha256:${"0".repeat(64)}` : binding } } : {}),
+ }, reason: this.reason, expiresAt: this.expiresAt },
{ ...base, type: "run.failed", error: { code: "engine.approval_required", message: "waiting", retryable: true, terminalReason: "engine_internal_error" } });
+ }
for (const event of events) request.onEvent?.(event);
return { status: "trusted", events, diagnostic: "" };
}
@@ -105,6 +115,61 @@ test("approval center restores the source session, preserves expiry, is idempote
} finally { await s.close(); }
});
+test("run scope is opt-in, source-bound, durable, and idempotent", async () => {
+ const driver = new ApprovalDriver();
+ driver.runScope = true;
+ const s = await startTestServer(undefined, driver);
+ try {
+ await open(s, await copyExampleWorkspace()); await request(s);
+ const snapshot = await list(s), a = snapshot.items[0]!, route = `/approvals/${a.id}/decision`;
+ assert.deepEqual(a.context?.scope.allowed, ["once", "run"]);
+ const decision = { ...body(snapshot, a), scope: "run" as const };
+ const accepted = await api(s.baseUrl, route, { token: s.token, method: "POST", body: decision });
+ assert.equal(accepted.status, 202);
+ assert.equal((accepted.body as ApprovalView).decision?.scope, "run");
+ const done = await settle(s, a.id);
+ assert.equal(done.decision?.scope, "run");
+ assert.equal(driver.calls.find(call => call.envelope.pendingApproval)?.envelope.pendingApproval?.scope, "run");
+ const replay = await api(s.baseUrl, route, { token: s.token, method: "POST", body: { ...decision, workspaceToken: (await list(s)).workspaceToken } });
+ assert.equal(replay.status, 200, "same request retains the original scope");
+ const changedScope = await api(s.baseUrl, route, { token: s.token, method: "POST", body: { ...decision, scope: "once", workspaceToken: (await list(s)).workspaceToken } });
+ assert.equal(changedScope.status, 409, "a request id cannot be replayed with a wider or narrower boundary");
+ } finally { await s.close(); }
+});
+
+test("run scope rejects undeclared, tampered, denied, and expired offers before dispatch", async () => {
+ for (const variant of ["undeclared", "tampered"] as const) {
+ const driver = new ApprovalDriver();
+ driver.runScope = variant === "tampered";
+ driver.invalidRunBinding = variant === "tampered";
+ const s = await startTestServer(undefined, driver);
+ try {
+ await open(s, await copyExampleWorkspace()); await request(s);
+ const snapshot = await list(s), a = snapshot.items[0]!;
+ assert.deepEqual(a.context?.scope.allowed, ["once"], `${variant} offer must not be rendered as an eligible run scope`);
+ const response = await api(s.baseUrl, `/approvals/${a.id}/decision`, { token: s.token, method: "POST", body: { ...body(snapshot, a), scope: "run" } });
+ assert.equal(response.status, 409, variant);
+ assert.equal(driver.calls.filter(call => call.envelope.pendingApproval).length, 0, variant);
+ } finally { await s.close(); }
+ }
+ const driver = new ApprovalDriver(); driver.runScope = true;
+ const s = await startTestServer(undefined, driver);
+ try {
+ await open(s, await copyExampleWorkspace()); await request(s);
+ const snapshot = await list(s), a = snapshot.items[0]!;
+ const denied = await api(s.baseUrl, `/approvals/${a.id}/decision`, { token: s.token, method: "POST", body: { ...body(snapshot, a, "denied"), scope: "run" } });
+ assert.equal(denied.status, 409);
+ driver.expiresAt = new Date(Date.now() - 1).toISOString();
+ // Existing records retain their request expiry; creating a new expiring
+ // request proves expiry wins before a run-scope grant can be dispatched.
+ await request(s, "community-operator");
+ const expired = (await list(s)).items.find(item => item.source.positionId === "community-operator")!;
+ assert.equal(expired.status, "expired");
+ const response = await api(s.baseUrl, `/approvals/${expired.id}/decision`, { token: s.token, method: "POST", body: { ...body(await list(s), expired), scope: "run" } });
+ assert.equal(response.status, 410);
+ } finally { await s.close(); }
+});
+
test("group approvals stay visible but read-only and never dispatch a recovery turn", async () => {
const driver = new ApprovalDriver(), s = await startTestServer(undefined, driver);
try {
diff --git a/docs/approvals-p0.md b/docs/approvals-p0.md
index ab88b15d..efdd9e67 100644
--- a/docs/approvals-p0.md
+++ b/docs/approvals-p0.md
@@ -4,17 +4,25 @@
## 行为
-审批中心和会话卡通过同一个本地审批服务读取状态和提交裁决。服务端绑定原岗位、原会话、原回合与引擎;批准范围固定为 `once`,保留原到期时间。裁决保存与后续执行结果分别展示。
+审批中心和会话卡通过同一个本地审批服务读取状态和提交裁决。服务端绑定原岗位、原会话、原回合与引擎;默认批准范围为 `once`,保留原到期时间。裁决保存与后续执行结果分别展示。
个人 session 和旧版岗位会话可以裁决。群聊来源只读展示,批准和拒绝按钮固定禁用,不能由审批中心裁决;已归档会话必须重新发起任务。旧接口直接提交 `pendingApproval` 返回 `409 approval_endpoint_required`,不会启动引擎。
批准或拒绝会启动一条携带 `pendingApproval` 的新恢复回合;原始 `engine.approval_required` 回合及其事件记录保持不变,不会在原回合上续写或改写终态。
+### #401:一次性与本回合范围
+
+引擎可以在 `approval.requested.action.scope` 中声明 `approval-scope-offer.v1`。`allowed` 必须以 `once` 开始;只有同时声明 `run` 和 `runBinding` 时,才可能选择本回合范围。`runBinding` 是 `sha256:` 加十六进制 SHA-256,输入为 shared 的 `approvalRunScopeBindingInput(approvalId, sourceRunId, {kind, description, target}, expiresAt)` 的规范 JSON。
+
+服务端重新计算摘要并在写入裁决前比对:绑定到不同审批编号、请求 run、动作(含目标)或有效期的 offer 一律不能升级为 `run`。`run` 只允许批准,不允许拒绝;来源已过期、失配、重放或版本冲突仍按原路径拒绝。审计记录持久化实际 `decision.scope`,同一个 `requestId` 重试时必须携带完全相同的范围。
+
+对 UI,服务端只投影校验通过的 `context.scope.allowed`;默认与旧记录均仅显示“仅此动作”。选择“仅本回合”仅会把该恢复回合的 `pendingApproval.scope` 设为 `run`,不会放宽该岗位、后续回合或其他动作。审批历史显示实际生效范围。
+
## 接口
- `GET /approvals?status=all|pending|decided&limit=50&cursor=...&workspacePath=...`:返回分页 items、pendingCount、revision、workspaceToken、nextCursor 和 syncState。limit 最大 200。快照改变后旧游标返回 `409 approval_snapshot_changed`。
- `GET /approvals/:id`:详情含 `canDecide` 和 `unavailableReason`。
-- `POST /approvals/:id/decision`:请求包含 `workspaceToken`、`requestId`、`expectedVersion`、`decision` 和可选 `reason`。理由上限为 1024 UTF-8 字节。
+- `POST /approvals/:id/decision`:请求包含 `workspaceToken`、`requestId`、`expectedVersion`、`decision`、可选 `scope`(省略即 `once`)和可选 `reason`。理由上限为 1024 UTF-8 字节。`scope: run` 仅接受引擎声明且服务端验证了绑定的批准请求。
- 首次持久化成功返回 202;相同请求幂等返回 200;相反裁决、旧版本或工作区实例失配返回 409;过期返回 410;格式错误返回 400。
- `approvals.changed` 在记录落盘后发布,携带工作区路径、审批记录 ID 和版本。客户端将 SSE 作为刷新提示,重连、聚焦、返回审批中心及每 10 秒补查快照。
diff --git a/packages/shared/src/approval-scope.ts b/packages/shared/src/approval-scope.ts
new file mode 100644
index 00000000..562fdcf0
--- /dev/null
+++ b/packages/shared/src/approval-scope.ts
@@ -0,0 +1,30 @@
+export const APPROVAL_SCOPE_OFFER_VERSION = "approval-scope-offer.v1" as const;
+
+export interface ApprovalScopeOffer {
+ version: typeof APPROVAL_SCOPE_OFFER_VERSION;
+ /** Always includes once; run is opt-in and must carry a binding. */
+ allowed: Array<"once" | "run">;
+ runBinding?: string;
+}
+
+export interface ApprovalScopeAction {
+ kind: string;
+ description: string;
+ target?: string;
+}
+
+export function approvalRunScopeBindingInput(approvalId: string, runId: string, action: ApprovalScopeAction, expiresAt?: string): string {
+ return JSON.stringify({ approvalId, runId, action: { kind: action.kind, description: action.description, ...(action.target === undefined ? {} : { target: action.target }) }, ...(expiresAt === undefined ? {} : { expiresAt }) });
+}
+
+export function isApprovalScopeOffer(value: unknown): value is ApprovalScopeOffer {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const offer = value as Record;
+ if (Object.keys(offer).some(key => !["version", "allowed", "runBinding"].includes(key)) ||
+ offer.version !== APPROVAL_SCOPE_OFFER_VERSION || !Array.isArray(offer.allowed) ||
+ offer.allowed.length < 1 || offer.allowed.length > 2 || offer.allowed[0] !== "once" ||
+ new Set(offer.allowed).size !== offer.allowed.length || offer.allowed.some(scope => scope !== "once" && scope !== "run")) return false;
+ return offer.allowed.includes("run")
+ ? typeof offer.runBinding === "string" && /^sha256:[a-f0-9]{64}$/.test(offer.runBinding)
+ : offer.runBinding === undefined;
+}
diff --git a/packages/shared/src/approvals.ts b/packages/shared/src/approvals.ts
index 0c7d9381..7c65a359 100644
--- a/packages/shared/src/approvals.ts
+++ b/packages/shared/src/approvals.ts
@@ -22,12 +22,14 @@ export interface ApprovalContext {
status: "unavailable";
reason: "engine_preview_not_supplied";
};
+ scope: { allowed: Array<"once" | "run"> };
}
export interface ApprovalDecisionRequest {
requestId: string;
expectedVersion: number;
decision: "granted" | "denied";
+ scope: "once" | "run";
reason?: string;
}
export interface ApprovalRecord {
@@ -49,7 +51,7 @@ export interface ApprovalRecord {
requestedAt: string;
expiresAt?: string;
status: ApprovalStatus;
- decision?: ApprovalDecisionRequest & { decidedBy: "operator"; decidedAt: string; scope: "once" };
+ decision?: ApprovalDecisionRequest & { decidedBy: "operator"; decidedAt: string };
execution: { phase: ApprovalPhase; turnId?: string; errorCode?: string };
createdAt: string;
updatedAt: string;
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 1731b808..c96270b6 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -5,6 +5,7 @@ export * from "./position-id.js";
export * from "./pending-approval.js";
export * from "./approvals.js";
export * from "./approval-redaction.js";
+export * from "./approval-scope.js";
export * from "./change-manifest.js";
export * from "./hire.js";
export * from "./position-profile.js";
diff --git a/packages/shared/src/turns.ts b/packages/shared/src/turns.ts
index 8e4e0003..7c17c69d 100644
--- a/packages/shared/src/turns.ts
+++ b/packages/shared/src/turns.ts
@@ -4,6 +4,7 @@
* package only adds workbench-local persistence records.
*/
import { createRequire } from "node:module";
+import type { ApprovalScopeOffer } from "./approval-scope.js";
export const TURN_ENVELOPE_SCHEMA_VERSION = "turn-envelope.v1" as const;
/** Upstream de#205 (DE-CONVREF-001): v1alpha2 = v1 + optional conversationRef. */
@@ -71,6 +72,8 @@ export interface ApprovalRequestedEvent extends EngineEventBase {
kind: TurnApprovalActionKind;
description: string;
target?: string;
+ /** Engine-declared scope eligibility; absent means once only. */
+ scope?: ApprovalScopeOffer;
};
reason?: string;
expiresAt?: string;
diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts
index 93a342db..09af52e2 100644
--- a/packages/ui/src/locales/en.ts
+++ b/packages/ui/src/locales/en.ts
@@ -827,6 +827,11 @@ export const enCatalog: Record = {
"apr.decidedNote": "This approval is handled and cannot be used again",
"apr.reasonOptional": "Denial reason (optional)",
"apr.grant": "Approve and continue",
+ "apr.scopeTitle": "Approval boundary",
+ "apr.scope.once": "This action only",
+ "apr.scope.run": "This run only",
+ "apr.scopeRunHint": "This grant applies only to the bound recovery run and expires with this request.",
+ "apr.effectiveScope": "Effective boundary: {scope}",
"apr.deny": "Deny",
"apr.queue": "Approval Queue",
"apr.queueLedeA": "Collects the ",
diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts
index dbb65cdb..c5128867 100644
--- a/packages/ui/src/locales/zh.ts
+++ b/packages/ui/src/locales/zh.ts
@@ -830,6 +830,11 @@ export const zhCatalog: Record = {
"apr.decidedNote": "这个审批已处理,不能重复操作",
"apr.reasonOptional": "拒绝理由(可选)",
"apr.grant": "批准并继续",
+ "apr.scopeTitle": "审批范围",
+ "apr.scope.once": "仅此动作",
+ "apr.scope.run": "仅本回合",
+ "apr.scopeRunHint": "该授权只作用于绑定的恢复回合,并随本请求过期。",
+ "apr.effectiveScope": "实际范围:{scope}",
"apr.deny": "拒绝",
"apr.queue": "审批队列",
"apr.queueLedeA": "汇总散落在各岗位回合里的 ",
From fb37f8f81f29e87fb250f945fc09a6f4bd748186 Mon Sep 17 00:00:00 2001
From: Bindy-lbb <70745012+Bindy-lbb@users.noreply.github.com>
Date: Sun, 20 Sep 2026 14:38:19 +0800
Subject: [PATCH 2/2] fix(packaging): include approval scope runtime
---
apps/desktop/packaging/runtime-layout.cjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs
index d4a9d490..36e2042c 100644
--- a/apps/desktop/packaging/runtime-layout.cjs
+++ b/apps/desktop/packaging/runtime-layout.cjs
@@ -146,6 +146,7 @@ const SHARED_RUNTIME_FILES = [
"dist/api.js",
"dist/approval-preview.js",
"dist/approval-redaction.js",
+ "dist/approval-scope.js",
"dist/approvals.js",
"dist/attachments.js",
"dist/avatar.js",