@wirecat/cli-core 0.437.0 → 0.439.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.439.0 — 26.10.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **A run record keeps the command line as typed.** `argv` in `run.json` and `runs show` now holds the words the person
15
+ typed — a chat's title, a search, message text — so the history of what was run can be read back. A secret is
16
+ still always `[redacted]`: the value of an option or argument a CLI marks with the new `secretInput(...)` (exported
17
+ from `@wirecat/cli-core/cli`), one whose name says token, password, secret, credential, cookie, authorization or
18
+ API key, a password inside a URL, and a long opaque string. `bot api … --body` is marked secret, since a field the
19
+ API marks secret can ride in it. The new per-profile setting `record_input: false` (`config set record_input false`)
20
+ brings back the previous masking exactly. A configuration that will not load also masks, since it may be the one
21
+ that turned recording off. A CLI with its own `resolveSettings` may return `recordInput`; when it does not, typed
22
+ words are kept. `doctor reports create` still masks the run's words as before, since the report is sent to other
23
+ people. `config set` and `config show` list `record_input`, so a CLI's help snapshot changes. A test or document
24
+ that says a run never holds what was typed needs updating.
25
+
26
+ ### Security
27
+
28
+ - **An error that quotes `--token=…` or `password=…` no longer keeps the value.** A kept error masked such a value
29
+ only when a space followed the `=` or `:`; Commander's `unknown option '--token=…'` kept it whole.
30
+
31
+ ## 0.438.0 — 26.10.2026
32
+
33
+ ### Security
34
+
35
+ - **A first word refused as a profile no longer reaches the run log.** When the first word could not be a profile
36
+ (`app "runs list"`, two words in one argument) or a profile lock refused it, the failed run's record kept it as
37
+ `profile` while its `argv` showed `[redacted]` — so whatever was typed first, possibly message text or a person's
38
+ name, was stored in the clear. Such a run now records no profile, as a run that names none does; a valid profile
39
+ the settings accepted is recorded as before. The lock's refusal quotes the refused word, so a kept error masks it,
40
+ and a bare first word with no command after it suggests `<tool> <word> account show` only when the word can be a
41
+ profile and the CLI has an `account show` command — that suggestion repeated the word unmasked in the kept error.
42
+
8
43
  ## 0.437.0 — 26.10.2026
9
44
 
10
45
  ### Added
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command, Option } from "commander";
4
+ import { secretInput } from "../../runs/run-context.js";
4
5
  import { REMOTE_READ, REMOTE_WRITE } from "../effects.js";
5
6
  import { apiFlagOf, apiOptionKey, readApiBody } from "./api-input.js";
6
7
  export const generatedApiCommand = (api) => {
@@ -20,7 +21,8 @@ export const generatedApiCommand = (api) => {
20
21
  command.addOption(parameter.required && parameter.in !== "body" ? option.makeOptionMandatory() : option);
21
22
  }
22
23
  if (operation.request) {
23
- command.option("--body <json>", "the request body as JSON; - reads it from stdin");
24
+ // A field the schema marks secret may ride in an inline body, and the flags never see it.
25
+ command.addOption(secretInput(new Option("--body <json>", "the request body as JSON; - reads it from stdin")));
24
26
  command.option("--body-file <path>", "the request body from a JSON file; - is stdin");
25
27
  }
26
28
  annotate(command, {
@@ -62,7 +62,7 @@ export interface BaseEnvironment {
62
62
  handled?: (event: MessageEvent) => void;
63
63
  /** Set by an MCP server on a command it runs for an agent (`runCommandForAgent`). */
64
64
  agent?: AgentRun;
65
- /** Set by `run()`: the words it was given, which a run keeps masked (`maskArgv`). */
65
+ /** Set by `run()`: the words it was given, which a run keeps as `record_input` says (`maskArgv`). */
66
66
  argv?: readonly string[];
67
67
  /**
68
68
  * Set by `run()`: the rest of the command has no default deadline, and Ctrl-C, SIGTERM, a cancel or an explicit
@@ -86,6 +86,7 @@ export const baseContext = (command, resolveSettings) => {
86
86
  program: rootOf(command),
87
87
  ...(environment.argv ? { argv: environment.argv } : {}),
88
88
  env,
89
+ recordInput: settings.recordInput !== false,
89
90
  }),
90
91
  ...(ranFor === undefined ? {} : { profile: ranFor }),
91
92
  record: settings.record,
@@ -25,6 +25,7 @@ export { type CacheEvent, type DiagnosticEvent, type EventSink, providerErrorKey
25
25
  export { CORE_LOG_EVENTS } from "../runs/log-events.js";
26
26
  export { crashOf, type Recording, type RecordingOptions, recorded, startRecording, wasSettled, } from "../runs/recording.js";
27
27
  export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, type RunMetadata, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
28
+ export { secretInput } from "../runs/run-context.js";
28
29
  export { currentRun, inRun, type RunScope, useAsProcessRun } from "../runs/scope.js";
29
30
  export { LANGUAGES } from "../search/languages.js";
30
31
  export { guardedApp, guardedPress, guardedStart, type Pressed } from "../sends/buttons.js";
package/dist/cli/index.js CHANGED
@@ -21,6 +21,7 @@ export { providerErrorKey, renderEvent, } from "../runs/events.js";
21
21
  export { CORE_LOG_EVENTS } from "../runs/log-events.js";
22
22
  export { crashOf, recorded, startRecording, wasSettled, } from "../runs/recording.js";
23
23
  export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
24
+ export { secretInput } from "../runs/run-context.js";
24
25
  export { currentRun, inRun, useAsProcessRun } from "../runs/scope.js";
25
26
  export { LANGUAGES } from "../search/languages.js";
26
27
  export { guardedApp, guardedPress, guardedStart } from "../sends/buttons.js";
@@ -14,7 +14,7 @@ import { destroysNothing, LOCAL_WRITE, NO_EFFECTS, REMOTE_READ } from "../effect
14
14
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
15
15
  import { unknownPermissionIssues } from "../permission-keys.js";
16
16
  import { NONE_NAMED } from "../settings.js";
17
- import { lineOf, nextStep } from "../steps.js";
17
+ import { lineOf, nextStep, rootOf } from "../steps.js";
18
18
  import { recalledAccount } from "./accounts.js";
19
19
  import { messengerContext } from "./context.js";
20
20
  import { privateFiles, withSqliteSidecars } from "./private-files.js";
@@ -100,6 +100,7 @@ const reportCommand = (messenger) => {
100
100
  sends: profile ? new SendJournal(sendsPathFor(app, profile, env)).entries() : [],
101
101
  now,
102
102
  home: env.HOME ?? env.USERPROFILE,
103
+ program: rootOf(this),
103
104
  ...(options.run === undefined ? {} : { runId: options.run }),
104
105
  });
105
106
  const path = resolve(options.output ?? reportFileName(app.command, now));
@@ -1,3 +1,4 @@
1
+ import { Command } from "commander";
1
2
  import { type RunMetadata } from "../../runs/run.js";
2
3
  import type { SendEntry } from "../../sends/journal.js";
3
4
  export interface Report<Doctor extends object = Record<string, unknown>> {
@@ -29,7 +30,7 @@ export interface ReportContext {
29
30
  * journal are built that way. Two things are added: every id becomes a label, and the home
30
31
  * directory is hidden, since paths name the person's account on this machine.
31
32
  */
32
- export declare const buildReport: <Doctor extends object, Context extends ReportContext = Record<never, never>>({ doctor, runsDir, runId, command, sends, now, home, context, eventFields, }: {
33
+ export declare const buildReport: <Doctor extends object, Context extends ReportContext = Record<never, never>>({ doctor, runsDir, runId, command, sends, now, home, context, eventFields, program, }: {
33
34
  doctor: Doctor;
34
35
  runsDir: string;
35
36
  runId?: string;
@@ -41,6 +42,8 @@ export declare const buildReport: <Doctor extends object, Context extends Report
41
42
  context?: Context;
42
43
  /** Retain allowlisted recorded fields, including time and severity, for an existing consumer contract. */
43
44
  eventFields?: "report" | "recorded";
45
+ /** The CLI's program, which tells a flag's value from a command word when the run's words are masked. */
46
+ program?: Command;
44
47
  }) => Report<Doctor> & Omit<Context, keyof Report>;
45
48
  /**
46
49
  * Every field of a journal line that names a chat, a message or a write. A send's `operationId` is
@@ -1,8 +1,10 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import { CliError } from "@wirecat/cli-base";
4
+ import { Command } from "commander";
4
5
  import { LOG_ATTRIBUTES } from "../../runs/log-attributes.js";
5
6
  import { findRun, listRuns, readEvents } from "../../runs/run.js";
7
+ import { maskArgv } from "../../runs/run-context.js";
6
8
  const RECENT_SENDS = 20;
7
9
  /** Only these fields of a run event travel; a field added later is left out until it is named here. */
8
10
  const EVENT_FIELDS = [
@@ -21,7 +23,7 @@ const EVENT_FIELDS = [
21
23
  * journal are built that way. Two things are added: every id becomes a label, and the home
22
24
  * directory is hidden, since paths name the person's account on this machine.
23
25
  */
24
- export const buildReport = ({ doctor, runsDir, runId, command, sends, now = new Date(), home, context, eventFields = "report", }) => {
26
+ export const buildReport = ({ doctor, runsDir, runId, command, sends, now = new Date(), home, context, eventFields = "report", program, }) => {
25
27
  const chosen = runId === undefined ? newestFailed(runsDir) : findRun(runsDir, runId);
26
28
  if (runId !== undefined && !chosen)
27
29
  throw new CliError("not_found", `no run ${runId} — \`${command} runs list\` names them`);
@@ -34,11 +36,18 @@ export const buildReport = ({ doctor, runsDir, runId, command, sends, now = new
34
36
  ...doctor,
35
37
  ...(account?.remembered ? { account: { ...account, remembered: label(account.remembered) } } : {}),
36
38
  },
37
- run: chosen ? { metadata: chosen.metadata, events: readEvents(chosen.dir).map(eventOf(label, eventFields)) } : null,
39
+ run: chosen
40
+ ? {
41
+ metadata: maskedRun(chosen.metadata, program),
42
+ events: readEvents(chosen.dir).map(eventOf(label, eventFields)),
43
+ }
44
+ : null,
38
45
  sends: sends.slice(-RECENT_SENDS).map((entry) => labelled(entry, label)),
39
46
  };
40
47
  return JSON.parse(hideHome(JSON.stringify(report), home));
41
48
  };
49
+ // A report leaves the machine, so it masks the typed words as `record_input: false` would.
50
+ const maskedRun = (metadata, program = new Command()) => metadata.argv ? { ...metadata, argv: maskArgv(program, metadata.argv).argv } : metadata;
42
51
  /**
43
52
  * Every field of a journal line that names a chat, a message or a write. A send's `operationId` is
44
53
  * its `sendId`, so one left raw would undo the other's label.
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  /** How the command line names this profile: always the first word, since none is assumed. */
3
3
  export declare const asFirstWord: (profile: string) => string;
4
+ export declare const isProfileName: (word: string) => boolean;
4
5
  export declare const usableProfileName: (profile: string) => string;
5
6
  /** The top-level commands that never read a profile; every other one is `<tool> <profile> …`. */
6
7
  export declare const PROFILE_FREE: ReadonlySet<string>;
@@ -7,8 +7,9 @@ export const asFirstWord = (profile) => `${profile} `;
7
7
  * somewhere nobody would think to look.
8
8
  */
9
9
  const PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
10
+ export const isProfileName = (word) => PLAIN_NAME.test(word);
10
11
  export const usableProfileName = (profile) => {
11
- if (!PLAIN_NAME.test(profile)) {
12
+ if (!isProfileName(profile)) {
12
13
  throw new CliError("validation_error", `"${profile}" cannot be a profile name — letters, digits, dot, dash and underscore only, ` +
13
14
  "starting with a letter or a digit");
14
15
  }
@@ -10,7 +10,7 @@ export interface ProgramDefinition {
10
10
  /** The CLI's own settings, for keeping a failure that happened before its command could; plain ones without. */
11
11
  configuration?: {
12
12
  permissionKey?: (path: readonly string[]) => string | null | undefined;
13
- resolveSettings: (flags?: GlobalFlags, options?: ResolveOptions) => Pick<Settings, "profile" | "keepFailedRuns" | "keepRunsForDays" | "skillHint"> & Partial<Pick<Settings, "permissions" | "permissionSources" | "configPath" | "savedPermissions" | "sources">>;
13
+ resolveSettings: (flags?: GlobalFlags, options?: ResolveOptions) => Pick<Settings, "profile" | "keepFailedRuns" | "keepRunsForDays" | "skillHint"> & Partial<Pick<Settings, "recordInput" | "permissions" | "permissionSources" | "configPath" | "savedPermissions" | "sources">>;
14
14
  };
15
15
  configure?: (program: Command) => void;
16
16
  /** The tool's pitch and "what you can do" lines on `--help` and a bare run. */
@@ -27,7 +27,7 @@ import { showInputFields } from "./input-fields.js";
27
27
  import { MAX_BUFFERED_INPUT, provideInputPolicy } from "./input-policy.js";
28
28
  import { checkJson, liftJson } from "./json-fields.js";
29
29
  import { PreviewComplete, preview } from "./preview.js";
30
- import { commandWords, liftProfile, sampleCommand, takesProfile, valuedOptions } from "./profile.js";
30
+ import { commandWords, isProfileName, liftProfile, sampleCommand, takesProfile, valuedOptions } from "./profile.js";
31
31
  import { absolutePaths, relayTarget } from "./relay-route.js";
32
32
  import { NoProfileHere, namedProfile, settingsFor, } from "./settings.js";
33
33
  import { assertSharedContracts } from "./shared-contracts.js";
@@ -433,8 +433,11 @@ const runOnce = async (argv, definition, options) => {
433
433
  // A bare word with nothing after it would make commander print help **on stdout**, which breaks
434
434
  // the one contract this program has and says nothing about why.
435
435
  if (profile !== undefined && rest.length === 0) {
436
+ const asProfile = commandAt(program, "account show") && isProfileName(profile)
437
+ ? `, or \`${command} ${profile} account show\` if "${profile}" is your profile`
438
+ : "";
436
439
  const message = `"${profile}" is not a command, so it was read as a profile name — and no command followed it. ` +
437
- `Run \`${command} --help\` for the commands, or \`${command} ${profile} account show\` if "${profile}" is your profile.`;
440
+ `Run \`${command} --help\` for the commands${asProfile}.`;
438
441
  const failure = new CliError("validation_error", message, { param: "profile" });
439
442
  const settled = await settleFailure(failure, { definition, program, rest, argv, profile, options });
440
443
  return report(streams, reporting, failed(definition, program, rest, failure, settled, debug));
@@ -571,7 +574,8 @@ const settleFailure = async (failure, state) => {
571
574
  /**
572
575
  * **Every failure is kept as a run** — a usage error, a configuration that will not load, a command
573
576
  * that never opens a run — unless recording was turned off. One its own run already kept is skipped.
574
- * Its arguments are kept masked (`maskArgv`): a value that can be a message never is.
577
+ * Its words are kept as `record_input` says (`maskArgv`); a configuration that did not load masks them, since it may
578
+ * be the one that turned recording input off.
575
579
  */
576
580
  const keepFailure = async (failure, { definition, program, rest, argv, profile, options }) => {
577
581
  if (wasSettled(failure))
@@ -596,8 +600,10 @@ const keepFailure = async (failure, { definition, program, rest, argv, profile,
596
600
  program,
597
601
  argv,
598
602
  env,
603
+ recordInput: settings ? settings.recordInput !== false : false,
599
604
  }),
600
- profile: settings ? namedProfile(settings) : profile,
605
+ // Settings that did not load never accepted the word, which may be a refused name or somebody's words.
606
+ profile: settings ? namedProfile(settings) : undefined,
601
607
  record: false,
602
608
  keepFailed: settings?.keepFailedRuns ?? !rest.includes("--no-record"),
603
609
  trace: false,
@@ -97,6 +97,11 @@ export interface Settings extends AISettings {
97
97
  */
98
98
  commandTimeoutMs: number | undefined;
99
99
  record: boolean;
100
+ /**
101
+ * A run record keeps the words typed, secrets hidden; off masks every value that may be content. Optional so a CLI
102
+ * with settings of its own need not return it: left out, the words are kept.
103
+ */
104
+ recordInput?: boolean;
100
105
  /** A failed run is kept even unrecorded, unless recording was turned off by name. */
101
106
  keepFailedRuns: boolean;
102
107
  keepRunsForDays: number;
@@ -265,6 +270,7 @@ export declare const settingsFor: <T extends object = Record<never, never>>(app:
265
270
  color: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
266
271
  sender_colors: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
267
272
  record: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
273
+ record_input: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
268
274
  keep_runs_for_days: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
269
275
  permissions: v.OptionalSchema<v.RecordSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, (issue: v.RegexIssue<string>) => string>]>, v.PicklistSchema<readonly ["deny", "readonly", "ask", "allow"], (issue: v.PicklistIssue) => string>, (issue: v.BaseIssue<unknown>) => string>, undefined>;
270
276
  sends_per_hour: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
@@ -338,6 +344,7 @@ export declare const settingsFor: <T extends object = Record<never, never>>(app:
338
344
  color: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
339
345
  sender_colors: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
340
346
  record: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
347
+ record_input: v.OptionalSchema<v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>, undefined>;
341
348
  keep_runs_for_days: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
342
349
  permissions: v.OptionalSchema<v.RecordSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, (issue: v.RegexIssue<string>) => string>]>, v.PicklistSchema<readonly ["deny", "readonly", "ask", "allow"], (issue: v.PicklistIssue) => string>, (issue: v.BaseIssue<unknown>) => string>, undefined>;
343
350
  sends_per_hour: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>]>, undefined>;
@@ -56,6 +56,7 @@ const SHARED_PROFILE_ENTRIES = {
56
56
  color: v.optional(flag),
57
57
  sender_colors: v.optional(flag),
58
58
  record: v.optional(flag),
59
+ record_input: v.optional(flag),
59
60
  keep_runs_for_days: v.optional(count),
60
61
  permissions: v.optional(permissionLevels),
61
62
  sends_per_hour: v.optional(count),
@@ -168,7 +169,7 @@ export const settingsFor = (app, extension = {}) => {
168
169
  if (profile.value === lock)
169
170
  return profile;
170
171
  if (profile.from === "first word" || profile.from === PROFILE) {
171
- throw new CliError("permission_error", `this process is locked to profile ${lock} (${PROFILE_LOCK}) — profile ${profile.value} is refused`);
172
+ throw new CliError("permission_error", `this process is locked to profile ${lock} (${PROFILE_LOCK}) — profile "${profile.value}" is refused`);
172
173
  }
173
174
  return { value: lock, from: PROFILE_LOCK };
174
175
  };
@@ -231,6 +232,7 @@ export const settingsFor = (app, extension = {}) => {
231
232
  const searchCatchUp = fromLayers("search_catch_up", false);
232
233
  const pictureSearch = fromLayers("picture_search", false);
233
234
  const searchReranker = fromLayers("search_reranker", DEFAULT_RERANK_MODEL);
235
+ const recordInput = fromLayers("record_input", true);
234
236
  const keepRunsForDays = fromLayers("keep_runs_for_days", DEFAULT_KEEP_RUNS_FOR_DAYS);
235
237
  // Set where an agent runs unattended; the config file and `--permission` cannot undo it.
236
238
  const readOnlyLock = readOnlyLockOf(app, env);
@@ -287,6 +289,7 @@ export const settingsFor = (app, extension = {}) => {
287
289
  ? undefined
288
290
  : (extension.parseDuration ?? parseDuration)(timeout.value, timeout.from === TIMEOUT ? TIMEOUT : "--timeout"),
289
291
  record: record.value,
292
+ recordInput: recordInput.value,
290
293
  keepFailedRuns: record.value || record.from === "default",
291
294
  keepRunsForDays: keepRunsForDays.value,
292
295
  ...(readOnlyLock ? { readOnlyLock } : {}),
@@ -314,6 +317,7 @@ export const settingsFor = (app, extension = {}) => {
314
317
  pictureSearch: pictureSearch.from,
315
318
  searchReranker: searchReranker.from,
316
319
  record: record.from,
320
+ recordInput: recordInput.from,
317
321
  keepRunsForDays: keepRunsForDays.from,
318
322
  sendsPerHour: sendsPerHour.from,
319
323
  requestsPerMinute: requestsPerMinute.from,
@@ -79,6 +79,7 @@ export declare const configCommand: (app: import("./entry.js").AppIdentity, conf
79
79
  color: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
80
80
  sender_colors: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
81
81
  record: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
82
+ record_input: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
82
83
  keep_runs_for_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
83
84
  permissions: import("valibot").OptionalSchema<import("valibot").RecordSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").RegexAction<string, (issue: import("valibot").RegexIssue<string>) => string>]>, import("valibot").PicklistSchema<readonly ["deny", "readonly", "ask", "allow"], (issue: import("valibot").PicklistIssue) => string>, (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
84
85
  sends_per_hour: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
@@ -152,6 +153,7 @@ export declare const configCommand: (app: import("./entry.js").AppIdentity, conf
152
153
  color: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
153
154
  sender_colors: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
154
155
  record: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
156
+ record_input: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
155
157
  keep_runs_for_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
156
158
  permissions: import("valibot").OptionalSchema<import("valibot").RecordSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").RegexAction<string, (issue: import("valibot").RegexIssue<string>) => string>]>, import("valibot").PicklistSchema<readonly ["deny", "readonly", "ask", "allow"], (issue: import("valibot").PicklistIssue) => string>, (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
157
159
  sends_per_hour: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
@@ -280,6 +282,7 @@ export declare const completeCommand: (messenger: import("./index.js").Messenger
280
282
  color: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
281
283
  sender_colors: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
282
284
  record: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
285
+ record_input: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
283
286
  keep_runs_for_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
284
287
  permissions: import("valibot").OptionalSchema<import("valibot").RecordSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").RegexAction<string, (issue: import("valibot").RegexIssue<string>) => string>]>, import("valibot").PicklistSchema<readonly ["deny", "readonly", "ask", "allow"], (issue: import("valibot").PicklistIssue) => string>, (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
285
288
  sends_per_hour: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
@@ -353,6 +356,7 @@ export declare const completeCommand: (messenger: import("./index.js").Messenger
353
356
  color: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
354
357
  sender_colors: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
355
358
  record: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
359
+ record_input: import("valibot").OptionalSchema<import("valibot").BooleanSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
356
360
  keep_runs_for_days: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
357
361
  permissions: import("valibot").OptionalSchema<import("valibot").RecordSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").RegexAction<string, (issue: import("valibot").RegexIssue<string>) => string>]>, import("valibot").PicklistSchema<readonly ["deny", "readonly", "ask", "allow"], (issue: import("valibot").PicklistIssue) => string>, (issue: import("valibot").BaseIssue<unknown>) => string>, undefined>;
358
362
  sends_per_hour: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<(issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").IntegerAction<number, (issue: import("valibot").BaseIssue<unknown>) => string>, import("valibot").MinValueAction<number, 1, (issue: import("valibot").BaseIssue<unknown>) => string>]>, undefined>;
@@ -119,7 +119,7 @@ export const redactText = (text) => {
119
119
  let out = home && home !== "/" ? text.split(home).join("~") : text;
120
120
  out = out
121
121
  .replace(/"[^"\n]*"|“[^”\n]*”|«[^»\n]*»/g, '"[redacted]"')
122
- .replace(/\b(bearer|basic|token|key|password|secret)(\s*[:=]?\s+)\S+/gi, "$1$2[redacted]")
122
+ .replace(/\b(bearer|basic|token|key|password|secret)(\s*[:=]\s*|\s+)[^\s'"]+/gi, "$1$2[redacted]")
123
123
  .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "[email]")
124
124
  .replace(/\+\d[\d\s()-]{6,}\d/g, "[number]")
125
125
  .replace(/(^|[^\w-])\d{11,15}\b/g, "$1[number]")
@@ -1,4 +1,4 @@
1
- import type { Command } from "commander";
1
+ import type { Argument, Command, Option } from "commander";
2
2
  import type { RunContext } from "./run.js";
3
3
  export declare const REDACTED = "[redacted]";
4
4
  /** Which coding agent started the process, and its session when the agent names one. Keys as written in run.json. */
@@ -17,24 +17,32 @@ export interface RunAgent {
17
17
  */
18
18
  export declare const agentOf: (env: NodeJS.ProcessEnv) => RunAgent | undefined;
19
19
  export interface MaskedArgv {
20
- /** The words after the binary, every value that may be content or a secret replaced by `[redacted]`. */
20
+ /** The words after the binary; a secret is `[redacted]`, and with `keep` off so is every value that may be content. */
21
21
  argv: string[];
22
22
  /** The first word that names no command where one was expected. */
23
23
  unknownCommand?: string;
24
24
  }
25
+ /** Marks an option or argument whose value a run record never keeps, whatever the owner's `record_input`. */
26
+ export declare const secretInput: <T extends Option | Argument>(input: T) => T;
25
27
  /**
26
- * The command line as typed, safe to keep. Command words, flag names and the profile stay; a value stays only when
27
- * its shape cannot be somebody's words — a number short of a phone's length, a duration, a date, `true`, one of the
28
+ * The command line for a run record. With `keep` (the owner's `record_input`, on unless turned off) every word stays
29
+ * as typed except a secret: a value of an input marked `secretInput` or named for one (`--token`), and a word shaped
30
+ * like a credential. With `keep` off, command words, flag names and the profile stay; a value stays only when its
31
+ * shape cannot be somebody's words — a number short of a phone's length, a duration, a date, `true`, one of the
28
32
  * option's declared choices — and its option or argument is not named for content (`--text`, `<phone>`). Anything
29
33
  * else, a positional after `--` or an argument no command declares, is `[redacted]`: a chat is often typed by title.
30
34
  */
31
- export declare const maskArgv: (program: Command, words: readonly string[]) => MaskedArgv;
32
- /** A run's context from the parsed program: `max messages list` and its masked words, or the word that named nothing. */
33
- export declare const runContextOf: ({ bin, command, program, argv, env, }: {
35
+ export declare const maskArgv: (program: Command, words: readonly string[], { keep }?: {
36
+ keep?: boolean | undefined;
37
+ }) => MaskedArgv;
38
+ /** A run's context from the parsed program: `max messages list` and its words, or the word that named nothing. */
39
+ export declare const runContextOf: ({ bin, command, program, argv, env, recordInput, }: {
34
40
  bin: string;
35
41
  /** The run's own name, `messages list` or `mcp messages list`; empty when no command was recognised. */
36
42
  command: string;
37
43
  program?: Command;
38
44
  argv?: readonly string[];
39
45
  env: NodeJS.ProcessEnv;
46
+ /** The owner's `record_input`; left out, every value that may be content is masked. */
47
+ recordInput?: boolean;
40
48
  }) => RunContext;
@@ -39,19 +39,55 @@ const sessionOf = (kind, env) => {
39
39
  return (variable === undefined ? undefined : env[variable]?.trim()) || undefined;
40
40
  };
41
41
  const plainWord = (word) => /^[A-Za-z][A-Za-z0-9.-]{0,39}$/.test(word);
42
+ const secretInputs = new WeakSet();
43
+ /** Marks an option or argument whose value a run record never keeps, whatever the owner's `record_input`. */
44
+ export const secretInput = (input) => {
45
+ secretInputs.add(input);
46
+ return input;
47
+ };
48
+ const SECRET_WORDS = new Set([
49
+ "authorization",
50
+ "cookie",
51
+ "credential",
52
+ "credentials",
53
+ "passphrase",
54
+ "password",
55
+ "secret",
56
+ "token",
57
+ "apikey",
58
+ ]);
59
+ // Undeclared flags carry no mark, so the name is the only signal; CLIs take secrets at a prompt (WC-SECR-5) and this
60
+ // catches the one typed anyway.
61
+ const secretNamed = (name) => {
62
+ const words = name
63
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
64
+ .split(/[\s_.-]+/)
65
+ .map((word) => word.toLowerCase());
66
+ return words.some((word, at) => SECRET_WORDS.has(word) || (word === "api" && words[at + 1] === "key"));
67
+ };
68
+ // A password inside a URL, or one unbroken run of 40 token characters, is a credential wherever it was typed.
69
+ const secretShaped = (word) => /:\/\/[^/@\s:]+:[^/@\s]+@/.test(word) || /(^|[^A-Za-z0-9_-])[A-Za-z0-9_-]{40,}($|[^A-Za-z0-9_-])/.test(word);
42
70
  /**
43
- * The command line as typed, safe to keep. Command words, flag names and the profile stay; a value stays only when
44
- * its shape cannot be somebody's words — a number short of a phone's length, a duration, a date, `true`, one of the
71
+ * The command line for a run record. With `keep` (the owner's `record_input`, on unless turned off) every word stays
72
+ * as typed except a secret: a value of an input marked `secretInput` or named for one (`--token`), and a word shaped
73
+ * like a credential. With `keep` off, command words, flag names and the profile stay; a value stays only when its
74
+ * shape cannot be somebody's words — a number short of a phone's length, a duration, a date, `true`, one of the
45
75
  * option's declared choices — and its option or argument is not named for content (`--text`, `<phone>`). Anything
46
76
  * else, a positional after `--` or an argument no command declares, is `[redacted]`: a chat is often typed by title.
47
77
  */
48
- export const maskArgv = (program, words) => {
78
+ export const maskArgv = (program, words, { keep = false } = {}) => {
49
79
  const argv = [];
50
80
  let unknownCommand;
51
81
  let current = program;
52
82
  let positional = 0;
53
83
  let profileSeen = false;
54
84
  let lost = false;
85
+ const raw = (word) => (keep && !secretShaped(word) ? word : REDACTED);
86
+ const keptValue = (name, value, input) => keep
87
+ ? (input && secretInputs.has(input)) || secretNamed(name)
88
+ ? REDACTED
89
+ : raw(value)
90
+ : maskedValue(name, value, input?.argChoices);
55
91
  const optionFor = (flag) => {
56
92
  for (let at = current; at; at = at.parent) {
57
93
  const found = at.options.find((option) => option.long === flag || option.short === flag);
@@ -65,32 +101,37 @@ export const maskArgv = (program, words) => {
65
101
  if (word === "--") {
66
102
  argv.push("--");
67
103
  for (let after = at + 1; after < words.length; after++)
68
- argv.push(REDACTED);
104
+ argv.push(raw(words[after]));
69
105
  break;
70
106
  }
71
107
  if (word.startsWith("-") && word.length > 1 && !NUMBER.test(word)) {
72
108
  const equals = word.indexOf("=");
73
109
  const flag = equals > 0 ? word.slice(0, equals) : word;
74
110
  if (!/^--?[A-Za-z0-9][A-Za-z0-9-]*$/.test(flag)) {
75
- argv.push(REDACTED);
111
+ argv.push(raw(word));
76
112
  continue;
77
113
  }
78
114
  const option = optionFor(flag);
79
115
  const name = option?.attributeName() ?? flag.replace(/^-+/, "");
80
116
  if (equals > 0) {
81
- argv.push(`${flag}=${maskedValue(name, word.slice(equals + 1), option?.argChoices)}`);
117
+ argv.push(`${flag}=${keptValue(name, word.slice(equals + 1), option)}`);
82
118
  continue;
83
119
  }
84
120
  argv.push(flag);
121
+ if (!option && keep && secretNamed(name) && words[at + 1] !== undefined && !words[at + 1]?.startsWith("-")) {
122
+ at += 1;
123
+ argv.push(REDACTED);
124
+ continue;
125
+ }
85
126
  if (!option || !(option.required || option.optional))
86
127
  continue;
87
128
  const takes = (next) => next !== undefined && (option.required || !next.startsWith("-") || NUMBER.test(next));
88
129
  if (takes(words[at + 1])) {
89
130
  at += 1;
90
- argv.push(maskedValue(name, words[at], option.argChoices));
131
+ argv.push(keptValue(name, words[at], option));
91
132
  while (option.variadic && words[at + 1] !== undefined && !words[at + 1].startsWith("-")) {
92
133
  at += 1;
93
- argv.push(maskedValue(name, words[at], option.argChoices));
134
+ argv.push(keptValue(name, words[at], option));
94
135
  }
95
136
  }
96
137
  continue;
@@ -104,20 +145,20 @@ export const maskArgv = (program, words) => {
104
145
  }
105
146
  if (current === program && !profileSeen) {
106
147
  profileSeen = true;
107
- argv.push(PROFILE.test(word) ? word : REDACTED);
148
+ argv.push(keep ? raw(word) : PROFILE.test(word) ? word : REDACTED);
108
149
  continue;
109
150
  }
110
151
  if (current.commands.length > 0 && current.registeredArguments.length === 0) {
111
152
  lost = true;
112
153
  unknownCommand = commandLike(word);
113
- argv.push(unknownCommand);
154
+ argv.push(keep ? raw(word) : unknownCommand);
114
155
  continue;
115
156
  }
116
157
  }
117
158
  const declared = lost ? undefined : current.registeredArguments;
118
159
  const argument = declared?.[Math.min(positional, declared.length - 1)];
119
160
  positional += 1;
120
- argv.push(argument ? maskedValue(argument.name(), word, argument.argChoices) : REDACTED);
161
+ argv.push(argument ? keptValue(argument.name(), word, argument) : raw(word));
121
162
  }
122
163
  return { argv, ...(unknownCommand === undefined ? {} : { unknownCommand }) };
123
164
  };
@@ -143,9 +184,9 @@ const safeShape = (value) => (NUMBER.test(value) && !/^\d{11,}$/.test(value)) ||
143
184
  /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(value) ||
144
185
  value === "true" ||
145
186
  value === "false";
146
- /** A run's context from the parsed program: `max messages list` and its masked words, or the word that named nothing. */
147
- export const runContextOf = ({ bin, command, program, argv, env, }) => {
148
- const masked = program && argv ? maskArgv(program, argv) : undefined;
187
+ /** A run's context from the parsed program: `max messages list` and its words, or the word that named nothing. */
188
+ export const runContextOf = ({ bin, command, program, argv, env, recordInput = false, }) => {
189
+ const masked = program && argv ? maskArgv(program, argv, { keep: recordInput }) : undefined;
149
190
  const agent = agentOf(env);
150
191
  return {
151
192
  cliCommand: command ? `${bin} ${command}` : bin,
@@ -49,7 +49,7 @@ export interface RunMetadata {
49
49
  traceId?: string;
50
50
  /** The whole command with the program's name, `max messages list`; `command` stays the run's short name. */
51
51
  cliCommand?: string;
52
- /** The words after the program's name, every value that may be content or a secret `[redacted]` (`maskArgv`). */
52
+ /** The words after the program's name, a secret `[redacted]`; with `record_input` off, any value that may be content too (`maskArgv`). */
53
53
  argv?: string[];
54
54
  /** The word that named no command, when that is why the run failed. */
55
55
  unknownCommand?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.437.0",
3
+ "version": "0.439.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",