@cspeach/cli 1.1.32 → 1.1.34
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/dist/agent/anthropic-provider.js +2 -0
- package/dist/agent/azure-profile.js +7 -0
- package/dist/agent/connection-profile.js +49 -0
- package/dist/agent/loop.js +61 -8
- package/dist/agent/provider-shape.js +36 -0
- package/dist/agent/providers/ai-hub-provider.js +6 -5
- package/dist/agent/providers/azure-provider.js +125 -0
- package/dist/agent/providers/byok-provider.js +8 -7
- package/dist/agent/providers/factory.js +8 -3
- package/dist/agent/providers/local-provider.js +18 -22
- package/dist/agent/risk-gate.js +202 -0
- package/dist/agent/summarise-via-provider.js +55 -8
- package/dist/agent/tool-dispatch.js +19 -0
- package/dist/agent/turn-error-ux.js +22 -0
- package/dist/auth/api-key.js +10 -5
- package/dist/auth/auth-file.js +35 -6
- package/dist/auth/azure/azure-key.js +21 -0
- package/dist/auth/azure/azure-log.js +45 -0
- package/dist/auth/azure/cache-store.js +171 -0
- package/dist/auth/azure/clear.js +28 -0
- package/dist/auth/azure/loopback.js +192 -0
- package/dist/auth/azure/msal-client.js +41 -0
- package/dist/auth/azure/sign-in.js +219 -0
- package/dist/auth/azure/startup-sign-in.js +64 -0
- package/dist/auth/azure/token-provider.js +116 -0
- package/dist/auth/key-resolver.js +258 -0
- package/dist/auth/me.js +11 -5
- package/dist/cli.js +21 -2
- package/dist/commands/compact.js +12 -1
- package/dist/commands/config-set.js +74 -3
- package/dist/commands/config-show.js +25 -0
- package/dist/commands/cost.js +25 -8
- package/dist/commands/help.js +2 -1
- package/dist/commands/login-azure.js +33 -0
- package/dist/commands/login.js +56 -8
- package/dist/commands/logout.js +34 -10
- package/dist/commands/plan-boundary.js +4 -4
- package/dist/commands/plan-model-tier.js +8 -3
- package/dist/commands/usage.js +112 -0
- package/dist/commands/whoami.js +113 -9
- package/dist/config/loader.js +5 -0
- package/dist/connection/azure-auth.js +135 -0
- package/dist/connection/azure-errors.js +103 -0
- package/dist/connection/azure-facts.js +21 -0
- package/dist/connection/azure-gate.js +40 -0
- package/dist/connection/azure-last-call.js +5 -0
- package/dist/connection/azure-turn-error.js +25 -0
- package/dist/connection/cache.js +70 -0
- package/dist/connection/fetch.js +33 -0
- package/dist/connection/guard.js +94 -0
- package/dist/connection/limit-gate.js +26 -0
- package/dist/connection/local-usage.js +71 -0
- package/dist/connection/login-step.js +62 -0
- package/dist/connection/startup.js +67 -0
- package/dist/connection/state.js +68 -0
- package/dist/connection/types.js +1 -0
- package/dist/cost/cost-log.js +3 -1
- package/dist/cost/credits-wire.js +1 -1
- package/dist/cost/credits.js +4 -0
- package/dist/cost/display-mode.js +7 -0
- package/dist/cost/month-usage.js +45 -0
- package/dist/cost/router-spend.js +3 -0
- package/dist/cost/session-cost.js +4 -0
- package/dist/doctor/checks/azure.js +100 -0
- package/dist/doctor/checks/credits.js +4 -3
- package/dist/doctor/checks/usage-reporting.js +22 -0
- package/dist/doctor/run.js +7 -0
- package/dist/licence/licence-check.js +205 -0
- package/dist/licence/paths.js +6 -0
- package/dist/licence/turn-gate.js +66 -0
- package/dist/models/model-identity.js +65 -0
- package/dist/models/resolve.js +8 -0
- package/dist/models/server-config.js +3 -2
- package/dist/one-shot.js +86 -2
- package/dist/renderer/footer-line.js +7 -1
- package/dist/renderer/status-footer.js +13 -6
- package/dist/repl/ask-or-no.js +18 -0
- package/dist/repl/builtin-commands.js +12 -1
- package/dist/repl/post-turn-status.js +3 -2
- package/dist/repl/safety-confirm.js +47 -2
- package/dist/repl/session-spend-line.js +4 -1
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/startup-account.js +51 -0
- package/dist/repl.js +223 -48
- package/dist/router/byok-classifier.js +41 -14
- package/dist/router/classifier.js +5 -16
- package/dist/router/router-target.js +41 -0
- package/dist/sap/onboarding.js +70 -64
- package/dist/session/audit-export.js +11 -4
- package/dist/skills/manifest-client.js +37 -4
- package/dist/skills/skills-cache.js +115 -0
- package/dist/skills/source-authed.js +276 -0
- package/dist/tools/filesystem/glob.js +12 -3
- package/dist/tools/filesystem/grep.js +14 -3
- package/dist/tools/tool-loading.js +9 -10
- package/dist/usage/usage-queue.js +323 -0
- package/dist/usage/usage-reporter.js +270 -0
- package/package.json +3 -2
- package/dist/skills/bundled-skills.js +0 -286
- package/dist/skills/source-bundled.js +0 -20
- package/dist/skills/source-manifest.js +0 -36
|
@@ -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
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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}`];
|
package/dist/auth/api-key.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
async
|
|
9
|
-
async
|
|
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
|
-
|
|
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; }
|
package/dist/auth/auth-file.js
CHANGED
|
@@ -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.
|
|
36
|
+
if (typeof parsed.customerId !== 'string')
|
|
36
37
|
return null;
|
|
37
|
-
|
|
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
|
|
47
|
-
const body = {
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode — MSAL and sign-in diagnostics go to
|
|
3
|
+
* <CSPEACH_HOME>/logs/azure-signin.log, never the screen, always redacted:
|
|
4
|
+
* JWTs, Bearer values, refresh tokens, authorization codes, PKCE verifiers,
|
|
5
|
+
* states and long hex keys are replaced. Callers still log fixed lines only;
|
|
6
|
+
* this is the second fence, not the first.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { cspeachRoot } from '../../config/paths.js';
|
|
11
|
+
const JWT = /eyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]*){1,2}/g;
|
|
12
|
+
const BEARER = /(Bearer\s+)\S+/gi;
|
|
13
|
+
const PARAMS = /(?<![A-Za-z_])((?:refresh_token|access_token|id_token|client_secret|code_verifier|code|state|session_state|client_info)=)[^&\s]+/gi;
|
|
14
|
+
const HEADERS = /((?:x-api-key|api-key)\s*[:=]\s*)\S+/gi;
|
|
15
|
+
const LONG_HEX = /\b[0-9a-f]{32,}\b/gi;
|
|
16
|
+
export function redactSecrets(line) {
|
|
17
|
+
return line
|
|
18
|
+
.replace(JWT, '<redacted>')
|
|
19
|
+
.replace(BEARER, '$1<redacted>')
|
|
20
|
+
.replace(PARAMS, '$1<redacted>')
|
|
21
|
+
.replace(HEADERS, '$1<redacted>')
|
|
22
|
+
.replace(LONG_HEX, '<redacted>');
|
|
23
|
+
}
|
|
24
|
+
/** The log never grows past this; when it does, the oldest whole lines are dropped. */
|
|
25
|
+
export const AZURE_LOG_MAX_BYTES = 256 * 1024;
|
|
26
|
+
function trim(file) {
|
|
27
|
+
if (fs.statSync(file).size <= AZURE_LOG_MAX_BYTES)
|
|
28
|
+
return;
|
|
29
|
+
const buf = fs.readFileSync(file);
|
|
30
|
+
let tail = buf.subarray(buf.length - Math.floor(AZURE_LOG_MAX_BYTES / 2));
|
|
31
|
+
const nl = tail.indexOf(0x0a);
|
|
32
|
+
if (nl >= 0)
|
|
33
|
+
tail = tail.subarray(nl + 1);
|
|
34
|
+
fs.writeFileSync(file, tail);
|
|
35
|
+
}
|
|
36
|
+
export function azureLog(line) {
|
|
37
|
+
try {
|
|
38
|
+
const dir = path.join(cspeachRoot(), 'logs');
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
const file = path.join(dir, 'azure-signin.log');
|
|
41
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${redactSecrets(line).replace(/\s+/g, ' ').slice(0, 2000)}\n`);
|
|
42
|
+
trim(file);
|
|
43
|
+
}
|
|
44
|
+
catch { /* logging never breaks sign-in */ }
|
|
45
|
+
}
|