Josh Avant 0e4245063f
CLI: make read-only SecretRef status flows degrade safely (#37023)
* CLI: add read-only SecretRef inspection

* CLI: fix read-only SecretRef status regressions

* CLI: preserve read-only SecretRef status fallbacks

* Docs: document read-only channel inspection hook

* CLI: preserve audit coverage for read-only SecretRefs

* CLI: fix read-only status account selection

* CLI: fix targeted gateway fallback analysis

* CLI: fix Slack HTTP read-only inspection

* CLI: align audit credential status checks

* CLI: restore Telegram read-only fallback semantics
2026-03-05 23:07:13 -06:00

161 lines
4.5 KiB
TypeScript

import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js";
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { loadConfig } from "../../config/config.js";
import { danger } from "../../globals.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import type { RuntimeEnv } from "../../runtime.js";
export type ChannelsResolveOptions = {
channel?: string;
account?: string;
kind?: "auto" | "user" | "group" | "channel";
json?: boolean;
entries?: string[];
};
type ResolveResult = {
input: string;
resolved: boolean;
id?: string;
name?: string;
error?: string;
note?: string;
};
function resolvePreferredKind(
kind?: ChannelsResolveOptions["kind"],
): ChannelResolveKind | undefined {
if (!kind || kind === "auto") {
return undefined;
}
if (kind === "user") {
return "user";
}
return "group";
}
function detectAutoKind(input: string): ChannelResolveKind {
const trimmed = input.trim();
if (!trimmed) {
return "group";
}
if (trimmed.startsWith("@")) {
return "user";
}
if (/^<@!?/.test(trimmed)) {
return "user";
}
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
return "user";
}
if (
/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser|googlechat|google-chat|gchat):/i.test(
trimmed,
)
) {
return "user";
}
return "group";
}
function formatResolveResult(result: ResolveResult): string {
if (!result.resolved || !result.id) {
return `${result.input} -> unresolved`;
}
const name = result.name ? ` (${result.name})` : "";
const note = result.note ? ` [${result.note}]` : "";
return `${result.input} -> ${result.id}${name}${note}`;
}
export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) {
const loadedRaw = loadConfig();
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "channels resolve",
targetIds: getChannelsCommandSecretTargetIds(),
mode: "operational_readonly",
});
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);
}
const entries = (opts.entries ?? []).map((entry) => entry.trim()).filter(Boolean);
if (entries.length === 0) {
throw new Error("At least one entry is required.");
}
const selection = await resolveMessageChannelSelection({
cfg,
channel: opts.channel ?? null,
});
const plugin = getChannelPlugin(selection.channel);
if (!plugin?.resolver?.resolveTargets) {
throw new Error(`Channel ${selection.channel} does not support resolve.`);
}
const preferredKind = resolvePreferredKind(opts.kind);
let results: ResolveResult[] = [];
if (preferredKind) {
const resolved = await plugin.resolver.resolveTargets({
cfg,
accountId: opts.account ?? null,
inputs: entries,
kind: preferredKind,
runtime,
});
results = resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.note,
}));
} else {
const byKind = new Map<ChannelResolveKind, string[]>();
for (const entry of entries) {
const kind = detectAutoKind(entry);
byKind.set(kind, [...(byKind.get(kind) ?? []), entry]);
}
const resolved: ChannelResolveResult[] = [];
for (const [kind, inputs] of byKind.entries()) {
const batch = await plugin.resolver.resolveTargets({
cfg,
accountId: opts.account ?? null,
inputs,
kind,
runtime,
});
resolved.push(...batch);
}
const byInput = new Map(resolved.map((entry) => [entry.input, entry]));
results = entries.map((input) => {
const entry = byInput.get(input);
return {
input,
resolved: entry?.resolved ?? false,
id: entry?.id,
name: entry?.name,
note: entry?.note,
};
});
}
if (opts.json) {
runtime.log(JSON.stringify(results, null, 2));
return;
}
for (const result of results) {
if (result.resolved && result.id) {
runtime.log(formatResolveResult(result));
} else {
runtime.error(
danger(
`${result.input} -> unresolved${result.error ? ` (${result.error})` : result.note ? ` (${result.note})` : ""}`,
),
);
}
}
}