Skip to content
Open
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
7 changes: 6 additions & 1 deletion apps/cli/docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16316,6 +16316,8 @@ routes[15]{method,path,purpose,auth}:
POST,/signal/:runId/:signalName,Legacy alias for signals,bearer
```

Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted.

JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry `SmithersEvent` JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state.

Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`.
Expand Down Expand Up @@ -16507,10 +16509,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1
{
"iteration": 0,
"note": "Looks good",
"decidedBy": "alice"
"decidedBy": "alice",
"decision": { "selected": "balanced", "notes": "best fit" }
}
```

For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted.

### POST /deny/:nodeId

Deny a pending approval gate. Same body as `/approve/:nodeId`.
Expand Down
5 changes: 4 additions & 1 deletion docs/integrations/serve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1
{
"iteration": 0,
"note": "Looks good",
"decidedBy": "alice"
"decidedBy": "alice",
"decision": { "selected": "balanced", "notes": "best fit" }
}
```

For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted.

### POST /deny/:nodeId

Deny a pending approval gate. Same body as `/approve/:nodeId`.
Expand Down
2 changes: 2 additions & 0 deletions docs/integrations/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ routes[15]{method,path,purpose,auth}:
POST,/signal/:runId/:signalName,Legacy alias for signals,bearer
```

Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted.

JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry [`SmithersEvent`](/runtime/events) JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state.

Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`.
Expand Down
7 changes: 6 additions & 1 deletion docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16316,6 +16316,8 @@ routes[15]{method,path,purpose,auth}:
POST,/signal/:runId/:signalName,Legacy alias for signals,bearer
```

Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted.

JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry `SmithersEvent` JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state.

Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`.
Expand Down Expand Up @@ -16507,10 +16509,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1
{
"iteration": 0,
"note": "Looks good",
"decidedBy": "alice"
"decidedBy": "alice",
"decision": { "selected": "balanced", "notes": "best fit" }
}
```

For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted.

### POST /deny/:nodeId

Deny a pending approval gate. Same body as `/approve/:nodeId`.
Expand Down
7 changes: 6 additions & 1 deletion docs/llms-observability.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ routes[15]{method,path,purpose,auth}:
POST,/signal/:runId/:signalName,Legacy alias for signals,bearer
```

Approval requests may include `iteration`, `note`, `decidedBy`, and `decision`. For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted.

JSON requests/responses use `Content-Type: application/json`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. SSE events are named `smithers` and carry [`SmithersEvent`](/runtime/events) JSON; the stream sends a keep-alive comment every 10 s and closes on terminal state.

Errors use the envelope `{ "error": { "code", "message", "details" } }`. Common codes: `INVALID_REQUEST`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `RUN_ID_REQUIRED`, `RUN_NOT_FOUND`, `RUN_ALREADY_EXISTS`, `RUN_NOT_ACTIVE`, `NOT_FOUND`, `UNAUTHORIZED`, `WORKFLOW_PATH_OUTSIDE_ROOT`, `DB_NOT_CONFIGURED`, `SERVER_ERROR`.
Expand Down Expand Up @@ -266,10 +268,13 @@ Approve a pending approval gate. All fields optional. Returns `{ "runId": "run-1
{
"iteration": 0,
"note": "Looks good",
"decidedBy": "alice"
"decidedBy": "alice",
"decision": { "selected": "balanced", "notes": "best fit" }
}
```

For `select` and `rank` approvals, `decision` must contain a valid selection or ranking. A stable decision envelope with `value` is also accepted.

### POST /deny/:nodeId

Deny a pending approval gate. Same body as `/approve/:nodeId`.
Expand Down
165 changes: 165 additions & 0 deletions packages/server/src/approvalDecision.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// @smithers-type-exports-begin
/**
* @typedef {{
* mode: "gate" | "select" | "rank" | "decision";
* title: string | null;
* summary: string | null;
* options: Array<{ key: string; label: string; summary?: string }>;
* allowedScopes: string[];
* allowedUsers: string[];
* restrictionError: string | null;
* autoApprove: Record<string, unknown> | null;
* }} ApprovalRequestRecord
*/
// @smithers-type-exports-end

/**
* @param {unknown} value
* @returns {Record<string, unknown> | null}
*/
function asObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value;
}

/**
* @param {unknown} value
* @returns {string | undefined}
*/
function asString(value) {
return typeof value === "string" ? value : undefined;
}

/**
* @param {unknown} value
* @returns {string[]}
*/
function parseStringArray(value) {
if (!Array.isArray(value)) {
return [];
}
return value.filter((entry) => typeof entry === "string");
}

/**
* @param {unknown} value
* @param {"allowedScopes" | "allowedUsers"} field
* @returns {{ values: string[]; error: string | null }}
*/
function parseApprovalRestriction(value, field) {
if (value === undefined) {
return { values: [], error: null };
}
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
return {
values: [],
error: `${field} must be an array of non-empty strings`,
};
}
return { values: value, error: null };
}

/**
* @param {unknown} value
* @param {string | null} fallbackTitle
* @returns {ApprovalRequestRecord}
*/
function parseApprovalRequest(value, fallbackTitle) {
const record = asObject(value);
const allowedScopes = parseApprovalRestriction(record?.allowedScopes, "allowedScopes");
const allowedUsers = parseApprovalRestriction(record?.allowedUsers, "allowedUsers");
const options = Array.isArray(record?.options)
? record.options
.filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)))
.map((entry) => ({
key: asString(entry.key) ?? "",
label: asString(entry.label) ?? "",
...(asString(entry.summary) ? { summary: asString(entry.summary) } : {}),
}))
.filter((entry) => entry.key.length > 0 && entry.label.length > 0)
: [];
const autoApprove =
record?.autoApprove && typeof record.autoApprove === "object" && !Array.isArray(record.autoApprove)
? record.autoApprove
: null;
return {
mode: record?.mode === "select" || record?.mode === "rank" || record?.mode === "decision" ? record.mode : "gate",
title: asString(record?.title) ?? fallbackTitle,
summary: asString(record?.summary) ?? null,
options,
allowedScopes: allowedScopes.values,
allowedUsers: allowedUsers.values,
restrictionError: allowedScopes.error ?? allowedUsers.error,
autoApprove,
};
}

/**
* @param {ApprovalRequestRecord} request
* @param {unknown} decision
*/
function validateApprovalDecision(request, decision) {
if (request.mode === "select") {
// Fail closed: a select request whose options were all malformed (dropped
// by parseApprovalRequest) must not accept an arbitrary selection.
if (request.options.length === 0) {
return { ok: false, code: "INVALID_REQUEST", message: "select approval request has no valid options" };
}
const payload = asObject(decision);
const selected = asString(payload?.selected);
if (!selected) {
return { ok: false, code: "INVALID_REQUEST", message: "select approvals require decision.selected" };
}
if (!request.options.some((option) => option.key === selected)) {
return { ok: false, code: "INVALID_REQUEST", message: `Unknown selection: ${selected}` };
}
}
if (request.mode === "rank") {
if (request.options.length === 0) {
return { ok: false, code: "INVALID_REQUEST", message: "rank approval request has no valid options" };
}
const payload = asObject(decision);
const rankedRaw = payload?.ranked;
// The original decision object is what gets persisted, so a mixed-type
// array must be rejected outright — sanitizing only the validation copy
// would persist a decisionJson that violates the string[] contract.
if (!Array.isArray(rankedRaw) || rankedRaw.some((entry) => typeof entry !== "string")) {
return { ok: false, code: "INVALID_REQUEST", message: "decision.ranked must be an array of strings" };
}
const ranked = parseStringArray(rankedRaw);
if (ranked.length === 0) {
return { ok: false, code: "INVALID_REQUEST", message: "rank approvals require decision.ranked" };
}
const allowed = new Set(request.options.map((option) => option.key));
if (ranked.some((value) => !allowed.has(value))) {
return { ok: false, code: "INVALID_REQUEST", message: "rank approval included unknown options" };
}
}
return { ok: true };
}

/**
* @param {unknown} value
* @param {unknown} explicitNote
*/
function normalizeDecision(value, explicitNote) {
const stableDecision = asObject(value);
return {
decision: stableDecision && "value" in stableDecision ? stableDecision.value : value,
note: asString(explicitNote) ?? asString(stableDecision?.note),
};
}

/**
* Shared approval request parsing and decision validation for all transports.
*/
export const approvalDecision = {
parseApprovalRequest,
validateApprovalDecision,
normalizeDecision,
unwrapDecision(value) {
return normalizeDecision(value, undefined).decision;
},
};
97 changes: 3 additions & 94 deletions packages/server/src/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
getRequiredScopeForGatewayMethod,
} from "@smithers-orchestrator/gateway/rpc";
import { hasGatewayScope, isGatewayScope } from "@smithers-orchestrator/gateway/auth/scopes";
import { approvalDecision } from "./approvalDecision.js";
import {
apiCollectionNames,
serializeAccountRow,
Expand Down Expand Up @@ -1822,98 +1823,7 @@ function parseJwtScopes(value) {
}
return parseStringArray(value);
}
/**
* Normalized approval request stored in an approval row's requestJson.
* @typedef {{
* mode: "gate" | "select" | "rank" | "decision";
* title: string | null;
* summary: string | null;
* options: Array<{ key: string; label: string; summary?: string }>;
* allowedScopes: string[];
* allowedUsers: string[];
* restrictionError: string | null;
* autoApprove: Record<string, unknown> | null;
* }} ApprovalRequestRecord
*/
/**
* @param {unknown} value
* @param {"allowedScopes" | "allowedUsers"} field
* @returns {{ values: string[]; error: string | null }}
*/
function parseApprovalRestriction(value, field) {
if (value === undefined) {
return { values: [], error: null };
}
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
return {
values: [],
error: `${field} must be an array of non-empty strings`,
};
}
return { values: value, error: null };
}
/**
* @param {unknown} value
* @param {string | null} fallbackTitle
* @returns {ApprovalRequestRecord}
*/
function parseApprovalRequest(value, fallbackTitle) {
const record = asObject(value);
const allowedScopes = parseApprovalRestriction(record?.allowedScopes, "allowedScopes");
const allowedUsers = parseApprovalRestriction(record?.allowedUsers, "allowedUsers");
const options = Array.isArray(record?.options)
? record.options
.filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)))
.map((entry) => ({
key: asString(entry.key) ?? "",
label: asString(entry.label) ?? "",
...(asString(entry.summary) ? { summary: asString(entry.summary) } : {}),
}))
.filter((entry) => entry.key.length > 0 && entry.label.length > 0)
: [];
const autoApprove =
record?.autoApprove && typeof record.autoApprove === "object" && !Array.isArray(record.autoApprove)
? record.autoApprove
: null;
return {
mode: record?.mode === "select" || record?.mode === "rank" || record?.mode === "decision" ? record.mode : "gate",
title: asString(record?.title) ?? fallbackTitle,
summary: asString(record?.summary) ?? null,
options,
allowedScopes: allowedScopes.values,
allowedUsers: allowedUsers.values,
restrictionError: allowedScopes.error ?? allowedUsers.error,
autoApprove,
};
}
/**
* @param {ApprovalRequestRecord} request
* @param {unknown} decision
*/
function validateApprovalDecision(request, decision) {
if (request.mode === "select") {
const payload = asObject(decision);
const selected = asString(payload?.selected);
if (!selected) {
return { ok: false, code: "INVALID_REQUEST", message: "select approvals require decision.selected" };
}
if (request.options.length > 0 && !request.options.some((option) => option.key === selected)) {
return { ok: false, code: "INVALID_REQUEST", message: `Unknown selection: ${selected}` };
}
}
if (request.mode === "rank") {
const payload = asObject(decision);
const ranked = parseStringArray(payload?.ranked);
if (ranked.length === 0) {
return { ok: false, code: "INVALID_REQUEST", message: "rank approvals require decision.ranked" };
}
const allowed = new Set(request.options.map((option) => option.key));
if (allowed.size > 0 && ranked.some((value) => !allowed.has(value))) {
return { ok: false, code: "INVALID_REQUEST", message: "rank approval included unknown options" };
}
}
return { ok: true };
}
const { parseApprovalRequest, validateApprovalDecision, normalizeDecision } = approvalDecision;
/**
* @param {string} pattern
*/
Expand Down Expand Up @@ -9904,8 +9814,7 @@ a { color: var(--brand); }</style>
) {
return responseError(frame.id, "FORBIDDEN", "Connection is missing required approval scope");
}
const decision = stableDecision && "value" in stableDecision ? stableDecision.value : params.decision;
const note = asString(params.note) ?? asString(stableDecision?.note);
const { decision, note } = normalizeDecision(params.decision, params.note);
if (approved) {
const validation = validateApprovalDecision(request, decision);
if (!validation.ok) {
Expand Down
Loading
Loading