@cspeach/cli 1.1.33 → 1.1.35

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.
Files changed (119) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +182 -39
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -1,15 +1,15 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
2
  import keychain from '../../auth/keychain.js';
3
- import { createManifestSkillSource } from '../../skills/source-manifest.js';
4
- import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
3
+ import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
5
4
  import { delegateSkillName, modelAcceptsFallbacks, requiredBetas, requireSkillName, withoutInternalHeaders, withSkillPrompt } from '../provider-shape.js';
6
5
  import { systemPromptForSkill } from '../system-prompt.js';
6
+ import { BYOK_PROFILE } from '../connection-profile.js';
7
7
  const KEYCHAIN_SERVICE = 'cspeach.anthropic';
8
8
  const KEYCHAIN_ACCOUNT = 'api-key';
9
9
  // Cheapest Anthropic model available at doctor-probe time.
10
10
  const HEALTH_PROBE_MODEL = 'claude-haiku-4-6-latest';
11
- export function createByokProvider(_cfg) {
12
- const skillSource = createManifestSkillSource(DEFAULT_MANIFEST_URL);
11
+ export function createByokProvider(cfg, getBearer = defaultBearer) {
12
+ const skillSource = createAuthedSkillSource(cfg, getBearer);
13
13
  // Task 5 (ruling F3) — the loop's request is provider-neutral; BYOK talks to
14
14
  // the model directly, so it adds what the proxy adds for managed: preamble +
15
15
  // skill body as `system`, cache markers, and the betas matching the fields
@@ -19,11 +19,11 @@ export function createByokProvider(_cfg) {
19
19
  const skillName = requireSkillName(options, 'BYOK');
20
20
  const key = await getKey();
21
21
  // Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
22
- const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: true }, delegateSkillName(options));
22
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: BYOK_PROFILE.cacheMarkers }, delegateSkillName(options));
23
23
  // Task 6 — opt into server-side fallback, only for the families probe
24
24
  // P3 proved (allow-list); requiredBetas adds its beta.
25
- const prompted = withSkillPrompt(params, system, { cacheMarkers: true });
26
- const shaped = modelAcceptsFallbacks(params.model)
25
+ const prompted = withSkillPrompt(params, system, { cacheMarkers: BYOK_PROFILE.cacheMarkers });
26
+ const shaped = BYOK_PROFILE.serverFallbacks && modelAcceptsFallbacks(params.model)
27
27
  ? { ...prompted, fallbacks: 'default' }
28
28
  : prompted;
29
29
  const betas = requiredBetas(shaped);
@@ -34,6 +34,7 @@ export function createByokProvider(_cfg) {
34
34
  return {
35
35
  name: 'anthropic-byok',
36
36
  mode: 'byok',
37
+ profile: BYOK_PROFILE,
37
38
  skillSource,
38
39
  async createStream(params, options = {}) {
39
40
  const { sdk, body } = await shapeRequest(params, options);
@@ -2,12 +2,17 @@ import { createManagedProvider } from '../anthropic-provider.js';
2
2
  import { createByokProvider } from './byok-provider.js';
3
3
  import { createAiHubProvider } from './ai-hub-provider.js';
4
4
  import { createLocalProvider } from './local-provider.js';
5
+ import { createAzureProvider } from './azure-provider.js';
5
6
  export function createProviderForMode(cfg, getBearer) {
6
7
  switch (cfg.llm.mode) {
7
8
  case 'managed': return createManagedProvider(cfg, getBearer);
8
- case 'byok': return createByokProvider(cfg);
9
- case 'ai-hub': return createAiHubProvider(cfg);
10
- case 'local': return createLocalProvider(cfg);
9
+ // Security batch item 2 — the CSPeach bearer now reaches every mode: skill
10
+ // bodies come from the signed-in route.
11
+ case 'byok': return createByokProvider(cfg, getBearer);
12
+ case 'ai-hub': return createAiHubProvider(cfg, getBearer);
13
+ case 'local': return createLocalProvider(cfg, getBearer);
14
+ // Azure tenant mode — the company's own Foundry resource (Task 5a).
15
+ case 'azure': return createAzureProvider(cfg, getBearer);
11
16
  default: throw new Error(`unknown llm.mode: ${cfg.llm.mode}`);
12
17
  }
13
18
  }
@@ -1,28 +1,29 @@
1
1
  // Loaded on use (fix round 3, item 11e): only local mode needs the OpenAI SDK.
2
2
  const loadOpenAI = async () => (await import('openai')).default;
3
3
  import crypto from 'node:crypto';
4
- import { sha256 } from '@noble/hashes/sha256';
5
- import { bytesToHex } from '@noble/hashes/utils';
6
- import { createBundledSkillSource } from '../../skills/source-bundled.js';
7
- import { BUNDLED_SKILLS } from '../../skills/bundled-skills.js';
4
+ import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
8
5
  import { delegateSkillName, requireSkillName } from '../provider-shape.js';
9
6
  import { systemPromptForSkill } from '../system-prompt.js';
10
- export function createLocalProvider(cfg) {
7
+ import { LOCAL_PROFILE } from '../connection-profile.js';
8
+ export function createLocalProvider(cfg, getBearer = defaultBearer) {
11
9
  if (!cfg.llm.local_base_url) {
12
10
  throw new Error('local mode requires llm.local_base_url — run: cspeach config set llm.local_base_url http://localhost:11434/v1');
13
11
  }
14
12
  const baseURL = cfg.llm.local_base_url;
15
13
  const model = cfg.llm.local_model ?? 'llama3.1:70b';
16
- const skillSource = createBundledSkillSource();
14
+ // Security batch item 2 — local follows the same rule as byok / ai-hub:
15
+ // sign in once, skills cached on disk, then offline within the 72-hour grace.
16
+ const skillSource = createAuthedSkillSource(cfg, getBearer);
17
17
  return {
18
18
  name: 'local-openai-compat',
19
19
  mode: 'local',
20
+ profile: LOCAL_PROFILE,
20
21
  skillSource,
21
22
  async createStream(params, options = {}) {
22
23
  if (params.tools && params.tools.length > 0) {
23
24
  throw new Error('Tool calling not supported in local mode (Plan A). Switch to managed/byok/ai-hub, or wait for the local-mode tool-calling follow-up PR.');
24
25
  }
25
- // Task 5 (ruling F3) — preamble + bundled skill body as the system
26
+ // Task 5 (ruling F3) — preamble + skill body as the system
26
27
  // message (no cache markers). anthropicParamsToOpenAI copies only
27
28
  // max_tokens / messages / system, so newer fields never reach the endpoint.
28
29
  const skillName = requireSkillName(options, 'Local');
@@ -49,24 +50,19 @@ export function createLocalProvider(cfg) {
49
50
  catch (e) {
50
51
  return { ok: false, message: `Local endpoint unreachable: ${e.message}`, hint: `Is Ollama/vLLM running at ${baseURL}? Is ${model} loaded?` };
51
52
  }
52
- const bundled = Object.entries(BUNDLED_SKILLS);
53
- if (bundled.length === 0) {
54
- return { ok: false, message: 'No skills bundled in this CLI build', hint: 'This CLI was built without running `npm run prebuild`. Rebuild from source or install the published CLI.' };
53
+ // Key presence only (no network): a signed-out seat cannot load skills.
54
+ const key = await getBearer().catch(() => '');
55
+ if (typeof key !== 'string' || key.trim() === '') {
56
+ return { ok: false, message: 'Local endpoint responding, but CSPeach is not signed in, so skills cannot be loaded.', hint: 'Run cspeach login once while online; skills are then kept on disk for offline use.' };
55
57
  }
56
- const mismatches = [];
57
- for (const [name, body] of bundled) {
58
- const actual = bytesToHex(sha256(new TextEncoder().encode(body.body)));
59
- if (actual !== body.sha256)
60
- mismatches.push(`${name} (manifest=${body.sha256}, actual=${actual})`);
58
+ let names;
59
+ try {
60
+ names = await skillSource.listAvailableSkills();
61
61
  }
62
- if (mismatches.length > 0) {
63
- return {
64
- ok: false,
65
- message: `bundled skill(s) sha256 mismatch: ${mismatches.join('; ')}`,
66
- hint: 'CLI binary may have been tampered with; reinstall from a trusted source.',
67
- };
62
+ catch (e) {
63
+ return { ok: false, message: `Local endpoint responding, but skills could not be loaded: ${e.message}`, hint: 'Run cspeach login once while online; skills are then kept on disk for offline use.' };
68
64
  }
69
- return { ok: true, message: `Local model ${model} at ${baseURL} responding; ${bundled.length} skills bundled (sha256 verified)` };
65
+ return { ok: true, message: `Local model ${model} at ${baseURL} responding; ${names.length} skills available to this signed-in seat` };
70
66
  },
71
67
  };
72
68
  }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Security batch item 4 — a risk gate of its own, in tool-dispatch.ts. Plan
3
+ * approval, batch approval and auto-approve never skip it: it does not read
4
+ * any of them. Headless runs (one-shot, CI) decline, and say so (via
5
+ * presentSafetyConfirmation).
6
+ *
7
+ * Ruling F16: network tools (curl, wget, Invoke-WebRequest, nc …) get the card
8
+ * whether or not they are on the shell allow-list. The gate runs before the
9
+ * tool's own allow-list check, so a network tool a user has added asks first,
10
+ * and one that is not on the list asks first and is then refused by the tool.
11
+ *
12
+ * Fix round 1: background_run is gated like shell_exec (F1); npm/pnpm
13
+ * run-anything subcommands and npx are flagged as wrappers (F3); node's
14
+ * `--import data:` (F4) and git's command-running subcommands (F5) are
15
+ * flagged; git is left out of the deploy/publish word rule (F6); node's
16
+ * options end at the script (F7). grep and glob skip credential files
17
+ * instead of asking (F2) — stricter than a card, a deliberate choice.
18
+ *
19
+ * Fix round 2: the npm/pnpm subcommand is found cautiously, past any word
20
+ * that could be an option's value (N1); git's long-option prefixes and short
21
+ * bundles (`--exe`, `-ix`) are read (N2); a git subcommand named deploy or
22
+ * publish is flagged again (L1).
23
+ *
24
+ * Honest limit: these are argv rules. `npm run <script>`, `npx` and
25
+ * `node <file>` running model-written code can get past them. Site copy says
26
+ * "known risky patterns".
27
+ */
28
+ import path from 'node:path';
29
+ import { BLOCKED_BASENAME_RE } from '../tools/_filesystem-shared.js';
30
+ const NETWORK_TOOLS = new Set(['curl', 'wget', 'invoke-webrequest', 'iwr', 'nc', 'ncat', 'netcat']);
31
+ const NODE_EVAL_FLAGS = new Set(['-e', '--eval', '-p', '--print']);
32
+ /** git global options that take a separate value (skipped when finding the subcommand). */
33
+ const GIT_VALUE_OPTIONS = new Set(['-C', '--git-dir', '--work-tree', '--namespace']);
34
+ /** npm / pnpm subcommands that run any program, or download and run a package (F3). */
35
+ const RUN_ANY_SUBCOMMANDS = new Set(['exec', 'x', 'dlx', 'explore']);
36
+ const CREDENTIAL_BASENAMES = new Set(['.git-credentials', '.npmrc']);
37
+ const CREDENTIAL_EXTS = new Set(['.pem', '.key']);
38
+ export const FILE_READ_TOOLS = new Set(['file_read', 'read_document']);
39
+ /**
40
+ * The bare program name, as Windows would resolve it: surrounding whitespace
41
+ * and quotes dropped, any folder dropped (either slash), lower-cased, and a
42
+ * Windows program extension dropped. Display uses the command as given.
43
+ */
44
+ const commandName = (cmd) => path.basename(cmd.trim().replace(/^["']+|["']+$/g, '').trim().replace(/\\/g, '/'))
45
+ .toLowerCase()
46
+ .replace(/\.(exe|cmd|bat|ps1)$/, '');
47
+ /** Show an arg with whitespace (or an empty arg) quoted, so the card reads as the argv it is (fix round 1, F9). Never redacted. */
48
+ const showArg = (a) => (a.length === 0 || /\s/.test(a) ? JSON.stringify(a) : a);
49
+ /**
50
+ * node runs inline code: `-e`/`--eval`/`-p`/`--print` (any spelling), or an
51
+ * `--import`/`--loader`/`--experimental-loader` whose value is a `data:` URL.
52
+ * Only node's own options count: scanning stops at `--` and at the script
53
+ * (the first positional that cannot be the value of the option before it),
54
+ * so the script's own args (`node build.js -prod`) are not read (F7, F4).
55
+ */
56
+ function nodeRunsInlineCode(args) {
57
+ for (let i = 0; i < args.length; i++) {
58
+ const a = args[i];
59
+ if (a === '--')
60
+ return false;
61
+ if (!a.startsWith('-') || a === '-') {
62
+ const prev = i > 0 ? args[i - 1] : undefined;
63
+ // The script — unless it may be the value of a dash option before it.
64
+ if (prev === undefined || !prev.startsWith('-') || prev.includes('='))
65
+ return false;
66
+ continue;
67
+ }
68
+ if (NODE_EVAL_FLAGS.has(a) || /^--(eval|print)=/.test(a) || /^-[a-z]*[ep][a-z]*$/i.test(a))
69
+ return true;
70
+ const m = /^--(import|loader|experimental-loader)(?:=(.*))?$/.exec(a);
71
+ if (m) {
72
+ const v = m[2] ?? args[i + 1];
73
+ if (typeof v === 'string' && /^\s*data:/i.test(v))
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+ /** git subcommands (after the global options) that run a command string (F5). */
80
+ function gitRunsCommand(sub, rest) {
81
+ switch (sub) {
82
+ // Fix round 2 (N2): git takes any unambiguous prefix of a long option
83
+ // (`--ex`, `--exe`) and bundled short options (`-ix`). A lowercase x in a
84
+ // short bundle is --exec; capital -X is the merge-strategy option.
85
+ case 'rebase':
86
+ return rest.some((a) => /^-[a-z]*x/.test(a) || /^--ex(e(c)?)?(=|$)/.test(a));
87
+ case 'bisect':
88
+ return rest[0] === 'run';
89
+ case 'submodule':
90
+ return rest.includes('foreach');
91
+ case 'filter-branch':
92
+ return true;
93
+ case 'difftool':
94
+ case 'mergetool':
95
+ // difftool has no other long option starting with `e`, so `--e` is enough.
96
+ return rest.some((a) => /^-[a-z]*x/.test(a) || /^--e(x(t(c(m(d)?)?)?)?)?(=|$)/.test(a));
97
+ default:
98
+ return false;
99
+ }
100
+ }
101
+ /**
102
+ * Fix round 2 (N1) — npm / pnpm's subcommand, read cautiously. Any option
103
+ * that is not a boolean takes the next word as its value (`--loglevel
104
+ * silent`, `--cache c`, `--reporter silent` …), and no hand-kept list can
105
+ * name them all. So every positional that could be the value of the dash
106
+ * option before it is a candidate, and the walk stops at the first positional
107
+ * that cannot be a value: the definite subcommand. Returns every candidate up
108
+ * to and including it (and the word after `--`). Extra card, never a miss:
109
+ * a value literally named `exec`/`x`/`dlx`/`explore` is flagged too.
110
+ */
111
+ function packageManagerSubcommands(args) {
112
+ const out = [];
113
+ for (let i = 0; i < args.length; i++) {
114
+ const a = args[i];
115
+ if (a === '--') {
116
+ if (args[i + 1] !== undefined)
117
+ out.push(args[i + 1]);
118
+ break;
119
+ }
120
+ if (a.startsWith('-'))
121
+ continue;
122
+ out.push(a);
123
+ const prev = i > 0 ? args[i - 1] : undefined;
124
+ if (prev === undefined || !prev.startsWith('-') || prev.includes('='))
125
+ break;
126
+ }
127
+ return out.map((s) => s.toLowerCase());
128
+ }
129
+ export function shellRisk(command, args) {
130
+ const cmd = commandName(command);
131
+ const subject = [command, ...args].map(showArg).join(' ');
132
+ const flag = (reason) => ({ kind: 'command', subject, reason });
133
+ if (cmd === 'node' && nodeRunsInlineCode(args)) {
134
+ return flag('runs code given on the command line');
135
+ }
136
+ if (cmd === 'git') {
137
+ let i = 0;
138
+ for (; i < args.length && args[i].startsWith('-'); i++) {
139
+ const a = args[i];
140
+ if (a === '-c' || /^-c./.test(a) || a.startsWith('--config-env'))
141
+ return flag('overrides git settings for this command');
142
+ if (GIT_VALUE_OPTIONS.has(a))
143
+ i++;
144
+ }
145
+ if (args[i] === 'config')
146
+ return flag('changes git settings');
147
+ if (gitRunsCommand(args[i], args.slice(i + 1)))
148
+ return flag('runs a command through git');
149
+ // Fix round 2 (L1) — a git subcommand named deploy / publish (a user's
150
+ // alias or a git-publish program). Only the subcommand position: file
151
+ // names, messages and branch names stay quiet (F6).
152
+ if (/deploy|publish/i.test(args[i] ?? ''))
153
+ return flag('deploys or publishes');
154
+ }
155
+ // F3 — the wrapper itself is flagged; the wrapped command is not parsed
156
+ // (`npm exec -c "<shell string>"` cannot be read reliably).
157
+ if (cmd === 'npx' || ((cmd === 'npm' || cmd === 'pnpm') && packageManagerSubcommands(args).some((s) => RUN_ANY_SUBCOMMANDS.has(s)))) {
158
+ return flag('runs a program through npm/pnpm');
159
+ }
160
+ if (NETWORK_TOOLS.has(cmd))
161
+ return flag('can send data to or fetch from the network');
162
+ // F6 — not git: `git add ui5-deploy.yaml` or a branch named for a publish
163
+ // cannot deploy anything (git's upload is `push`, an honest limit).
164
+ if (cmd !== 'git' && [command, ...args].some((a) => /deploy|publish/i.test(a)))
165
+ return flag('deploys or publishes');
166
+ return null;
167
+ }
168
+ export function credentialFileRisk(p) {
169
+ if (typeof p !== 'string' || p.length === 0)
170
+ return null;
171
+ // Windows spellings that open the same file: `.npmrc.`, `.npmrc `, `.npmrc::$DATA`.
172
+ const base = path.posix.basename(p.replace(/\\/g, '/')).split(':')[0].replace(/[. ]+$/, '').toLowerCase();
173
+ const flag = (reason) => ({ kind: 'credential-file', subject: p, reason });
174
+ if (CREDENTIAL_BASENAMES.has(base))
175
+ return flag('this file usually holds a password or token');
176
+ if (base.startsWith('.env') && !BLOCKED_BASENAME_RE.test(base))
177
+ return flag('environment files often hold passwords or keys');
178
+ if (CREDENTIAL_EXTS.has(path.posix.extname(base)))
179
+ return flag('certificate and key files can hold private keys');
180
+ return null;
181
+ }
182
+ /**
183
+ * grep and glob skip credential files (fix round 1, F2): a card cannot be
184
+ * shown mid-walk for a file the model did not name, so they are left out and
185
+ * this one line says so. file_read still reads one, with the card.
186
+ */
187
+ export function credentialSkipNote(n) {
188
+ return `... (${n} credential file${n === 1 ? '' : 's'} skipped: .npmrc, .git-credentials, .env*, *.pem, *.key — use file_read, which asks the user first)`;
189
+ }
190
+ export function riskOf(tool, args) {
191
+ // background_run (category 'subagent') runs the same allow-listed commands
192
+ // with the same {command, args} — fix round 1, F1.
193
+ if (tool.category === 'shell' || tool.name === 'background_run') {
194
+ if (typeof args?.command !== 'string')
195
+ return null;
196
+ const argv = Array.isArray(args?.args) ? args.args.filter((a) => typeof a === 'string') : [];
197
+ return shellRisk(args.command, argv);
198
+ }
199
+ if (FILE_READ_TOOLS.has(tool.name))
200
+ return credentialFileRisk(args?.path);
201
+ return null;
202
+ }
@@ -13,6 +13,8 @@
13
13
  * NOT for tool-using calls. NOT for thinking-enabled calls. Pure text-in /
14
14
  * text-out.
15
15
  */
16
+ import { profileOf } from './connection-profile.js';
17
+ import { familyOf } from '../models/model-identity.js';
16
18
  /**
17
19
  * Run a single non-streaming summarisation call.
18
20
  *
@@ -37,20 +39,65 @@ export async function summariseViaProvider(provider, system, user, opts) {
37
39
  : opts.headers,
38
40
  signal: opts.signal,
39
41
  });
42
+ // Final fix wave B — in azure mode a compaction call is counted like any
43
+ // other azure call (usage record + local limit count). Every other mode
44
+ // reports nothing from here, as before.
45
+ const azure = profileOf(provider).modelIdentity === 'deployment';
46
+ const usage = { started: false, id: undefined, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
47
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
40
48
  let accumulated = '';
41
- for await (const event of stream) {
42
- const type = event?.type;
43
- if (type === 'content_block_delta') {
44
- const delta = event.delta;
45
- if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
46
- accumulated += delta.text;
49
+ try {
50
+ for await (const event of stream) {
51
+ const type = event?.type;
52
+ if (type === 'content_block_delta') {
53
+ const delta = event.delta;
54
+ if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
55
+ accumulated += delta.text;
56
+ }
47
57
  }
58
+ else if (azure && type === 'message_start') {
59
+ const u = event.message?.usage ?? {};
60
+ usage.started = true;
61
+ if (typeof event.message?.id === 'string' && event.message.id)
62
+ usage.id = event.message.id;
63
+ usage.input = num(u.input_tokens);
64
+ usage.cacheRead = num(u.cache_read_input_tokens);
65
+ usage.cacheWrite = num(u.cache_creation_input_tokens);
66
+ usage.output = num(u.output_tokens);
67
+ }
68
+ else if (azure && type === 'message_delta') {
69
+ // output_tokens on message_delta is the running total for the message.
70
+ usage.output = Math.max(usage.output, num(event.usage?.output_tokens));
71
+ }
72
+ // Ignore everything else — message_stop etc.
48
73
  }
49
- // Ignore everything else — message_start/stop, usage events, etc.
50
- // For one-shot text summarisation only content_block_delta matters.
74
+ }
75
+ finally {
76
+ if (azure && usage.started)
77
+ await reportCompactionUsage(provider, opts, usage);
51
78
  }
52
79
  if (!accumulated || accumulated.trim().length === 0) {
53
80
  throw new Error('Summariser returned empty output.');
54
81
  }
55
82
  return accumulated;
56
83
  }
84
+ /** Fire-and-forget, never throws: usage counts are best effort. */
85
+ async function reportCompactionUsage(provider, opts, u) {
86
+ try {
87
+ const { reportCallUsage } = await import('../usage/usage-reporter.js');
88
+ void reportCallUsage({
89
+ mode: 'azure',
90
+ requestId: u.id,
91
+ skill: opts.headers?.['X-CSForge-Skill'] ?? 'compact',
92
+ // The family of the deployment the call went to (the small one for compaction).
93
+ model: familyOf(opts.model, provider),
94
+ input: u.input,
95
+ output: u.output,
96
+ cacheRead: u.cacheRead,
97
+ cacheWrite: u.cacheWrite,
98
+ sessionId: opts.sessionId ?? '',
99
+ started: true,
100
+ }).catch(() => { });
101
+ }
102
+ catch { /* best effort */ }
103
+ }
@@ -8,6 +8,7 @@ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
8
8
  import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
9
9
  import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '../repl/credential-handover-gate.js';
10
10
  import { READER_TOOLS } from '../tools/subagent/reader-prompt.js';
11
+ import { riskOf } from './risk-gate.js';
11
12
  const READER_TOOL_SET = new Set(READER_TOOLS);
12
13
  export async function dispatchTool(name, args, ctx) {
13
14
  const tool = getTool(name);
@@ -68,6 +69,24 @@ export async function dispatchTool(name, args, ctx) {
68
69
  return declined(r);
69
70
  markCredentialHandoverApproved(sessionId, handover.alias, handover.scope);
70
71
  }
72
+ // Security batch item 4 — the risk gate. Its own check, on every call:
73
+ // plan approval, batch approval and auto-approve do not skip it. Known risky
74
+ // shell patterns and reads of credential files ask every time; headless runs
75
+ // decline (presentSafetyConfirmation) and the refusal goes back to the model.
76
+ const risk = riskOf(tool, args);
77
+ if (risk) {
78
+ const r = await presentSafetyConfirmation({
79
+ rule: risk.kind === 'command' ? 'RISKY_COMMAND' : 'CREDENTIAL_FILE_READ',
80
+ op: tool.name,
81
+ object: { type: risk.kind === 'command' ? 'command' : 'file', name: risk.subject },
82
+ what_will_happen: `Flagged: ${risk.reason}`,
83
+ rollback_available: false,
84
+ transport: '',
85
+ args,
86
+ });
87
+ if (!r.confirmed)
88
+ return declined(r);
89
+ }
71
90
  // Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
72
91
  // explicit confirmation. Fires regardless of /safety-mode toggle (batch
73
92
  // detection is unconditional per Plan 3 §6.6).
@@ -21,6 +21,8 @@ import { promises as fs } from 'node:fs';
21
21
  import * as os from 'node:os';
22
22
  import * as path from 'node:path';
23
23
  import { TurnStreamWriter } from './turn-stream.js';
24
+ import { azureSignInTurnLine } from '../connection/azure-turn-error.js';
25
+ import { azureErrorNotice, azureErrorContext } from '../connection/azure-errors.js';
24
26
  export function logsRoot() {
25
27
  return path.join(os.homedir(), '.cspeach', 'logs');
26
28
  }
@@ -204,6 +206,26 @@ export async function renderTurnError(input) {
204
206
  print('');
205
207
  return;
206
208
  }
209
+ // Azure tenant mode (Task 6b) — the Microsoft sign-in ended (or the key is
210
+ // gone) mid-turn: the plain line and the command that fixes it, never a
211
+ // browser or a question here.
212
+ const azure = azureSignInTurnLine(input.err);
213
+ if (azure) {
214
+ printBlock(print, formatFailure(azure));
215
+ print('');
216
+ return;
217
+ }
218
+ // Azure tenant mode (Task 7) — the plain messages for the customer's own
219
+ // Azure (null unless azure settings are active or the error is an azure
220
+ // refusal, so every other mode is unchanged). A refusal the SDK wrapped as
221
+ // "Connection error." is unwrapped here (ruling 4).
222
+ const az = azureErrorNotice(input.err, azureErrorContext());
223
+ if (az) {
224
+ await writeErrorLog(id, input.err);
225
+ printBlock(print, 'preformatted' in az ? az.preformatted : formatFailure({ what: az.what, detail: [`Session: ${id}`], next: az.next }));
226
+ print('');
227
+ return;
228
+ }
207
229
  const reason = describeError(input.err);
208
230
  const logPath = await writeErrorLog(id, input.err);
209
231
  const detail = [reason, `Session: ${id}`];
@@ -1,12 +1,16 @@
1
1
  import keychain, { newProbeAccount } from './keychain.js';
2
+ import { resolveApiKey, keychainAccountForHome, recordStoreWrite } from './key-resolver.js';
2
3
  const SERVICE = 'cspeach';
3
- const ACCOUNT = 'api-key';
4
4
  class KeychainStore {
5
5
  /** One probe per store: repeat checks (repl.tsx) reuse the first answer. */
6
6
  available;
7
- async get() { return keychain.getPassword(SERVICE, ACCOUNT); }
8
- async set(key) { await keychain.setPassword(SERVICE, ACCOUNT, key); }
9
- async delete() { await keychain.deletePassword(SERVICE, ACCOUNT); }
7
+ /** Security batch item 6 — the one resolver: this home's account first, then auth.json. */
8
+ async get() { return (await resolveApiKey()).key; }
9
+ async set(key) {
10
+ await keychain.setPassword(SERVICE, keychainAccountForHome(), key);
11
+ await recordStoreWrite().catch(() => { });
12
+ }
13
+ async delete() { await keychain.deletePassword(SERVICE, keychainAccountForHome()); }
10
14
  isKeychainAvailable() {
11
15
  this.available ??= this.probe();
12
16
  return this.available;
@@ -32,7 +36,8 @@ class KeychainStore {
32
36
  }
33
37
  class EphemeralStore {
34
38
  cached = null;
35
- async get() { return this.cached; }
39
+ /** No credential store: a key typed this session, else auth.json (the fallback). */
40
+ async get() { return this.cached ?? (await resolveApiKey()).key; }
36
41
  async set(key) { this.cached = key; }
37
42
  async delete() { this.cached = null; }
38
43
  async isKeychainAvailable() { return false; }
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { promises as fs } from 'node:fs';
21
21
  import * as path from 'node:path';
22
+ import { randomBytes } from 'node:crypto';
22
23
  import { cspeachRoot } from '../config/paths.js';
23
24
  let pathOverride = null;
24
25
  /** Test-only override; pass null to reset. */
@@ -32,9 +33,14 @@ export async function readAuthFile() {
32
33
  try {
33
34
  const raw = await fs.readFile(authFilePath(), 'utf-8');
34
35
  const parsed = JSON.parse(raw);
35
- if (typeof parsed.apiKey !== 'string' || typeof parsed.customerId !== 'string')
36
+ if (typeof parsed.customerId !== 'string')
36
37
  return null;
37
- return { apiKey: parsed.apiKey, customerId: parsed.customerId };
38
+ const out = { customerId: parsed.customerId };
39
+ if (typeof parsed.apiKey === 'string' && parsed.apiKey.trim() !== '')
40
+ out.apiKey = parsed.apiKey;
41
+ if (typeof parsed.savedAt === 'string')
42
+ out.savedAt = parsed.savedAt;
43
+ return out;
38
44
  }
39
45
  catch {
40
46
  return null;
@@ -43,10 +49,27 @@ export async function readAuthFile() {
43
49
  export async function writeAuthFile(auth) {
44
50
  const p = authFilePath();
45
51
  await fs.mkdir(path.dirname(p), { recursive: true });
46
- // Only the two known fields — never a secret a caller passes along by mistake.
47
- const body = { apiKey: auth.apiKey, customerId: auth.customerId };
52
+ // Only the known fields — never a secret a caller passes along by mistake.
53
+ const body = {
54
+ ...(auth.apiKey !== undefined ? { apiKey: auth.apiKey } : {}),
55
+ customerId: auth.customerId,
56
+ ...(auth.savedAt !== undefined ? { savedAt: auth.savedAt } : {}),
57
+ };
48
58
  await writeAtomic0600(p, JSON.stringify(body, null, 2));
49
59
  }
60
+ /** auth.json's modification time, or null when there is no file. */
61
+ export async function authFileMtimeMs() {
62
+ try {
63
+ return (await fs.stat(authFilePath())).mtimeMs;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** The folder auth.json lives in (the CSPeach home, or a test override's folder). */
70
+ export function authFileDir() {
71
+ return path.dirname(authFilePath());
72
+ }
50
73
  /** Rename errors that mean "the target is open elsewhere" (Windows), worth retrying. */
51
74
  const LOCK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
52
75
  /** Backoff between rename attempts (5 tries in all). */
@@ -63,7 +86,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
63
86
  * The temp file holds the API key, so it is always removed.
64
87
  */
65
88
  async function writeAtomic0600(p, text) {
66
- const tmp = `${p}.${process.pid}.tmp`;
89
+ // Unique per write (security batch fix round 1): two writes in one process
90
+ // never share a temp file. Still matches cleanStaleAuthTemps' auth.json.*.tmp.
91
+ const tmp = `${p}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
67
92
  try {
68
93
  await fs.writeFile(tmp, text, { encoding: 'utf-8', mode: 0o600 });
69
94
  // Defensive: re-chmod in case the create-time mode was ignored (e.g., umask 022).
@@ -129,11 +154,15 @@ export async function cleanStaleAuthTemps() {
129
154
  }
130
155
  catch { /* no ~/.cspeach yet */ }
131
156
  }
157
+ /** Remove auth.json. True when it is gone (or was never there); false when the unlink failed. */
132
158
  export async function deleteAuthFile() {
133
159
  try {
134
160
  await fs.unlink(authFilePath());
161
+ return true;
162
+ }
163
+ catch (err) {
164
+ return err.code === 'ENOENT';
135
165
  }
136
- catch { /* ignore */ }
137
166
  }
138
167
  /** The BYOK key's keychain entry — the same one byok-provider.ts reads and login writes. */
139
168
  const BYOK_KEYCHAIN_SERVICE = 'cspeach.anthropic';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Azure tenant mode — the admin-issued Azure key fallback, in the OS
3
+ * credential store: service cspeach.azure, account api-key[:<home hash>]
4
+ * (the same per-home rule as the CSPeach key, key-resolver.ts). Allowed only
5
+ * when the company's settings list `key` as a sign-in method.
6
+ */
7
+ import keychain from '../keychain.js';
8
+ import { keychainAccountForHome } from '../key-resolver.js';
9
+ export const AZURE_SERVICE = 'cspeach.azure';
10
+ export function azureAccountForHome(prefix, home, defaultHome) {
11
+ return keychainAccountForHome(home, defaultHome).replace(/^api-key/, prefix);
12
+ }
13
+ export async function getAzureKey() {
14
+ return keychain.getPassword(AZURE_SERVICE, azureAccountForHome('api-key'));
15
+ }
16
+ export async function setAzureKey(key) {
17
+ await keychain.setPassword(AZURE_SERVICE, azureAccountForHome('api-key'), key);
18
+ }
19
+ export async function deleteAzureKey() {
20
+ return keychain.deletePassword(AZURE_SERVICE, azureAccountForHome('api-key'));
21
+ }