fix(gateway): require admin for persisted verbose defaults (#55916)

* fix(gateway): require admin for verbose persistence

* gateway: tighten verbose persistence follow-ups
This commit is contained in:
Jacob Tomlinson
2026-03-27 12:04:02 -07:00
committed by GitHub
parent 55cd272fe1
commit c603123528
5 changed files with 162 additions and 9 deletions

View File

@@ -20,10 +20,13 @@ import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js";
import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js";
import {
canPersistInternalExecDirective,
canPersistInternalVerboseDirective,
formatDirectiveAck,
formatElevatedRuntimeHint,
formatElevatedUnavailableText,
formatInternalExecPersistenceDeniedText,
formatInternalVerboseCurrentReplyOnlyText,
formatInternalVerbosePersistenceDeniedText,
enqueueModeSwitchEvents,
withOptions,
} from "./directive-handling.shared.js";
@@ -99,6 +102,10 @@ export async function handleDirectiveOnly(
surface: params.surface,
gatewayClientScopes: params.gatewayClientScopes,
});
const allowInternalVerbosePersistence = canPersistInternalVerboseDirective({
surface: params.surface,
gatewayClientScopes: params.gatewayClientScopes,
});
const modelInfo = await maybeHandleModelDirectiveInfo({
directives,
@@ -319,7 +326,9 @@ export async function handleDirectiveOnly(
const shouldPersistSessionEntry =
(directives.hasThinkDirective && Boolean(directives.thinkLevel)) ||
(directives.hasFastDirective && directives.fastMode !== undefined) ||
(directives.hasVerboseDirective && Boolean(directives.verboseLevel)) ||
(directives.hasVerboseDirective &&
Boolean(directives.verboseLevel) &&
allowInternalVerbosePersistence) ||
(directives.hasReasoningDirective && Boolean(directives.reasoningLevel)) ||
(directives.hasElevatedDirective && Boolean(directives.elevatedLevel)) ||
(directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) ||
@@ -342,7 +351,11 @@ export async function handleDirectiveOnly(
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
if (
directives.hasVerboseDirective &&
directives.verboseLevel &&
allowInternalVerbosePersistence
) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
@@ -457,13 +470,22 @@ export async function handleDirectiveOnly(
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
parts.push(
directives.verboseLevel === "off"
? formatDirectiveAck("Verbose logging disabled.")
: directives.verboseLevel === "full"
? formatDirectiveAck("Verbose logging set to full.")
: formatDirectiveAck("Verbose logging enabled."),
!allowInternalVerbosePersistence
? formatDirectiveAck(formatInternalVerboseCurrentReplyOnlyText())
: directives.verboseLevel === "off"
? formatDirectiveAck("Verbose logging disabled.")
: directives.verboseLevel === "full"
? formatDirectiveAck("Verbose logging set to full.")
: formatDirectiveAck("Verbose logging enabled."),
);
}
if (
directives.hasVerboseDirective &&
directives.verboseLevel &&
!allowInternalVerbosePersistence
) {
parts.push(formatDirectiveAck(formatInternalVerbosePersistenceDeniedText()));
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
parts.push(
directives.reasoningLevel === "off"

View File

@@ -583,6 +583,43 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(sessionEntry.execNode).toBeUndefined();
});
it("blocks internal operator.write verbose persistence in directive-only handling", async () => {
const directives = parseInlineDirectives("/verbose full");
const sessionEntry = createSessionEntry();
const sessionStore = { [sessionKey]: sessionEntry };
const result = await handleDirectiveOnly(
createHandleParams({
directives,
sessionEntry,
sessionStore,
surface: "webchat",
gatewayClientScopes: ["operator.write"],
}),
);
expect(result?.text).toContain("Verbose logging set for the current reply only.");
expect(result?.text).toContain("operator.admin");
expect(sessionEntry.verboseLevel).toBeUndefined();
});
it("allows internal operator.admin verbose persistence in directive-only handling", async () => {
const directives = parseInlineDirectives("/verbose full");
const sessionEntry = createSessionEntry();
const sessionStore = { [sessionKey]: sessionEntry };
const result = await handleDirectiveOnly(
createHandleParams({
directives,
sessionEntry,
sessionStore,
surface: "webchat",
gatewayClientScopes: ["operator.admin"],
}),
);
expect(result?.text).toContain("Verbose logging set to full.");
expect(sessionEntry.verboseLevel).toBe("full");
});
it("allows internal operator.admin exec persistence in directive-only handling", async () => {
const directives = parseInlineDirectives(
"/exec host=node security=allowlist ask=always node=worker-1",
@@ -646,4 +683,38 @@ describe("persistInlineDirectives internal exec scope gate", () => {
expect(sessionEntry.execAsk).toBeUndefined();
expect(sessionEntry.execNode).toBeUndefined();
});
it("skips verbose persistence for internal operator.write callers", async () => {
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
const directives = parseInlineDirectives("/verbose full");
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
} as SessionEntry;
const sessionStore = { "agent:main:main": sessionEntry };
await persistInlineDirectives({
directives,
cfg: baseConfig(),
sessionEntry,
sessionStore,
sessionKey: "agent:main:main",
storePath: "/tmp/sessions.json",
elevatedEnabled: true,
elevatedAllowed: true,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
agentCfg: undefined,
surface: "webchat",
gatewayClientScopes: ["operator.write"],
});
expect(sessionEntry.verboseLevel).toBeUndefined();
});
});

View File

@@ -16,6 +16,7 @@ import { resolveModelSelectionFromDirective } from "./directive-handling.model-s
import type { InlineDirectives } from "./directive-handling.parse.js";
import {
canPersistInternalExecDirective,
canPersistInternalVerboseDirective,
enqueueModeSwitchEvents,
} from "./directive-handling.shared.js";
import type { ElevatedLevel, ReasoningLevel } from "./directives.js";
@@ -65,6 +66,10 @@ export async function persistInlineDirectives(params: {
surface: params.surface,
gatewayClientScopes: params.gatewayClientScopes,
});
const allowInternalVerbosePersistence = canPersistInternalVerboseDirective({
surface: params.surface,
gatewayClientScopes: params.gatewayClientScopes,
});
const activeAgentId = sessionKey
? resolveSessionAgentId({ sessionKey, config: cfg })
: resolveDefaultAgentId(cfg);
@@ -89,7 +94,11 @@ export async function persistInlineDirectives(params: {
sessionEntry.thinkingLevel = directives.thinkLevel;
updated = true;
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
if (
directives.hasVerboseDirective &&
directives.verboseLevel &&
allowInternalVerbosePersistence
) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
updated = true;
}

View File

@@ -17,7 +17,13 @@ export const formatElevatedRuntimeHint = () =>
export const formatInternalExecPersistenceDeniedText = () =>
"Exec defaults require operator.admin for internal gateway callers; skipped persistence.";
export function canPersistInternalExecDirective(params: {
export const formatInternalVerbosePersistenceDeniedText = () =>
"Verbose defaults require operator.admin for internal gateway callers; skipped persistence.";
export const formatInternalVerboseCurrentReplyOnlyText = () =>
"Verbose logging set for the current reply only.";
function canPersistInternalDirective(params: {
surface?: string;
gatewayClientScopes?: string[];
}): boolean {
@@ -28,6 +34,9 @@ export function canPersistInternalExecDirective(params: {
return scopes.includes("operator.admin");
}
export const canPersistInternalExecDirective = canPersistInternalDirective;
export const canPersistInternalVerboseDirective = canPersistInternalDirective;
export const formatElevatedEvent = (level: ElevatedLevel) => {
if (level === "full") {
return "Elevated FULL — exec runs on host with auto-approval.";

View File

@@ -14,6 +14,7 @@ import {
rpcReq,
testState,
trackConnectChallengeNonce,
withGatewayServer,
writeSessionStore,
} from "./test-helpers.js";
import { agentCommand } from "./test-helpers.mocks.js";
@@ -684,6 +685,47 @@ describe("gateway server chat", () => {
]);
});
test("chat.send does not persist verboseLevel for operator.write callers", async () => {
await withGatewayServer(async ({ port }) => {
await withMainSessionStore(async () => {
let scopedWs: WebSocket | undefined;
try {
scopedWs = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(scopedWs);
await new Promise<void>((resolve) => scopedWs?.once("open", resolve));
await connectOk(scopedWs, {
scopes: ["operator.write"],
});
const sendRes = await rpcReq(scopedWs, "chat.send", {
sessionKey: "main",
message: "/verbose full",
idempotencyKey: "idem-write-scope-verbose-no-persist",
});
expect(sendRes.ok).toBe(true);
const waitRes = await rpcReq(scopedWs, "agent.wait", {
runId: "idem-write-scope-verbose-no-persist",
timeoutMs: 1_000,
});
expect(waitRes.ok).toBe(true);
expect(waitRes.payload?.status).toBe("ok");
const raw = await fs.readFile(testState.sessionStorePath!, "utf-8");
const stored = JSON.parse(raw) as {
"agent:main:main"?: {
verboseLevel?: string;
};
};
expect(stored["agent:main:main"]?.verboseLevel).toBeUndefined();
} finally {
scopedWs?.close();
}
});
});
});
test("agent.wait resolves chat.send runs that finish without lifecycle events", async () => {
await withMainSessionStore(async () => {
const runId = "idem-wait-chat-1";