fix: harden connect auth flow and exec policy diagnostics

This commit is contained in:
Peter Steinberger
2026-02-22 20:20:11 +01:00
parent 7e83e7b3a7
commit bbdfba5694
19 changed files with 797 additions and 145 deletions

View File

@@ -0,0 +1,66 @@
export const ConnectErrorDetailCodes = {
AUTH_REQUIRED: "AUTH_REQUIRED",
AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
AUTH_TOKEN_MISSING: "AUTH_TOKEN_MISSING",
AUTH_TOKEN_MISMATCH: "AUTH_TOKEN_MISMATCH",
AUTH_TOKEN_NOT_CONFIGURED: "AUTH_TOKEN_NOT_CONFIGURED",
AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING",
AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH",
AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED",
AUTH_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH",
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
AUTH_TAILSCALE_PROXY_MISSING: "AUTH_TAILSCALE_PROXY_MISSING",
AUTH_TAILSCALE_WHOIS_FAILED: "AUTH_TAILSCALE_WHOIS_FAILED",
AUTH_TAILSCALE_IDENTITY_MISMATCH: "AUTH_TAILSCALE_IDENTITY_MISMATCH",
CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED",
DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID",
PAIRING_REQUIRED: "PAIRING_REQUIRED",
} as const;
export type ConnectErrorDetailCode =
(typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes];
export function resolveAuthConnectErrorDetailCode(
reason: string | undefined,
): ConnectErrorDetailCode {
switch (reason) {
case "token_missing":
return ConnectErrorDetailCodes.AUTH_TOKEN_MISSING;
case "token_mismatch":
return ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
case "token_missing_config":
return ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED;
case "password_missing":
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING;
case "password_mismatch":
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH;
case "password_missing_config":
return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED;
case "tailscale_user_missing":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING;
case "tailscale_proxy_missing":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING;
case "tailscale_whois_failed":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED;
case "tailscale_user_mismatch":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH;
case "rate_limited":
return ConnectErrorDetailCodes.AUTH_RATE_LIMITED;
case "device_token_mismatch":
return ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH;
case undefined:
return ConnectErrorDetailCodes.AUTH_REQUIRED;
default:
return ConnectErrorDetailCodes.AUTH_UNAUTHORIZED;
}
}
export function readConnectErrorDetailCode(details: unknown): string | null {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return null;
}
const code = (details as { code?: unknown }).code;
return typeof code === "string" && code.trim().length > 0 ? code : null;
}

View File

@@ -3,6 +3,7 @@ import { WebSocket } from "ws";
import { withEnvAsync } from "../test-utils/env.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import { ConnectErrorDetailCodes } from "./protocol/connect-error-details.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
import {
@@ -716,6 +717,9 @@ describe("gateway server auth/connect", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("secure context");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
);
ws.close();
});
});
@@ -898,6 +902,9 @@ describe("gateway server auth/connect", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("pairing required");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.PAIRING_REQUIRED,
);
ws.close();
});
} finally {
@@ -1004,6 +1011,9 @@ describe("gateway server auth/connect", () => {
expect(res2.ok).toBe(false);
expect(res2.error?.message ?? "").toContain("gateway token mismatch");
expect(res2.error?.message ?? "").not.toContain("device token mismatch");
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
);
ws2.close();
await server.close();
@@ -1023,6 +1033,9 @@ describe("gateway server auth/connect", () => {
});
expect(res2.ok).toBe(false);
expect(res2.error?.message ?? "").toContain("device token mismatch");
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
);
ws2.close();
await server.close();

View File

@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
limiter: AuthRateLimiter;
reset: ReturnType<typeof vi.fn>;
} {
const allowed = params?.allowed ?? true;
const retryAfterMs = params?.retryAfterMs ?? 5_000;
const check = vi.fn(() => ({ allowed, retryAfterMs }));
const reset = vi.fn();
const recordFailure = vi.fn();
return {
limiter: {
check,
reset,
recordFailure,
} as unknown as AuthRateLimiter,
reset,
};
}
function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthState {
return {
authResult: { ok: false, reason: "token_mismatch" },
authOk: false,
authMethod: "token",
sharedAuthOk: false,
sharedAuthProvided: true,
deviceTokenCandidate: "device-token",
deviceTokenCandidateSource: "shared-token-fallback",
...overrides,
};
}
describe("resolveConnectAuthDecision", () => {
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("token_mismatch");
expect(verifyDeviceToken).toHaveBeenCalledOnce();
});
it("reports explicit device-token mismatches as device_token_mismatch", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
deviceTokenCandidateSource: "explicit-device-token",
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("device_token_mismatch");
});
it("accepts valid device tokens and marks auth method as device-token", async () => {
const rateLimiter = createRateLimiter();
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
rateLimiter: rateLimiter.limiter,
clientIp: "203.0.113.20",
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("device-token");
expect(verifyDeviceToken).toHaveBeenCalledOnce();
expect(rateLimiter.reset).toHaveBeenCalledOnce();
});
it("returns rate-limited auth result without verifying device token", async () => {
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
rateLimiter: rateLimiter.limiter,
clientIp: "203.0.113.20",
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("rate_limited");
expect(decision.authResult.retryAfterMs).toBe(60_000);
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("returns the original decision when device fallback does not apply", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
authResult: { ok: true, method: "token" },
authOk: true,
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: [],
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("token");
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage } from "node:http";
import {
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
type AuthRateLimiter,
type RateLimitCheckResult,
@@ -29,6 +30,14 @@ export type ConnectAuthState = {
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
};
type VerifyDeviceTokenResult = { ok: boolean };
export type ConnectAuthDecision = {
authResult: GatewayAuthResult;
authOk: boolean;
authMethod: GatewayAuthResult["method"];
};
function trimToUndefined(value: string | undefined): string | undefined {
if (!value) {
return undefined;
@@ -139,3 +148,67 @@ export async function resolveConnectAuthState(params: {
deviceTokenCandidateSource,
};
}
export async function resolveConnectAuthDecision(params: {
state: ConnectAuthState;
hasDeviceIdentity: boolean;
deviceId?: string;
role: string;
scopes: string[];
rateLimiter?: AuthRateLimiter;
clientIp?: string;
verifyDeviceToken: (params: {
deviceId: string;
token: string;
role: string;
scopes: string[];
}) => Promise<VerifyDeviceTokenResult>;
}): Promise<ConnectAuthDecision> {
let authResult = params.state.authResult;
let authOk = params.state.authOk;
let authMethod = params.state.authMethod;
const deviceTokenCandidate = params.state.deviceTokenCandidate;
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
return { authResult, authOk, authMethod };
}
if (params.rateLimiter) {
const deviceRateCheck = params.rateLimiter.check(
params.clientIp,
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
);
if (!deviceRateCheck.allowed) {
authResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs,
};
}
}
if (!authResult.rateLimited) {
const tokenCheck = await params.verifyDeviceToken({
deviceId: params.deviceId,
token: deviceTokenCandidate,
role: params.role,
scopes: params.scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
} else {
authResult = {
ok: false,
reason:
params.state.deviceTokenCandidateSource === "explicit-device-token"
? "device_token_mismatch"
: (authResult.reason ?? "device_token_mismatch"),
};
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}
return { authResult, authOk, authMethod };
}

View File

@@ -24,7 +24,7 @@ import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../../../version.js";
import { AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, type AuthRateLimiter } from "../../auth-rate-limit.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
import { isLocalDirectRequest } from "../../auth.js";
import {
@@ -42,6 +42,10 @@ import {
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import {
ConnectErrorDetailCodes,
resolveAuthConnectErrorDetailCode,
} from "../../protocol/connect-error-details.js";
import {
type ConnectParams,
ErrorCodes,
@@ -67,7 +71,7 @@ import {
refreshGatewayHealthSnapshot,
} from "../health-state.js";
import type { GatewayWsClient } from "../ws-types.js";
import { resolveConnectAuthState } from "./auth-context.js";
import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js";
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
import {
evaluateMissingDeviceIdentity,
@@ -401,7 +405,12 @@ export function attachGatewayWsMessageHandler(params: {
reason: failedAuth.reason,
client: connectParams.client,
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage, {
details: {
code: resolveAuthConnectErrorDetailCode(failedAuth.reason),
authReason: failedAuth.reason,
},
});
close(1008, truncateCloseReason(authMessage));
};
const clearUnboundScopes = () => {
@@ -434,7 +443,9 @@ export function attachGatewayWsMessageHandler(params: {
markHandshakeFailure("control-ui-insecure-auth", {
insecureAuthConfigured: controlUiAuthPolicy.allowInsecureAuthConfigured,
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, {
details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED },
});
close(1008, errorMessage);
return false;
}
@@ -445,7 +456,9 @@ export function attachGatewayWsMessageHandler(params: {
}
markHandshakeFailure("device-required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required", {
details: { code: ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED },
});
close(1008, "device identity required");
return false;
};
@@ -464,7 +477,12 @@ export function attachGatewayWsMessageHandler(params: {
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, message),
error: errorShape(ErrorCodes.INVALID_REQUEST, message, {
details: {
code: ConnectErrorDetailCodes.DEVICE_AUTH_INVALID,
reason,
},
}),
});
close(1008, message);
};
@@ -514,39 +532,24 @@ export function attachGatewayWsMessageHandler(params: {
}
}
if (!authOk && device && deviceTokenCandidate) {
if (rateLimiter) {
const deviceRateCheck = rateLimiter.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
if (!deviceRateCheck.allowed) {
authResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs,
};
}
}
if (!authResult.rateLimited) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
token: deviceTokenCandidate,
role,
scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
} else {
const mismatchReason =
deviceTokenCandidateSource === "explicit-device-token"
? "device_token_mismatch"
: (authResult.reason ?? "device_token_mismatch");
authResult = { ok: false, reason: mismatchReason };
rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}
}
({ authResult, authOk, authMethod } = await resolveConnectAuthDecision({
state: {
authResult,
authOk,
authMethod,
sharedAuthOk,
sharedAuthProvided: hasSharedAuth,
deviceTokenCandidate,
deviceTokenCandidateSource,
},
hasDeviceIdentity: Boolean(device),
deviceId: device?.id,
role,
scopes,
rateLimiter,
clientIp,
verifyDeviceToken,
}));
if (!authOk) {
rejectUnauthorized(authResult);
return;
@@ -636,7 +639,11 @@ export function attachGatewayWsMessageHandler(params: {
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", {
details: { requestId: pairing.request.requestId },
details: {
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
requestId: pairing.request.requestId,
reason,
},
}),
});
close(1008, "pairing required");

View File

@@ -409,7 +409,7 @@ type ConnectResponse = {
id: string;
ok: boolean;
payload?: Record<string, unknown>;
error?: { message?: string };
error?: { message?: string; code?: string; details?: unknown };
};
export async function readConnectChallengeNonce(

View File

@@ -57,20 +57,31 @@ export type NpmSpecArchiveFinalInstallResult<TResult extends { ok: boolean }> =
integrityDrift?: NpmIntegrityDrift;
});
function isSuccessfulInstallResult<TResult extends { ok: boolean }>(
result: TResult,
): result is Extract<TResult, { ok: true }> {
return result.ok;
}
export function finalizeNpmSpecArchiveInstall<TResult extends { ok: boolean }>(
flowResult: NpmSpecArchiveInstallFlowResult<TResult>,
): NpmSpecArchiveFinalInstallResult<TResult> {
if (!flowResult.ok) {
return flowResult;
}
if (!flowResult.installResult.ok) {
return flowResult.installResult;
const installResult = flowResult.installResult;
if (!isSuccessfulInstallResult(installResult)) {
return installResult as Exclude<TResult, { ok: true }>;
}
return {
...flowResult.installResult,
const finalized: Extract<TResult, { ok: true }> & {
npmResolution: NpmSpecResolution;
integrityDrift?: NpmIntegrityDrift;
} = {
...installResult,
npmResolution: flowResult.npmResolution,
integrityDrift: flowResult.integrityDrift,
...(flowResult.integrityDrift ? { integrityDrift: flowResult.integrityDrift } : {}),
};
return finalized;
}
export async function installFromNpmSpecArchive<TResult extends { ok: boolean }>(params: {

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
evaluateSystemRunPolicy,
formatSystemRunAllowlistMissMessage,
resolveExecApprovalDecision,
} from "./exec-policy.js";
describe("resolveExecApprovalDecision", () => {
it("accepts known approval decisions", () => {
expect(resolveExecApprovalDecision("allow-once")).toBe("allow-once");
expect(resolveExecApprovalDecision("allow-always")).toBe("allow-always");
});
it("normalizes unknown approval decisions to null", () => {
expect(resolveExecApprovalDecision("deny")).toBeNull();
expect(resolveExecApprovalDecision(undefined)).toBeNull();
});
});
describe("formatSystemRunAllowlistMissMessage", () => {
it("returns legacy allowlist miss message by default", () => {
expect(formatSystemRunAllowlistMissMessage()).toBe("SYSTEM_RUN_DENIED: allowlist miss");
});
it("adds Windows shell-wrapper guidance when blocked by cmd.exe policy", () => {
expect(
formatSystemRunAllowlistMissMessage({
windowsShellWrapperBlocked: true,
}),
).toContain("Windows shell wrappers like cmd.exe /c require approval");
});
});
describe("evaluateSystemRunPolicy", () => {
it("denies when security mode is deny", () => {
const decision = evaluateSystemRunPolicy({
security: "deny",
ask: "off",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("security=deny");
expect(decision.errorMessage).toBe("SYSTEM_RUN_DISABLED: security=deny");
});
it("requires approval when ask policy requires it", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "always",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("approval-required");
expect(decision.requiresAsk).toBe(true);
});
it("allows allowlist miss when explicit approval is provided", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "on-miss",
analysisOk: false,
allowlistSatisfied: false,
approvalDecision: "allow-once",
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(true);
if (!decision.allowed) {
throw new Error("expected allowed decision");
}
expect(decision.approvedByAsk).toBe(true);
});
it("denies allowlist misses without approval", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "off",
analysisOk: false,
allowlistSatisfied: false,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("allowlist-miss");
expect(decision.errorMessage).toBe("SYSTEM_RUN_DENIED: allowlist miss");
});
it("treats Windows cmd.exe wrappers as allowlist misses", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "off",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: true,
cmdInvocation: true,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.windowsShellWrapperBlocked).toBe(true);
expect(decision.errorMessage).toContain("Windows shell wrappers like cmd.exe /c");
});
it("allows execution when policy checks pass", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "on-miss",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(true);
if (!decision.allowed) {
throw new Error("expected allowed decision");
}
expect(decision.requiresAsk).toBe(false);
expect(decision.analysisOk).toBe(true);
expect(decision.allowlistSatisfied).toBe(true);
});
});

View File

@@ -0,0 +1,116 @@
import { requiresExecApproval, type ExecAsk, type ExecSecurity } from "../infra/exec-approvals.js";
export type ExecApprovalDecision = "allow-once" | "allow-always" | null;
export type SystemRunPolicyDecision = {
analysisOk: boolean;
allowlistSatisfied: boolean;
windowsShellWrapperBlocked: boolean;
requiresAsk: boolean;
approvalDecision: ExecApprovalDecision;
approvedByAsk: boolean;
} & (
| {
allowed: true;
}
| {
allowed: false;
eventReason: "security=deny" | "approval-required" | "allowlist-miss";
errorMessage: string;
}
);
export function resolveExecApprovalDecision(value: unknown): ExecApprovalDecision {
if (value === "allow-once" || value === "allow-always") {
return value;
}
return null;
}
export function formatSystemRunAllowlistMissMessage(params?: {
windowsShellWrapperBlocked?: boolean;
}): string {
if (params?.windowsShellWrapperBlocked) {
return (
"SYSTEM_RUN_DENIED: allowlist miss " +
"(Windows shell wrappers like cmd.exe /c require approval; " +
"approve once/always or run with --ask on-miss|always)"
);
}
return "SYSTEM_RUN_DENIED: allowlist miss";
}
export function evaluateSystemRunPolicy(params: {
security: ExecSecurity;
ask: ExecAsk;
analysisOk: boolean;
allowlistSatisfied: boolean;
approvalDecision: ExecApprovalDecision;
approved?: boolean;
isWindows: boolean;
cmdInvocation: boolean;
}): SystemRunPolicyDecision {
const windowsShellWrapperBlocked =
params.security === "allowlist" && params.isWindows && params.cmdInvocation;
const analysisOk = windowsShellWrapperBlocked ? false : params.analysisOk;
const allowlistSatisfied = windowsShellWrapperBlocked ? false : params.allowlistSatisfied;
const approvedByAsk = params.approvalDecision !== null || params.approved === true;
if (params.security === "deny") {
return {
allowed: false,
eventReason: "security=deny",
errorMessage: "SYSTEM_RUN_DISABLED: security=deny",
analysisOk,
allowlistSatisfied,
windowsShellWrapperBlocked,
requiresAsk: false,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}
const requiresAsk = requiresExecApproval({
ask: params.ask,
security: params.security,
analysisOk,
allowlistSatisfied,
});
if (requiresAsk && !approvedByAsk) {
return {
allowed: false,
eventReason: "approval-required",
errorMessage: "SYSTEM_RUN_DENIED: approval required",
analysisOk,
allowlistSatisfied,
windowsShellWrapperBlocked,
requiresAsk,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}
if (params.security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
return {
allowed: false,
eventReason: "allowlist-miss",
errorMessage: formatSystemRunAllowlistMissMessage({ windowsShellWrapperBlocked }),
analysisOk,
allowlistSatisfied,
windowsShellWrapperBlocked,
requiresAsk,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}
return {
allowed: true,
analysisOk,
allowlistSatisfied,
windowsShellWrapperBlocked,
requiresAsk,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}

View File

@@ -8,7 +8,6 @@ import {
evaluateExecAllowlist,
evaluateShellAllowlist,
recordAllowlistUse,
requiresExecApproval,
resolveAllowAlwaysPatterns,
resolveExecApprovals,
type ExecAllowlistEntry,
@@ -20,6 +19,7 @@ import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../in
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js";
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
import type {
ExecEventPayload,
RunResult,
@@ -32,19 +32,7 @@ type SystemRunInvokeResult = {
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
};
export function formatSystemRunAllowlistMissMessage(params?: {
windowsShellWrapperBlocked?: boolean;
}): string {
if (params?.windowsShellWrapperBlocked) {
return (
"SYSTEM_RUN_DENIED: allowlist miss " +
"(Windows shell wrappers like cmd.exe /c require approval; " +
"approve once/always or run with --ask on-miss|always)"
);
}
return "SYSTEM_RUN_DENIED: allowlist miss";
}
export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js";
export async function handleSystemRunInvoke(opts: {
client: GatewayClient;
@@ -122,6 +110,7 @@ export async function handleSystemRunInvoke(opts: {
const autoAllowSkills = approvals.agent.autoAllowSkills;
const sessionKey = opts.params.sessionKey?.trim() || "node";
const runId = opts.params.runId?.trim() || crypto.randomUUID();
const approvalDecision = resolveExecApprovalDecision(opts.params.approvalDecision);
const envOverrides = sanitizeSystemRunEnvOverrides({
overrides: opts.params.env ?? undefined,
shellWrapper: shellCommand !== null,
@@ -176,19 +165,9 @@ export async function handleSystemRunInvoke(opts: {
const cmdInvocation = shellCommand
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
: opts.isCmdExeInvocation(argv);
const windowsShellWrapperBlocked = security === "allowlist" && isWindows && cmdInvocation;
if (windowsShellWrapperBlocked) {
analysisOk = false;
allowlistSatisfied = false;
}
const useMacAppExec = process.platform === "darwin";
if (useMacAppExec) {
const approvalDecision =
opts.params.approvalDecision === "allow-once" ||
opts.params.approvalDecision === "allow-always"
? opts.params.approvalDecision
: null;
const execRequest: ExecHostRequest = {
command: argv,
rawCommand: rawCommand || shellCommand || null,
@@ -252,38 +231,19 @@ export async function handleSystemRunInvoke(opts: {
}
}
if (security === "deny") {
await opts.sendNodeEvent(
opts.client,
"exec.denied",
opts.buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "security=deny",
}),
);
await opts.sendInvokeResult({
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
});
return;
}
const requiresAsk = requiresExecApproval({
ask,
const policy = evaluateSystemRunPolicy({
security,
ask,
analysisOk,
allowlistSatisfied,
approvalDecision,
approved: opts.params.approved === true,
isWindows,
cmdInvocation,
});
const approvalDecision =
opts.params.approvalDecision === "allow-once" || opts.params.approvalDecision === "allow-always"
? opts.params.approvalDecision
: null;
const approvedByAsk = approvalDecision !== null || opts.params.approved === true;
if (requiresAsk && !approvedByAsk) {
analysisOk = policy.analysisOk;
allowlistSatisfied = policy.allowlistSatisfied;
if (!policy.allowed) {
await opts.sendNodeEvent(
opts.client,
"exec.denied",
@@ -292,17 +252,18 @@ export async function handleSystemRunInvoke(opts: {
runId,
host: "node",
command: cmdText,
reason: "approval-required",
reason: policy.eventReason,
}),
);
await opts.sendInvokeResult({
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
error: { code: "UNAVAILABLE", message: policy.errorMessage },
});
return;
}
if (approvalDecision === "allow-always" && security === "allowlist") {
if (analysisOk) {
if (policy.approvalDecision === "allow-always" && security === "allowlist") {
if (policy.analysisOk) {
const patterns = resolveAllowAlwaysPatterns({
segments,
cwd: opts.params.cwd ?? undefined,
@@ -317,28 +278,6 @@ export async function handleSystemRunInvoke(opts: {
}
}
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
await opts.sendNodeEvent(
opts.client,
"exec.denied",
opts.buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "allowlist-miss",
}),
);
await opts.sendInvokeResult({
ok: false,
error: {
code: "UNAVAILABLE",
message: formatSystemRunAllowlistMissMessage({ windowsShellWrapperBlocked }),
},
});
return;
}
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
@@ -379,10 +318,10 @@ export async function handleSystemRunInvoke(opts: {
if (
security === "allowlist" &&
isWindows &&
!approvedByAsk &&
!policy.approvedByAsk &&
shellCommand &&
analysisOk &&
allowlistSatisfied &&
policy.analysisOk &&
policy.allowlistSatisfied &&
segments.length === 1 &&
segments[0]?.argv.length > 0
) {