From fac040cb104b5a3886e84c4a54173122e4d1ce9f Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 21:14:55 -0800 Subject: [PATCH] fix (gateway): redact sensitive status details for non-admin scopes --- src/commands/status.summary.redaction.test.ts | 69 +++++++++++++++++++ src/commands/status.summary.ts | 28 +++++++- .../health.status-scope.test.ts | 31 +++++++++ src/gateway/server-methods/health.ts | 9 ++- 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/commands/status.summary.redaction.test.ts create mode 100644 src/gateway/server-methods/health.status-scope.test.ts diff --git a/src/commands/status.summary.redaction.test.ts b/src/commands/status.summary.redaction.test.ts new file mode 100644 index 00000000000..ecb99ae6ecb --- /dev/null +++ b/src/commands/status.summary.redaction.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { StatusSummary } from "./status.types.js"; +import { redactSensitiveStatusSummary } from "./status.summary.js"; + +describe("redactSensitiveStatusSummary", () => { + it("removes sensitive session and path details while preserving summary structure", () => { + const input: StatusSummary = { + heartbeat: { + defaultAgentId: "main", + agents: [{ agentId: "main", enabled: true, every: "5m", everyMs: 300_000 }], + }, + channelSummary: ["ok"], + queuedSystemEvents: ["none"], + sessions: { + paths: ["/tmp/openclaw/sessions.json"], + count: 1, + defaults: { model: "gpt-5", contextTokens: 200_000 }, + recent: [ + { + key: "main", + kind: "direct", + sessionId: "sess-1", + updatedAt: 1, + age: 2, + totalTokens: 3, + totalTokensFresh: true, + remainingTokens: 4, + percentUsed: 5, + model: "gpt-5", + contextTokens: 200_000, + flags: ["id:sess-1"], + }, + ], + byAgent: [ + { + agentId: "main", + path: "/tmp/openclaw/main-sessions.json", + count: 1, + recent: [ + { + key: "main", + kind: "direct", + sessionId: "sess-1", + updatedAt: 1, + age: 2, + totalTokens: 3, + totalTokensFresh: true, + remainingTokens: 4, + percentUsed: 5, + model: "gpt-5", + contextTokens: 200_000, + flags: ["id:sess-1"], + }, + ], + }, + ], + }, + }; + + const redacted = redactSensitiveStatusSummary(input); + expect(redacted.sessions.paths).toEqual([]); + expect(redacted.sessions.defaults).toEqual({ model: null, contextTokens: null }); + expect(redacted.sessions.recent).toEqual([]); + expect(redacted.sessions.byAgent[0]?.path).toBe("[redacted]"); + expect(redacted.sessions.byAgent[0]?.recent).toEqual([]); + expect(redacted.heartbeat).toEqual(input.heartbeat); + expect(redacted.channelSummary).toEqual(input.channelSummary); + }); +}); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 3c74e1a7b5d..d9fa242ea56 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -67,7 +67,30 @@ const buildFlags = (entry?: SessionEntry): string[] => { return flags; }; -export async function getStatusSummary(): Promise { +export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSummary { + return { + ...summary, + sessions: { + ...summary.sessions, + paths: [], + defaults: { + model: null, + contextTokens: null, + }, + recent: [], + byAgent: summary.sessions.byAgent.map((entry) => ({ + ...entry, + path: "[redacted]", + recent: [], + })), + }, + }; +} + +export async function getStatusSummary( + options: { includeSensitive?: boolean } = {}, +): Promise { + const { includeSensitive = true } = options; const cfg = loadConfig(); const linkContext = await resolveLinkChannelContext(cfg); const agentList = listAgentsForGateway(cfg); @@ -179,7 +202,7 @@ export async function getStatusSummary(): Promise { const recent = allSessions.slice(0, 10); const totalSessions = allSessions.length; - return { + const summary: StatusSummary = { linkChannel: linkContext ? { id: linkContext.plugin.id, @@ -205,4 +228,5 @@ export async function getStatusSummary(): Promise { byAgent, }, }; + return includeSensitive ? summary : redactSensitiveStatusSummary(summary); } diff --git a/src/gateway/server-methods/health.status-scope.test.ts b/src/gateway/server-methods/health.status-scope.test.ts new file mode 100644 index 00000000000..f8a83aa1974 --- /dev/null +++ b/src/gateway/server-methods/health.status-scope.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { getStatusSummary } from "../../commands/status.js"; +import { healthHandlers } from "./health.js"; + +vi.mock("../../commands/status.js", () => ({ + getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), +})); + +describe("gateway healthHandlers.status scope handling", () => { + it("requests redacted status for non-admin clients", async () => { + const respond = vi.fn(); + await healthHandlers.status({ + respond, + client: { connect: { role: "operator", scopes: ["operator.read"] } }, + } as Parameters<(typeof healthHandlers)["status"]>[0]); + + expect(vi.mocked(getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: false }); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); + + it("requests full status for admin clients", async () => { + const respond = vi.fn(); + await healthHandlers.status({ + respond, + client: { connect: { role: "operator", scopes: ["operator.admin"] } }, + } as Parameters<(typeof healthHandlers)["status"]>[0]); + + expect(vi.mocked(getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: true }); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); +}); diff --git a/src/gateway/server-methods/health.ts b/src/gateway/server-methods/health.ts index b4e0ae8ae92..d0c499090c1 100644 --- a/src/gateway/server-methods/health.ts +++ b/src/gateway/server-methods/health.ts @@ -5,6 +5,8 @@ import { HEALTH_REFRESH_INTERVAL_MS } from "../server-constants.js"; import { formatError } from "../server-utils.js"; import { formatForLog } from "../ws-log.js"; +const ADMIN_SCOPE = "operator.admin"; + export const healthHandlers: GatewayRequestHandlers = { health: async ({ respond, context, params }) => { const { getHealthCache, refreshHealthSnapshot, logHealth } = context; @@ -25,8 +27,11 @@ export const healthHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - status: async ({ respond }) => { - const status = await getStatusSummary(); + status: async ({ respond, client }) => { + const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + const status = await getStatusSummary({ + includeSensitive: scopes.includes(ADMIN_SCOPE), + }); respond(true, status, undefined); }, };