@aiwg/cli 2026.10.7 → 2026.10.8

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.
@@ -1,6 +1,7 @@
1
1
  /** OpenRouter fleet cost-report CLI handler. @issue #1187 */
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { sensitiveInputOptions } from '../../auth/sensitive-input.js';
4
5
  import { FleetConfigMissingError, formatFleetSpendReport, generateFleetSpendReport, } from '../../cost/fleet-report.js';
5
6
  function option(args, name) {
6
7
  const index = args.indexOf(name);
@@ -9,10 +10,11 @@ function option(args, name) {
9
10
  }
10
11
  function usage() {
11
12
  return [
12
- 'Usage: aiwg cost-report (--fleet | --key <key_ref>) [--source openrouter] [--config <fleet.yaml>] [--json]',
13
+ 'Usage: aiwg cost-report (--fleet | --key <key_ref> [--key-store <name>]) [--source openrouter] [--config <fleet.yaml>] [--json]',
13
14
  '',
14
15
  'Reads bot-to-key references from ~/.config/aiwg/fleet.yaml and credentials from',
15
16
  '~/.config/aiwg/keys/<key_ref> or AIWG_OPENROUTER_KEY_<KEY_REF>.',
17
+ 'Use --key-store <name> for one key, or per-entry key_store references in fleet YAML.',
16
18
  '',
17
19
  'AIWG observes and correlates spend; OpenRouter enforces all key limits and caps.',
18
20
  ].join('\n');
@@ -40,6 +42,9 @@ export const costReportHandler = {
40
42
  ? path.resolve(ctx.cwd, configuredPath)
41
43
  : path.join(os.homedir(), '.config', 'aiwg', 'fleet.yaml');
42
44
  try {
45
+ const keyStore = sensitiveInputOptions(ctx.args, 'key').store;
46
+ if (keyStore !== undefined && !keyRef)
47
+ return { exitCode: 2, message: '--key-store requires --key <key_ref>; use per-entry key_store references for --fleet.' };
43
48
  const capValue = option(ctx.args, '--monthly-cap');
44
49
  const cap = capValue === undefined ? 0 : Number(capValue);
45
50
  if (!Number.isFinite(cap) || cap < 0)
@@ -53,6 +58,7 @@ export const costReportHandler = {
53
58
  bot: option(ctx.args, '--bot') ?? keyRef,
54
59
  machine: option(ctx.args, '--machine') ?? os.hostname(),
55
60
  key_ref: keyRef,
61
+ ...(keyStore !== undefined ? { key_store: keyStore } : {}),
56
62
  monthly_cap: cap,
57
63
  }],
58
64
  } : {}),
@@ -1,12 +1,12 @@
1
1
  import { resolveSensitiveInput, sensitiveInputOptions } from '../../auth/sensitive-input.js';
2
2
  import { decisionPricing } from '../../decision/pricing.js';
3
- import { ATTRIBUTION_FIELD_PATTERN, DecisionAskUsageError, decisionCapabilities, DecisionLedgerSinceError, decisionLedgerHistory, decisionLedgerStats, evaluateRequestPath, listPatterns, livePlan, MAX_ATTRIBUTION_FIELD_LENGTH, materializeSyntheticClassificationSetup, parseDecisionLedgerSince, readDecisionContextFile, runDecisionOffloadCheckpoint, runOfflinePattern, setupJev, showPattern, syntheticClassificationSetup, validateDecisionInput, } from '../../decision/driver.js';
3
+ import { ATTRIBUTION_FIELD_PATTERN, DecisionAskUsageError, decisionCapabilities, DecisionLedgerSinceError, decisionLedgerHistory, decisionLedgerStats, rotateDecisionLedger, parseDecisionLedgerRetention, evaluateRequestPath, listPatterns, livePlan, MAX_ATTRIBUTION_FIELD_LENGTH, materializeSyntheticClassificationSetup, parseDecisionLedgerSince, readDecisionContextFile, runDecisionOffloadCheckpoint, runOfflinePattern, setupJev, showPattern, syntheticClassificationSetup, validateDecisionInput, } from '../../decision/driver.js';
4
4
  function jsonResult(value, exitCode = 0) {
5
5
  return { exitCode, message: JSON.stringify(value, null, 2), rawOutput: true };
6
6
  }
7
7
  function usage() {
8
8
  return [
9
- 'Usage: aiwg decision <capabilities|status|patterns|validate|evaluate|setup|history|stats|pricing> [options]',
9
+ 'Usage: aiwg decision <capabilities|status|patterns|validate|evaluate|setup|history|stats|rotate|pricing> [options]',
10
10
  ' aiwg decision ask --question "<q>" (--yes-no | --choices a,b,c | --scale 1-5) [--context <text> | --context-file <path> | --context-stdin] [--threshold 0.8] [--timeout-ms 15000] [--passes N] [--escalation-passes N] [--provider <id>] [--session <id>] [--caller <tag>] [--record-text] [--json]',
11
11
  '',
12
12
  'Commands:',
@@ -20,9 +20,10 @@ function usage() {
20
20
  ' setup synthetic-classification [--output-dir <dir>]',
21
21
  ' setup jev [--token-interactive | --token-stdin | --token-file <path> | --token-store <name> | --token-env <name>] [--region <r>] [--endpoint <url>] [--verify] [--remove]',
22
22
  ' ask --question "<q>" (--yes-no | --choices a,b,c | --scale 1-5) [--provider <id>] [--session <id>] [--caller <tag>] [--record-text]',
23
- ' history [--since <date>] [--provider <id>] [--session <id>] [--kind yes-no|choices|scale] [--status answered|fallback] [--caller <tag>|<prefix>* | --no-caller] [--json]',
23
+ ' history [--since <date>] [--provider <id>] [--session <id>] [--kind yes-no|choices|scale] [--status answered|fallback] [--caller <tag>|<prefix>* | --no-caller] [--include-archives] [--json]',
24
24
  ' pricing [--refresh --model jev-X.Y.Z] [--dry-run] [--offline | --allow-snapshot] [--json]',
25
- ' stats [--since <date>] [--provider <id>] [--session <id>] [--kind yes-no|choices|scale] [--status answered|fallback] [--caller <tag>|<prefix>* | --no-caller] [--group-by provider|session|kind|caller|passes|escalation] [--json]',
25
+ ' stats [--since <date>] [--provider <id>] [--session <id>] [--kind yes-no|choices|scale] [--status answered|fallback] [--caller <tag>|<prefix>* | --no-caller] [--group-by provider|session|kind|caller|passes|escalation] [--include-archives] [--json]',
26
+ ' rotate [--keep-last <Nd|N> | --all] [--to <path>] [--json]',
26
27
  '',
27
28
  'Notes:',
28
29
  ' Every COMPLETED `ask` call (CLI and MCP) appends one record to the decision',
@@ -42,9 +43,13 @@ function usage() {
42
43
  ` --session and --caller must match ${ATTRIBUTION_FIELD_PATTERN.source} (<= ${MAX_ATTRIBUTION_FIELD_LENGTH} characters).`,
43
44
  ' `history`/`stats` accept `--caller <tag>` (exact), `--caller \'<prefix>*\'` (prefix; the only wildcard), or `--no-caller` (records with no tag; not combinable with `--caller`).',
44
45
  ' `history`/`stats` reject unknown flags and positional arguments with exit 2.',
45
- ' The ledger has no built-in retention: it grows without bound and the',
46
- ' operator may truncate or delete it at any time; readers tolerate a missing',
47
- ' file. The ledger trusts only the local filesystem under the project',
46
+ ' `rotate` keeps the last 90 days by default; --keep-last accepts days or a',
47
+ ' positive count. Rotating everything requires --all. Malformed lines stay',
48
+ ' inline unless --all is used. Archives are private .jsonl files beneath',
49
+ ' archive/decision/ in the resolved artifact root; --to must stay there.',
50
+ ' `history`/`stats` optionally read archives with --include-archives, offline.',
51
+ ' Appenders, rotation and readers share one cross-process lock.',
52
+ ' The ledger trusts only the local filesystem under the project',
48
53
  ' artifact root, owned by the same user running aiwg — it is not a defense',
49
54
  ' against a hostile process already running as that user.',
50
55
  ].join('\n');
@@ -207,7 +212,7 @@ function parseGroupBy(value) {
207
212
  return value;
208
213
  }
209
214
  const HISTORY_VALUE_FLAGS = ['--since', '--provider', '--session', '--kind', '--status', '--caller'];
210
- const HISTORY_BOOL_FLAGS = ['--json', '--no-caller'];
215
+ const HISTORY_BOOL_FLAGS = ['--json', '--no-caller', '--include-archives'];
211
216
  const STATS_VALUE_FLAGS = [...HISTORY_VALUE_FLAGS, '--group-by'];
212
217
  const STATS_BOOL_FLAGS = HISTORY_BOOL_FLAGS;
213
218
  /**
@@ -269,6 +274,7 @@ function parseLedgerFilters(args) {
269
274
  kind: parseKindFilter(takeOption(args, '--kind')),
270
275
  status: parseStatusFilter(takeOption(args, '--status')),
271
276
  ...parseCallerFilter(args),
277
+ ...(args.includes('--include-archives') ? { includeArchives: true } : {}),
272
278
  };
273
279
  }
274
280
  /** One header line stating the caller filter, shown only when one is set. */
@@ -323,6 +329,23 @@ async function decisionStats(ctx) {
323
329
  ]);
324
330
  return { exitCode: 0, message: [...header, ...lines].join('\n'), rawOutput: true };
325
331
  }
332
+ async function decisionRotate(ctx) {
333
+ const args = ctx.args.slice(1);
334
+ assertLedgerArgs(args, ['--keep-last', '--to'], ['--all', '--json']);
335
+ const keepLast = args.includes('--keep-last') ? takeRequiredOption(args, '--keep-last') : undefined;
336
+ const all = args.includes('--all');
337
+ try {
338
+ parseDecisionLedgerRetention(keepLast, all);
339
+ }
340
+ catch (error) {
341
+ throw new DecisionAskUsageError('invalid-retention', error instanceof Error ? error.message : String(error));
342
+ }
343
+ const result = await rotateDecisionLedger({ projectRoot: ctx.cwd, keepLast, all,
344
+ to: args.includes('--to') ? takeRequiredOption(args, '--to') : undefined });
345
+ if (args.includes('--json'))
346
+ return jsonResult(result);
347
+ return { exitCode: 0, rawOutput: true, message: `Archived ${result.archived} line(s)${result.archivePath ? ` to ${result.archivePath}` : ''}; retained ${result.retained} line(s) inline (${result.malformed} malformed).` };
348
+ }
326
349
  async function executeDecision(ctx) {
327
350
  const [command, subcommand, ...rest] = ctx.args;
328
351
  const options = { cwd: ctx.cwd, frameworkRoot: ctx.frameworkRoot, env: process.env };
@@ -335,6 +358,8 @@ async function executeDecision(ctx) {
335
358
  return await decisionAsk(ctx);
336
359
  if (command === 'history')
337
360
  return await decisionHistory(ctx);
361
+ if (command === 'rotate')
362
+ return await decisionRotate(ctx);
338
363
  if (command === 'pricing') {
339
364
  const args = ctx.args.slice(1);
340
365
  assertLedgerArgs(args, ['--model'], ['--refresh', '--dry-run', '--offline', '--allow-snapshot', '--json']);
@@ -21,9 +21,22 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
21
21
  import { createHash, randomUUID } from 'crypto';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { projectAiwgPath } from '../../config/project-artifacts.js';
24
+ import { resolveSensitiveInput, sensitiveInputOptions } from '../../auth/sensitive-input.js';
24
25
  const DEFAULT_PORT = 8200;
25
26
  const DEFAULT_BIND = '127.0.0.1';
26
27
  const DEFAULT_AIWG_SERVE = 'http://127.0.0.1:7337';
28
+ /** Optional input: no source keeps registration/cache behavior, even at a terminal. */
29
+ export async function resolveLocalExecutorToken(args, dependencies = {}) {
30
+ const source = sensitiveInputOptions(args, 'token');
31
+ const environment = dependencies.environment ?? process.env;
32
+ const explicit = source.interactive || source.stdin || source.file !== undefined || source.store !== undefined || source.env !== undefined;
33
+ if (!explicit) {
34
+ if (!environment.AIWG_EXECUTOR_TOKEN)
35
+ return undefined;
36
+ source.env = 'AIWG_EXECUTOR_TOKEN';
37
+ }
38
+ return resolveSensitiveInput(source, { ...dependencies, environment });
39
+ }
27
40
  /**
28
41
  * Resolve or generate-once the executor identity.
29
42
  * Written to `.aiwg/local-executor/identity.json` so restarts reuse the same
@@ -122,6 +135,14 @@ export const localExecutorServeHandler = {
122
135
  // The subcommand is: `aiwg local-executor serve [flags]`
123
136
  // ctx.args[0] === 'serve' (consumed by router) or flags directly
124
137
  const flagArgs = ctx.args[0] === 'serve' ? ctx.args.slice(1) : ctx.args;
138
+ let preexistingToken;
139
+ try {
140
+ preexistingToken = await resolveLocalExecutorToken(flagArgs);
141
+ }
142
+ catch (error) {
143
+ return { exitCode: 1, message: error.message };
144
+ }
145
+ const redact = (message) => preexistingToken ? message.split(preexistingToken).join('[REDACTED]') : message;
125
146
  const opts = parseLocalExecutorArgs(flagArgs);
126
147
  warnIfNonLoopback(opts.bind);
127
148
  // ── Dynamic imports (same pattern as serve.ts) ──────────────────────────
@@ -138,7 +159,7 @@ export const localExecutorServeHandler = {
138
159
  }
139
160
  catch (err) {
140
161
  return {
141
- message: `Failed to load executor-shim: ${err.message}`,
162
+ message: `Failed to load executor-shim: ${redact(err.message)}`,
142
163
  exitCode: 1,
143
164
  };
144
165
  }
@@ -148,7 +169,7 @@ export const localExecutorServeHandler = {
148
169
  }
149
170
  catch (err) {
150
171
  return {
151
- message: `Failed to load DaemonSupervisor: ${err.message}`,
172
+ message: `Failed to load DaemonSupervisor: ${redact(err.message)}`,
152
173
  exitCode: 1,
153
174
  };
154
175
  }
@@ -208,7 +229,7 @@ export const localExecutorServeHandler = {
208
229
  restBase,
209
230
  wsBase,
210
231
  aiwgServeUrl: opts.aiwgServe,
211
- token: process.env['AIWG_EXECUTOR_TOKEN'],
232
+ token: preexistingToken,
212
233
  tokenPath: projectAiwgPath(process.cwd(), 'local-executor', 'tokens', `${createHash('sha256').update(`${opts.aiwgServe}\0${opts.executorId}`).digest('hex')}.json`),
213
234
  });
214
235
  // Register with aiwg-serve
@@ -218,7 +239,7 @@ export const localExecutorServeHandler = {
218
239
  }
219
240
  catch (err) {
220
241
  return {
221
- message: `Failed to register with aiwg-serve at ${opts.aiwgServe}: ${err.message}\n` +
242
+ message: `Failed to register with aiwg-serve at ${opts.aiwgServe}: ${redact(err.message)}\n` +
222
243
  'Is aiwg serve running? Start it with: aiwg serve --no-open',
223
244
  exitCode: 1,
224
245
  };
@@ -235,7 +256,7 @@ export const localExecutorServeHandler = {
235
256
  catch (err) {
236
257
  await shim.deregister();
237
258
  return {
238
- message: `Failed to start executor server: ${err.message}`,
259
+ message: `Failed to start executor server: ${redact(err.message).split(token).join('[REDACTED]')}`,
239
260
  exitCode: 1,
240
261
  };
241
262
  }
@@ -304,9 +325,15 @@ export const localExecutorHandler = {
304
325
  ' --aiwg-serve <url> Registration target (default: http://127.0.0.1:7337)\n' +
305
326
  ' --max-concurrency <n> Max concurrent missions (default: 4)\n' +
306
327
  ' --executor-id <uuid> Override stable executor ID\n' +
328
+ ' --token-interactive Read optional existing bearer without echo\n' +
329
+ ' --token-stdin Read optional existing bearer from stdin\n' +
330
+ ' --token-file <path> Read optional existing bearer from a private file\n' +
331
+ ' --token-store <name> Read optional existing bearer from the OS store\n' +
332
+ ' --token-env <NAME> Read optional existing bearer from a named variable\n' +
307
333
  '\n' +
308
334
  'Environment:\n' +
309
- ' AIWG_SERVE_ENDPOINT Override --aiwg-serve default\n');
335
+ ' AIWG_SERVE_ENDPOINT Override --aiwg-serve default\n' +
336
+ ' AIWG_EXECUTOR_TOKEN Optional preexisting executor bearer\n');
310
337
  return { exitCode: 0 };
311
338
  }
312
339
  return {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OpenRouter fleet spend observation and local activity correlation.
3
3
  *
4
- * Secrets are resolved only from the process environment or the user's
4
+ * Secrets are resolved from named sources or the user's
5
5
  * ~/.config/aiwg/keys directory. Fleet configuration contains references,
6
6
  * never credential values.
7
7
  *
@@ -11,6 +11,7 @@ import { promises as fs } from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { load as loadYaml } from 'js-yaml';
14
+ import { resolveSensitiveInput } from '../auth/sensitive-input.js';
14
15
  const OPENROUTER_API = 'https://openrouter.ai/api/v1';
15
16
  const KEY_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
16
17
  const SECRET_FIELD_PATTERN = /(?:api[_-]?key|token|secret|credential|authorization)/i;
@@ -60,6 +61,9 @@ function validateFleetConfig(value) {
60
61
  if (!KEY_REF_PATTERN.test(entry.key_ref)) {
61
62
  throw new Error(`fleet[${index}].key_ref contains unsupported characters.`);
62
63
  }
64
+ if (entry.key_store !== undefined && (typeof entry.key_store !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(entry.key_store))) {
65
+ throw new Error(`fleet[${index}].key_store must name a native-store entry (1–128 letters, digits, dots, underscores or hyphens).`);
66
+ }
63
67
  if (typeof entry.monthly_cap !== 'number' || !Number.isFinite(entry.monthly_cap) || entry.monthly_cap < 0) {
64
68
  throw new Error(`fleet[${index}].monthly_cap must be a non-negative number.`);
65
69
  }
@@ -73,6 +77,7 @@ function validateFleetConfig(value) {
73
77
  bot: entry.bot.trim(),
74
78
  machine: entry.machine.trim(),
75
79
  key_ref: entry.key_ref,
80
+ ...(entry.key_store !== undefined ? { key_store: entry.key_store } : {}),
76
81
  monthly_cap: entry.monthly_cap,
77
82
  };
78
83
  });
@@ -101,10 +106,16 @@ export async function loadFleetKey(keyRef, options = {}) {
101
106
  if (!KEY_REF_PATTERN.test(keyRef))
102
107
  throw new Error('Invalid key_ref.');
103
108
  const env = options.env ?? process.env;
109
+ const dependencies = { environment: env, readStore: options.readStore, isTTY: false };
110
+ // An explicit reference is scoped to this entry. Never probe a global key or
111
+ // another bot's store when the selected source is unavailable.
112
+ if (options.store !== undefined) {
113
+ return resolveSensitiveInput({ prefix: 'key', store: options.store }, dependencies);
114
+ }
104
115
  const environmentName = keyEnvironmentName(keyRef);
105
116
  const environmentValue = env[environmentName]?.trim();
106
117
  if (environmentValue)
107
- return environmentValue;
118
+ return resolveSensitiveInput({ prefix: 'key', env: environmentName }, dependencies);
108
119
  const homeDir = options.homeDir ?? os.homedir();
109
120
  const keyDirectory = path.resolve(homeDir, '.config', 'aiwg', 'keys');
110
121
  const keyPath = path.resolve(keyDirectory, keyRef);
@@ -116,7 +127,7 @@ export async function loadFleetKey(keyRef, options = {}) {
116
127
  }
117
128
  catch (error) {
118
129
  if (error.code === 'ENOENT') {
119
- throw new Error(`No secure credential directory exists for key_ref '${keyRef}'; use ${environmentName} or create ~/.config/aiwg/keys with mode 0700.`);
130
+ throw new Error(`No secure credential directory exists for key_ref '${keyRef}'; use ${environmentName}, --key-store <name> (fleet: key_store), or create ~/.config/aiwg/keys with mode 0700.`);
120
131
  }
121
132
  throw error;
122
133
  }
@@ -129,27 +140,12 @@ export async function loadFleetKey(keyRef, options = {}) {
129
140
  if (typeof process.getuid === 'function' && directoryStat.uid !== process.getuid()) {
130
141
  throw new Error('The AIWG credential directory must be owned by the current user.');
131
142
  }
132
- let stat;
133
143
  try {
134
- stat = await fs.lstat(keyPath);
144
+ return await resolveSensitiveInput({ prefix: 'key', file: keyPath }, dependencies);
135
145
  }
136
146
  catch (error) {
137
- if (error.code === 'ENOENT') {
138
- throw new Error(`No credential found for key_ref '${keyRef}' in the secure key directory or ${environmentName}.`);
139
- }
140
- throw error;
141
- }
142
- if (stat.isSymbolicLink() || !stat.isFile())
143
- throw new Error(`Credential for key_ref '${keyRef}' must be a regular file, not a symlink.`);
144
- if ((stat.mode & 0o077) !== 0)
145
- throw new Error(`Credential file for key_ref '${keyRef}' must not be accessible by group or other users.`);
146
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
147
- throw new Error(`Credential file for key_ref '${keyRef}' must be owned by the current user.`);
147
+ throw new Error(`Credential for key_ref '${keyRef}': ${error.message} Use ${environmentName}, --key-store <name> (fleet: key_store), or a private ~/.config/aiwg/keys/<key_ref> file.`);
148
148
  }
149
- const value = (await fs.readFile(keyPath, 'utf8')).trim();
150
- if (!value)
151
- throw new Error(`Credential file for key_ref '${keyRef}' is empty.`);
152
- return value;
153
149
  }
154
150
  function parseActivityLog(raw) {
155
151
  const entries = [];
@@ -202,7 +198,9 @@ async function openRouterGet(endpoint, key, options) {
202
198
  }
203
199
  if (!payload || typeof payload !== 'object' || !payload.data)
204
200
  throw new Error('OpenRouter returned an invalid response.');
205
- return payload.data;
201
+ // Generation metadata can reflect a request header in free-text fields. Keep
202
+ // such content out of both structured and human-readable reports.
203
+ return JSON.parse(JSON.stringify(payload.data, (_name, value) => (typeof value === 'string' ? value.split(key).join('[REDACTED]') : value)));
206
204
  }
207
205
  function correlateGenerations(records) {
208
206
  const sessions = new Map();
@@ -223,7 +221,7 @@ function correlateGenerations(records) {
223
221
  }
224
222
  async function reportBot(entry, activity, options) {
225
223
  try {
226
- const key = await loadFleetKey(entry.key_ref, options);
224
+ const key = await loadFleetKey(entry.key_ref, { ...options, store: entry.key_store });
227
225
  const keyData = await openRouterGet('/key', key, options);
228
226
  const spend = Number(keyData.usage_monthly ?? keyData.usage ?? 0);
229
227
  if (!Number.isFinite(spend) || spend < 0)
@@ -4,12 +4,12 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { askDecision, setupJev, DecisionAskUsageError, readDecisionContextFile, validateDecisionAskInput, } from './ask.js';
7
- import { recordDecisionAsk, decisionLedgerHistory, decisionLedgerStats, resolveDecisionSessionId, resolveDecisionSession, DECISION_SESSION_ENV_VARS, parseDecisionLedgerSince, DecisionLedgerSinceError, DECISION_LEDGER_SCHEMA, DECISION_LEDGER_SCHEMA_V1, ATTRIBUTION_FIELD_PATTERN, MAX_ATTRIBUTION_FIELD_LENGTH, MAX_RECORDED_CHOICE_IDS, MAX_RECORD_TEXT_BYTES, MAX_LEDGER_RECORD_BYTES, DECISION_LEDGER_RECORD_TIMEOUT_MS, DECISION_SAVINGS_REASONING_ALLOWANCE_TOKENS, DECISION_SAVINGS_ESTIMATION_METHOD, buildDecisionLedgerRecord, appendDecisionLedgerRecord, filterDecisionLedgerRecords, } from './ledger.js';
7
+ import { recordDecisionAsk, decisionLedgerHistory, decisionLedgerStats, rotateDecisionLedger, parseDecisionLedgerRetention, resolveDecisionSessionId, resolveDecisionSession, DECISION_SESSION_ENV_VARS, parseDecisionLedgerSince, DecisionLedgerSinceError, DECISION_LEDGER_SCHEMA, DECISION_LEDGER_SCHEMA_V1, ATTRIBUTION_FIELD_PATTERN, MAX_ATTRIBUTION_FIELD_LENGTH, MAX_RECORDED_CHOICE_IDS, MAX_RECORD_TEXT_BYTES, MAX_LEDGER_RECORD_BYTES, DECISION_LEDGER_RECORD_TIMEOUT_MS, DECISION_SAVINGS_REASONING_ALLOWANCE_TOKENS, DECISION_SAVINGS_ESTIMATION_METHOD, buildDecisionLedgerRecord, appendDecisionLedgerRecord, filterDecisionLedgerRecords, } from './ledger.js';
8
8
  export { DEFAULT_DECISION_SAVINGS_WEIGHTS, decisionSavingsWeights } from './savings.js';
9
9
  import { resolveActiveProvider } from '../cli/provider-resolution.js';
10
10
  export { appendDecisionOpportunity, buildDecisionOffloadSnapshot, decisionOffloadCategory, DECISION_OFFLOAD_CHECKPOINT_COVERAGE, DECISION_OFFLOAD_EXECUTABLE_COVERAGE, DECISION_OFFLOAD_SNAPSHOT_SCHEMA, DECISION_OPPORTUNITY_SCHEMA, runDecisionOffloadCheckpoint, } from './offload.js';
11
11
  import { assertDecisionEvaluateDispatcherConfig, decisionPatternPacks, getDecisionPatternPack, governedPatternArtifacts, listDecisionPatterns, patternInputSchema, patternOutputSchema, planLiveDecisionPattern, runOfflineDecisionPattern, validateAgainstSchema, validateDecisionDocument, validateDefinition, validateRuleset, } from './index.js';
12
- export { askDecision, setupJev, readDecisionContextFile, validateDecisionAskInput, DecisionAskUsageError, recordDecisionAsk, decisionLedgerHistory, decisionLedgerStats, resolveDecisionSessionId, resolveDecisionSession, DECISION_SESSION_ENV_VARS, parseDecisionLedgerSince, DecisionLedgerSinceError, DECISION_LEDGER_SCHEMA, DECISION_LEDGER_SCHEMA_V1, ATTRIBUTION_FIELD_PATTERN, MAX_ATTRIBUTION_FIELD_LENGTH, MAX_RECORDED_CHOICE_IDS, MAX_RECORD_TEXT_BYTES, MAX_LEDGER_RECORD_BYTES, DECISION_LEDGER_RECORD_TIMEOUT_MS, DECISION_SAVINGS_REASONING_ALLOWANCE_TOKENS, DECISION_SAVINGS_ESTIMATION_METHOD, buildDecisionLedgerRecord, appendDecisionLedgerRecord, filterDecisionLedgerRecords, resolveActiveProvider, };
12
+ export { askDecision, setupJev, readDecisionContextFile, validateDecisionAskInput, DecisionAskUsageError, recordDecisionAsk, decisionLedgerHistory, decisionLedgerStats, rotateDecisionLedger, parseDecisionLedgerRetention, resolveDecisionSessionId, resolveDecisionSession, DECISION_SESSION_ENV_VARS, parseDecisionLedgerSince, DecisionLedgerSinceError, DECISION_LEDGER_SCHEMA, DECISION_LEDGER_SCHEMA_V1, ATTRIBUTION_FIELD_PATTERN, MAX_ATTRIBUTION_FIELD_LENGTH, MAX_RECORDED_CHOICE_IDS, MAX_RECORD_TEXT_BYTES, MAX_LEDGER_RECORD_BYTES, DECISION_LEDGER_RECORD_TIMEOUT_MS, DECISION_SAVINGS_REASONING_ALLOWANCE_TOKENS, DECISION_SAVINGS_ESTIMATION_METHOD, buildDecisionLedgerRecord, appendDecisionLedgerRecord, filterDecisionLedgerRecords, resolveActiveProvider, };
13
13
  const DECISION_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
14
14
  const VALIDATION_TARGETS = ['request', 'definition', 'ruleset', 'binding'];
15
15
  const OFFLINE_ADAPTER = 'agentic/code/addons/decision-engine/examples/fixture-jev-adapter.mjs';
@@ -1,10 +1,11 @@
1
1
  import { resolveDecisionEnablement } from '../config/decision-config.js';
2
2
  import { constants } from 'node:fs';
3
- import { access, chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { access, chmod, lstat, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { JevCredentialError } from './adapters/jev.js';
8
+ import { readPrivateValueFile } from '../auth/sensitive-input.js';
8
9
  export const JEV_CREDENTIAL_REF = 'typesafe-api';
9
10
  export const JEV_DEFAULT_REGION = 'us';
10
11
  function env(options) {
@@ -45,10 +46,22 @@ function parseRecord(raw) {
45
46
  async function readCredentialFile(options = {}) {
46
47
  const credentialPath = jevCredentialPath(options);
47
48
  try {
48
- const [fileStat, parentStat] = await Promise.all([stat(credentialPath), stat(path.dirname(credentialPath))]);
49
- if (isInsecureMode(fileStat.mode) || isInsecureMode(parentStat.mode))
50
- throw new JevCredentialError('denied');
51
- return parseRecord(await readFile(credentialPath, 'utf8'));
49
+ // Preserve the missing-file result and the existing directory requirement.
50
+ // The shared reader validates the opened descriptor, including ownership
51
+ // and link checks, before any credential record is parsed.
52
+ const [, parentStat] = await Promise.all([lstat(credentialPath), stat(path.dirname(credentialPath))]);
53
+ if (isInsecureMode(parentStat.mode)) {
54
+ throw Object.assign(new JevCredentialError('denied'), { message: 'Jev credential directory must not allow group or other access (chmod 700).' });
55
+ }
56
+ let raw;
57
+ try {
58
+ raw = await readPrivateValueFile(credentialPath, 'Jev credential file');
59
+ }
60
+ catch (error) {
61
+ // Shared private-file diagnostics contain no path or credential bytes.
62
+ throw Object.assign(new JevCredentialError('denied'), { message: error.message });
63
+ }
64
+ return parseRecord(raw);
52
65
  }
53
66
  catch (error) {
54
67
  if (error.code === 'ENOENT')