Files
rea/src/cli/explicitSensitiveArguments.ts
T
Kaoru0822 c083fedaf4 fix(cli): exclude declared sensitive values from parser diagnostics (#6258)
* fix(cli): exclude declared sensitive values from parser diagnostics

Incur rejects malformed command tokens and output-flag values before the
web capture service applies --sensitive-value declarations, so its
diagnostics repeated declared literals.

Read declarations from the tokens Incur leaves for command parsing,
independently of parse success. Commands accepting the option get an
outermost middleware that excludes declarations from parse, validation
and thrown failures while keeping type, code and exit status. Rejected
--format/--token-limit/--token-offset values are replaced by a
literal-free value Incur still rejects. The option definition and the
Incur builtin-flag rules now have one owner each.

Fixes #6252

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): redact whole rejected output-flag diagnostics

Incur builds its rejected --format/--token-limit/--token-offset message
from fixed text and the value, outside any middleware. Substituting only
the value left declarations that match the fixed text, such as "token"
in "--token-limit", in the diagnostic.

Report that first rejection at the serve boundary instead, redacting the
whole message as parser failures are, while keeping the UNKNOWN code and
Incur's toon or TTY rendering. Incur's message is now mirrored beside its
acceptance rule, and the value-substitution path is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:22:16 -07:00

184 lines
6.6 KiB
TypeScript

import { AsyncLocalStorage } from "node:async_hooks";
import { Errors, Formatter, z, type MiddlewareHandler } from "incur";
import {
cliOutputValueRejection,
splitIncurBuiltinArguments,
type IncurBuiltinArgumentSplit,
} from "../cliOutput.js";
import { redactExplicitText } from "../domain/explicitSensitiveValues.js";
import type { CliInstance } from "./types.js";
import { visitRegisteredCommands } from "./visitRegisteredCommands.js";
/** Repeatable option whose literals the command and its CLI diagnostics exclude. */
export const explicitSensitiveValueOptions = {
sensitiveValue: z
.array(z.string().min(1))
.default([])
.describe(
"Literal string values explicitly marked sensitive; repeat as needed",
),
};
const OPTION =
"sensitiveValue" satisfies keyof typeof explicitSensitiveValueOptions;
/** Incur accepts the schema key and its kebab-case spelling. */
const FLAGS = ["--sensitive-value", "--sensitiveValue"] as const;
/**
* Read declarations from the tokens Incur leaves for command parsing. Incur
* rejects a malformed token before it reaches later declarations, so this scan
* is independent of parse success. A token following a declaration flag counts
* as declared even where the command parser would bind it to another option.
*/
const declaredSensitiveValues = (
command: IncurBuiltinArgumentSplit["command"],
): readonly string[] => {
const values = new Set<string>();
for (let index = 0; index < command.length; index += 1) {
const token = command[index]!;
const flag = FLAGS.find(
(candidate) => token === candidate || token.startsWith(`${candidate}=`),
);
if (flag === undefined) continue;
if (token !== flag) values.add(token.slice(flag.length + 1));
else if (index + 1 < command.length) {
values.add(command[index + 1]!);
index += 1;
}
}
values.delete("");
return [...values];
};
type ServeOptions = NonNullable<Parameters<CliInstance["serve"]>[1]>;
/**
* Incur rejects an output value in its builtin pass, before command resolution
* and outside any middleware, then renders the message it built. Report that
* same first rejection here so the whole message follows the declarations,
* as parser failures do; the code and Incur's rendering stay unchanged. It
* precedes command resolution, so declarations apply whichever command follows.
*/
const reportDeclaredOutputRejection = (
arguments_: readonly string[],
outputValues: IncurBuiltinArgumentSplit["outputValues"],
values: readonly string[],
options: ServeOptions,
): boolean => {
const rejection = outputValues
.map(({ flag, index }) => cliOutputValueRejection(flag, arguments_[index]!))
.find((message) => message !== undefined);
if (rejection === undefined) return false;
const error = {
code: "UNKNOWN",
message: redactExplicitText(rejection, values),
};
// Incur has not seen --format or --json yet, so only a TTY selects prose.
const output =
process.stdout.isTTY === true
? `Error: ${error.message}`
: Formatter.format(error, "toon");
(options.stdout ?? ((text: string) => process.stdout.write(text)))(
output.endsWith("\n") ? output : `${output}\n`,
);
(options.exit ?? ((code: number) => process.exit(code)))(1);
return true;
};
/** Keep the thrown type, code and exit status; exclude declared literals from text. */
const withoutDeclaredText = (
error: unknown,
values: readonly string[],
): unknown => {
const text = (value: string): string => redactExplicitText(value, values);
if (!(error instanceof Error)) {
const description = String(error);
return text(description) === description ? error : text(description);
}
const message = text(error.message);
if (error instanceof Errors.ValidationError) {
// Paths and expectations name the command's own schema.
const fieldErrors = error.fieldErrors.map((field) => ({
...field,
received: text(field.received),
message: text(field.message),
}));
return message === error.message &&
fieldErrors.every(
(field, index) =>
field.received === error.fieldErrors[index]!.received &&
field.message === error.fieldErrors[index]!.message,
)
? error
: new Errors.ValidationError({ message, fieldErrors });
}
if (error instanceof Errors.IncurError) {
const hint = error.hint === undefined ? undefined : text(error.hint);
return message === error.message && hint === error.hint
? error
: new Errors.IncurError({
code: error.code,
message,
...(hint === undefined ? {} : { hint }),
retryable: error.retryable,
...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }),
});
}
if (message === error.message) return error;
return error instanceof Errors.ParseError
? new Errors.ParseError({ message })
: new Error(message);
};
/**
* Apply `--sensitive-value` declarations to diagnostics produced before the
* command's own service sees them: rejected output values and the parse,
* validation and thrown failures of every command that accepts the option.
* Diagnostic text is redacted whole; codes, keys and rendering stay intact.
* Returns the serve step; command results keep their own redaction owners.
*/
export const excludeDeclaredSensitiveArguments = (
cli: CliInstance,
): ((
arguments_: readonly string[],
options: ServeOptions,
serve: (arguments_: string[], options: ServeOptions) => Promise<void>,
) => Promise<void>) => {
const declarations = new AsyncLocalStorage<readonly string[]>();
const excludeFromFailures: MiddlewareHandler = async (_context, next) => {
const values = declarations.getStore();
if (values === undefined) return next();
try {
await next();
} catch (error) {
throw withoutDeclaredText(error, values);
}
};
visitRegisteredCommands(cli, (command) => {
if (
command.options instanceof z.ZodObject &&
Object.hasOwn(command.options.shape, OPTION)
)
// Outermost, so later command middleware failures are covered as well
// as the parsing and run that Incur executes inside the chain.
command.middleware = [excludeFromFailures, ...(command.middleware ?? [])];
});
return async (arguments_, options, serve) => {
const split = splitIncurBuiltinArguments(arguments_);
const values = declaredSensitiveValues(split.command);
if (values.length === 0) return serve([...arguments_], options);
if (
reportDeclaredOutputRejection(
arguments_,
split.outputValues,
values,
options,
)
)
return;
return declarations.run(values, () => serve([...arguments_], options));
};
};