@cspeach/cli 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BUNDLED_SKILLS } from './bundled-skills.js';
|
|
2
|
+
export function createBundledSkillSource() {
|
|
3
|
+
return {
|
|
4
|
+
async getSkillBody(name) {
|
|
5
|
+
const body = BUNDLED_SKILLS[name];
|
|
6
|
+
if (!body) {
|
|
7
|
+
const avail = Object.keys(BUNDLED_SKILLS);
|
|
8
|
+
throw new Error(`skill '${name}' not bundled in this CLI build (local mode).` +
|
|
9
|
+
` This CLI ships ${avail.length} skills. Upgrade CLI for newer skills, or switch to byok/ai-hub mode for live manifest fetch.`);
|
|
10
|
+
}
|
|
11
|
+
return body;
|
|
12
|
+
},
|
|
13
|
+
async listAvailableSkills() {
|
|
14
|
+
return Object.keys(BUNDLED_SKILLS);
|
|
15
|
+
},
|
|
16
|
+
describe() {
|
|
17
|
+
return `bundled with CLI binary (${Object.keys(BUNDLED_SKILLS).length} skills)`;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function createManagedSkillSource(cfg, getBearer) {
|
|
2
|
+
const baseUrl = cfg.proxy_url.replace(/\/$/, '');
|
|
3
|
+
return {
|
|
4
|
+
async getSkillBody(name) {
|
|
5
|
+
const bearer = await getBearer();
|
|
6
|
+
const r = await fetch(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, {
|
|
7
|
+
headers: { Authorization: `Bearer ${bearer}` },
|
|
8
|
+
});
|
|
9
|
+
if (!r.ok)
|
|
10
|
+
throw new Error(`managed skill fetch: HTTP ${r.status} for ${name}`);
|
|
11
|
+
const j = await r.json();
|
|
12
|
+
return { name: j.name, body: j.body, sha256: j.sha256, signature: j.signature, signedAt: j.signedAt };
|
|
13
|
+
},
|
|
14
|
+
async listAvailableSkills() {
|
|
15
|
+
const bearer = await getBearer();
|
|
16
|
+
const r = await fetch(`${baseUrl}/v1/skills`, { headers: { Authorization: `Bearer ${bearer}` } });
|
|
17
|
+
if (!r.ok)
|
|
18
|
+
throw new Error(`managed skill list: HTTP ${r.status}`);
|
|
19
|
+
const j = await r.json();
|
|
20
|
+
return j.skills.map((s) => s.name);
|
|
21
|
+
},
|
|
22
|
+
describe() {
|
|
23
|
+
return `managed via ${cfg.proxy_url} (bearer-authenticated)`;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ManifestClient } from './manifest-client.js';
|
|
2
|
+
export function createManifestSkillSource(manifestUrl) {
|
|
3
|
+
const client = new ManifestClient(manifestUrl);
|
|
4
|
+
let cachedManifest = null;
|
|
5
|
+
async function getManifest() {
|
|
6
|
+
if (!cachedManifest)
|
|
7
|
+
cachedManifest = await client.fetchAndVerifyManifest();
|
|
8
|
+
return cachedManifest;
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
async getSkillBody(name) {
|
|
12
|
+
const m = await getManifest();
|
|
13
|
+
const entry = m.skills.find(s => s.name === name);
|
|
14
|
+
if (!entry)
|
|
15
|
+
throw new Error(`skill '${name}' not found in manifest (available: ${m.skills.map(s => s.name).join(', ')})`);
|
|
16
|
+
return client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
|
|
17
|
+
},
|
|
18
|
+
async listAvailableSkills() {
|
|
19
|
+
const m = await getManifest();
|
|
20
|
+
return m.skills.map(s => s.name);
|
|
21
|
+
},
|
|
22
|
+
describe() {
|
|
23
|
+
return `public signed manifest at ${manifestUrl}`;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/_command-shared.ts
|
|
2
|
+
/**
|
|
3
|
+
* Phase 3 / Chunk 3E — shared command-spawning helpers.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from shell_exec.ts so background_run can reuse the same
|
|
6
|
+
* safelist + PATHEXT walk + env scrubbing without duplication. Pure
|
|
7
|
+
* refactor — every constant, every check, every limit is identical
|
|
8
|
+
* to what shell_exec used inline before this extraction.
|
|
9
|
+
*
|
|
10
|
+
* Importers: shell_exec.ts (Chunk 3B), background_run.ts (Chunk 3E).
|
|
11
|
+
* No tool runs the bare command name through any shell parser — argv
|
|
12
|
+
* arrays only, shell: false, every time.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { loadConfig } from '../config/loader.js';
|
|
17
|
+
export const DEFAULT_SAFELIST = new Set([
|
|
18
|
+
'npm', 'pnpm', 'ui5', 'cds', 'git', 'abaplint', 'node',
|
|
19
|
+
]);
|
|
20
|
+
// Defense-in-depth caps on the argv array — neither matters for normal
|
|
21
|
+
// build commands, but a pathological model output could otherwise consume
|
|
22
|
+
// memory before spawn ever runs. Per-arg cap is far above any realistic
|
|
23
|
+
// flag value; total cap is far above any realistic command-line length.
|
|
24
|
+
export const MAX_ARGS = 1_000;
|
|
25
|
+
export const MAX_ARG_BYTES = 100_000;
|
|
26
|
+
export const KILL_GRACE_MS = 2_000; // SIGTERM → SIGKILL escalation grace
|
|
27
|
+
// Env names that pass through to children. Everything else (including
|
|
28
|
+
// CSPEACH_*, ANTHROPIC_*, SAP_*) is dropped. Both 'PATH' and 'Path'
|
|
29
|
+
// appear because Node's process.env on Windows is case-insensitive at
|
|
30
|
+
// read time but case-preserving at iteration — only one ends up in
|
|
31
|
+
// process.env in practice, but accepting both keeps the whitelist
|
|
32
|
+
// honest about what we'd let through if both ever co-existed.
|
|
33
|
+
export const ENV_PASSTHROUGH = new Set([
|
|
34
|
+
'PATH', 'Path', 'PATHEXT', 'HOME', 'USERPROFILE', 'TEMP', 'TMP',
|
|
35
|
+
'SystemRoot', 'windir', 'COMSPEC', 'LANG', 'LC_ALL', 'TZ',
|
|
36
|
+
]);
|
|
37
|
+
export function buildSafeEnv() {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const k of Object.keys(process.env)) {
|
|
40
|
+
if (ENV_PASSTHROUGH.has(k))
|
|
41
|
+
out[k] = process.env[k];
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
export async function loadSafelist() {
|
|
46
|
+
try {
|
|
47
|
+
const cfg = await loadConfig();
|
|
48
|
+
const extras = cfg.shell_exec?.allow ?? [];
|
|
49
|
+
return new Set([...DEFAULT_SAFELIST, ...extras]);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return new Set(DEFAULT_SAFELIST);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a bare command name to an absolute executable path by walking
|
|
57
|
+
* PATH. Tries the bare name + PATHEXT extensions on Windows. Returns
|
|
58
|
+
* null if nothing matches anywhere on PATH. Identical to Chunk 3B's
|
|
59
|
+
* inlined function — extracted verbatim.
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveExecutable(name) {
|
|
62
|
+
const PATH = process.env.PATH ?? process.env.Path ?? '';
|
|
63
|
+
const dirs = PATH.split(path.delimiter).filter((d) => d.length > 0);
|
|
64
|
+
let extensions;
|
|
65
|
+
if (process.platform === 'win32') {
|
|
66
|
+
const pathext = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').toLowerCase().split(';');
|
|
67
|
+
extensions = ['', ...pathext];
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
extensions = [''];
|
|
71
|
+
}
|
|
72
|
+
for (const dir of dirs) {
|
|
73
|
+
for (const ext of extensions) {
|
|
74
|
+
const candidate = path.join(dir, name + ext);
|
|
75
|
+
try {
|
|
76
|
+
const stat = await fs.stat(candidate);
|
|
77
|
+
if (stat.isFile())
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
80
|
+
catch { /* ENOENT — keep walking */ }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Returns null if argv is valid, or an error message describing the
|
|
87
|
+
* violation. Caller surfaces this as the tool's error envelope.
|
|
88
|
+
*/
|
|
89
|
+
export function validateArgv(argv) {
|
|
90
|
+
if (argv.length > MAX_ARGS) {
|
|
91
|
+
return `too many args (max ${MAX_ARGS}, got ${argv.length})`;
|
|
92
|
+
}
|
|
93
|
+
const oversized = argv.findIndex((a) => a.length > MAX_ARG_BYTES);
|
|
94
|
+
if (oversized !== -1) {
|
|
95
|
+
return `arg at index ${oversized} exceeds max length of ${MAX_ARG_BYTES} chars`;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Returns null if the bare command name is acceptable, or an error
|
|
101
|
+
* message if it contains a path separator OR is absolute. Closes the
|
|
102
|
+
* "model passes /bin/sh because /bin/sh's basename is in the safelist"
|
|
103
|
+
* class of attack.
|
|
104
|
+
*/
|
|
105
|
+
export function rejectPathSeparator(command) {
|
|
106
|
+
if (path.isAbsolute(command) || command.includes('/') || command.includes('\\')) {
|
|
107
|
+
return `command must be a bare name (no path separators / not absolute path) — got "${command}". safelist check refused.`;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/_filesystem-shared.ts
|
|
2
|
+
/**
|
|
3
|
+
* Phase 3 — shared filesystem-tool helpers.
|
|
4
|
+
*
|
|
5
|
+
* Centralizes path containment so individual tools (file_read, file_edit,
|
|
6
|
+
* file_write, glob, grep) cannot drift on their own security checks.
|
|
7
|
+
* Every Phase 3 filesystem tool MUST resolve user-supplied paths through
|
|
8
|
+
* resolveSafePath before doing any IO.
|
|
9
|
+
*/
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { promises as fsPromises } from 'node:fs';
|
|
12
|
+
/** Phase 3 sensitive in-root paths — refused by all filesystem tools. */
|
|
13
|
+
export const BLOCKED_PREFIXES = ['.cspeach', '.env', '.git', '.cspeach-design'];
|
|
14
|
+
/**
|
|
15
|
+
* True if `realAbsPath` falls under one of BLOCKED_PREFIXES relative to root.
|
|
16
|
+
* Caller should refuse the operation when `blocked` is true.
|
|
17
|
+
*/
|
|
18
|
+
export function isDenylistedPath(realAbsPath, root) {
|
|
19
|
+
const relFromRoot = path.relative(path.resolve(root), realAbsPath).replace(/\\/g, '/');
|
|
20
|
+
for (const prefix of BLOCKED_PREFIXES) {
|
|
21
|
+
if (relFromRoot === prefix || relFromRoot.startsWith(prefix + '/')) {
|
|
22
|
+
return { blocked: true, relFromRoot };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { blocked: false, relFromRoot };
|
|
26
|
+
}
|
|
27
|
+
export class PathOutsideRootError extends Error {
|
|
28
|
+
constructor(givenPath, root) {
|
|
29
|
+
super(`Path "${givenPath}" resolves outside the project root "${root}"`);
|
|
30
|
+
this.name = 'PathOutsideRootError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve `userPath` relative to `root` (the project cwd) and verify the
|
|
35
|
+
* result is INSIDE root. Throws PathOutsideRootError otherwise.
|
|
36
|
+
*
|
|
37
|
+
* Sync by design: the work is pure path-string math (no IO). If a future
|
|
38
|
+
* caller wants symlink-aware resolution they should fs.realpath the result;
|
|
39
|
+
* adding async to this helper today would mislead callers about what it does.
|
|
40
|
+
*
|
|
41
|
+
* Accepts:
|
|
42
|
+
* - relative paths (foo, foo/bar.ts, ./foo, '', '.', 'subdir/..')
|
|
43
|
+
* - absolute paths that already resolve inside root
|
|
44
|
+
*
|
|
45
|
+
* Rejects:
|
|
46
|
+
* - any path containing a null byte (hostile input)
|
|
47
|
+
* - any path that, after resolution, sits outside root
|
|
48
|
+
* - `..` traversals that escape root
|
|
49
|
+
*/
|
|
50
|
+
export function resolveSafePath(root, userPath) {
|
|
51
|
+
if (userPath.includes('\0'))
|
|
52
|
+
throw new PathOutsideRootError(userPath, root);
|
|
53
|
+
const absRoot = path.resolve(root);
|
|
54
|
+
const absInput = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(absRoot, userPath);
|
|
55
|
+
const rel = path.relative(absRoot, absInput);
|
|
56
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
57
|
+
throw new PathOutsideRootError(userPath, root);
|
|
58
|
+
}
|
|
59
|
+
return absInput;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve symlinks via fs.realpath and verify the real path is still
|
|
63
|
+
* inside root. Throws PathOutsideRootError on symlink escape. Returns
|
|
64
|
+
* the realpath on success.
|
|
65
|
+
*
|
|
66
|
+
* Use this AFTER resolveSafePath when the path will actually be opened
|
|
67
|
+
* for read — pure path-string math doesn't catch symlink escapes.
|
|
68
|
+
*
|
|
69
|
+
* If the file doesn't exist (ENOENT), the underlying realpath rejects;
|
|
70
|
+
* the caller should let that error propagate as a normal "file not
|
|
71
|
+
* found" condition.
|
|
72
|
+
*/
|
|
73
|
+
export async function assertRealPathContained(absInputPath, root) {
|
|
74
|
+
const absRoot = path.resolve(root);
|
|
75
|
+
const real = await fsPromises.realpath(absInputPath);
|
|
76
|
+
const rel = path.relative(absRoot, real);
|
|
77
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
78
|
+
throw new PathOutsideRootError(absInputPath, root);
|
|
79
|
+
}
|
|
80
|
+
return real;
|
|
81
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/_flag.ts
|
|
2
|
+
/**
|
|
3
|
+
* Phase 3 of Track B — per-tool feature-flag gate.
|
|
4
|
+
*
|
|
5
|
+
* Each new Phase 3 tool is gated behind CSPEACH_TOOL_<UPPERCASE_NAME>=on.
|
|
6
|
+
* Default OFF means the tool is invisible to listTools() and therefore
|
|
7
|
+
* to the model. Removed in a separate commit per tool after dogfooding
|
|
8
|
+
* validates it.
|
|
9
|
+
*
|
|
10
|
+
* Misconfiguration surface: a one-shot stderr warning fires the first
|
|
11
|
+
* time we see a flag that is SET but not exactly "on" (after trim).
|
|
12
|
+
* Catches the common .env mistakes — `=true`, `=1`, leading whitespace
|
|
13
|
+
* — without making the on-test forgiving.
|
|
14
|
+
*/
|
|
15
|
+
export const TOOL_FLAG_PREFIX = 'CSPEACH_TOOL_';
|
|
16
|
+
/** One-shot per process — set of flag-names we've already warned about. */
|
|
17
|
+
const warnedFlags = new Set();
|
|
18
|
+
export function isToolFlagOn(toolName) {
|
|
19
|
+
const flag = `${TOOL_FLAG_PREFIX}${toolName.toUpperCase()}`;
|
|
20
|
+
const raw = process.env[flag];
|
|
21
|
+
if (raw === undefined)
|
|
22
|
+
return false;
|
|
23
|
+
const trimmed = raw.trim();
|
|
24
|
+
if (trimmed === 'on')
|
|
25
|
+
return true;
|
|
26
|
+
// Set but not "on" — warn ONCE so a misconfigured flag is visible.
|
|
27
|
+
if (!warnedFlags.has(flag)) {
|
|
28
|
+
warnedFlags.add(flag);
|
|
29
|
+
try {
|
|
30
|
+
process.stderr.write(`[tools] ${flag} is set to "${raw}" — did you mean ${flag}=on? Tool stays disabled.\n`);
|
|
31
|
+
}
|
|
32
|
+
catch { /* stderr write itself failed — nothing more we can do */ }
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/** Test-only — clear the warned-once set so successive tests can re-trigger warnings. */
|
|
37
|
+
export function __resetFlagWarningsForTests() {
|
|
38
|
+
warnedFlags.clear();
|
|
39
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { registerTool } from './index.js';
|
|
2
|
+
import { effectiveRisk } from '../approvals/risk-floor.js';
|
|
3
|
+
import { mintApprovalId } from '../approvals/jwt.js';
|
|
4
|
+
import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
|
|
5
|
+
import { loadConfig } from '../config/loader.js';
|
|
6
|
+
import { maybeShowAutoApproveNag } from '../approvals/approval-prompt.js';
|
|
7
|
+
import { renderAdvisoryProposal, } from '../approvals/advisory-render.js';
|
|
8
|
+
import { promptAdvisory } from '../approvals/advisory-prompt.js';
|
|
9
|
+
import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
10
|
+
/**
|
|
11
|
+
* Advisory-only replacement for the per-change approval gauntlet.
|
|
12
|
+
*
|
|
13
|
+
* Renders the proposal, prompts the developer (who applies manually in ADT),
|
|
14
|
+
* and returns a JSON-encoded envelope carrying the outcome plus an AI-directive
|
|
15
|
+
* string. Never mints approval IDs.
|
|
16
|
+
*
|
|
17
|
+
* `_ctx` is accepted to match the shape of the main handler (and to leave room
|
|
18
|
+
* for future use of `sapAlias` in the rendered directive), but is currently
|
|
19
|
+
* unused — the prompt and render are sapAlias-agnostic.
|
|
20
|
+
*/
|
|
21
|
+
export async function handleAdvisoryApproval(args, _ctx) {
|
|
22
|
+
const render = renderAdvisoryProposal(args);
|
|
23
|
+
const outcome = await promptAdvisory({
|
|
24
|
+
displayText: render.displayText,
|
|
25
|
+
clipboardText: render.clipboardText,
|
|
26
|
+
});
|
|
27
|
+
let ai_directive = '';
|
|
28
|
+
switch (outcome) {
|
|
29
|
+
case 'applied':
|
|
30
|
+
ai_directive =
|
|
31
|
+
'Advisory mode: the change has been proposed to the developer and the developer reports it was applied manually in ADT. Do not attempt any sap_set_source, sap_create_object, sap_activate, or other write operations for this change. Summarise what you proposed and continue.';
|
|
32
|
+
break;
|
|
33
|
+
case 'skipped':
|
|
34
|
+
ai_directive =
|
|
35
|
+
'Advisory mode: the developer skipped this proposal. Do not attempt to write. Summarise and move on.';
|
|
36
|
+
break;
|
|
37
|
+
case 'aborted':
|
|
38
|
+
ai_directive =
|
|
39
|
+
'Advisory mode: the developer aborted. Stop current workflow. Do not attempt any writes.';
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
content: JSON.stringify({
|
|
44
|
+
advisory_outcome: outcome,
|
|
45
|
+
ai_directive,
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
registerTool({
|
|
50
|
+
name: 'request_approval',
|
|
51
|
+
description: 'Request user approval for one or more SAP mutations. Returns approval_ids (one per change) to be passed as approval_id on matching mutating tool calls.',
|
|
52
|
+
isMutating: false,
|
|
53
|
+
input_schema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
summary: { type: 'string', description: 'One-line description of the planned change set' },
|
|
57
|
+
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
58
|
+
transport: { type: 'string', description: 'Target transport ID, if applicable' },
|
|
59
|
+
changes: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
items: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release'] },
|
|
65
|
+
object: { type: 'string' },
|
|
66
|
+
type: { type: 'string' },
|
|
67
|
+
diff: { type: 'string', description: 'Unified diff for modify/create ops (optional)' },
|
|
68
|
+
},
|
|
69
|
+
required: ['op', 'object', 'type'],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ['summary', 'risk', 'changes'],
|
|
74
|
+
},
|
|
75
|
+
handler: async (args, ctx) => {
|
|
76
|
+
let cfg = await loadConfig();
|
|
77
|
+
// Advisory-only short-circuit — never mint approval_ids.
|
|
78
|
+
// The developer applies the change manually in ADT; the AI gets an
|
|
79
|
+
// ai_directive telling it not to attempt any SAP write tools.
|
|
80
|
+
if (cfg.write_mode === 'advisory-only') {
|
|
81
|
+
return handleAdvisoryApproval(args, { sapAlias: ctx.sapAlias });
|
|
82
|
+
}
|
|
83
|
+
let sapCfg = cfg.sap[ctx.sapAlias];
|
|
84
|
+
const riskCtx = {
|
|
85
|
+
sapAlias: ctx.sapAlias,
|
|
86
|
+
autoApprovePolicy: sapCfg?.auto_approve ?? 'never',
|
|
87
|
+
};
|
|
88
|
+
const changes = args.changes;
|
|
89
|
+
const declared = args.risk;
|
|
90
|
+
const eff = effectiveRisk(declared, changes, riskCtx);
|
|
91
|
+
// Progressive-disclosure: offer to upgrade never→low on first low-risk encounter.
|
|
92
|
+
await maybeShowAutoApproveNag({ sapAlias: ctx.sapAlias, effectiveRisk: eff.level });
|
|
93
|
+
// Refresh cfg so this approval benefits from a just-enabled auto_approve.
|
|
94
|
+
cfg = await loadConfig();
|
|
95
|
+
sapCfg = cfg.sap[ctx.sapAlias];
|
|
96
|
+
riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
|
|
97
|
+
// Auto-approve caps — never auto-approve at high, cap change count per risk level.
|
|
98
|
+
const autoApproveAllowed = ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
99
|
+
(sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
|
|
100
|
+
if (autoApproveAllowed) {
|
|
101
|
+
const ids = [];
|
|
102
|
+
for (const c of changes) {
|
|
103
|
+
ids.push(await mintApprovalId({
|
|
104
|
+
object: c.object,
|
|
105
|
+
type: c.type,
|
|
106
|
+
op: c.op,
|
|
107
|
+
session_id: ctx.session.id,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
// Tell Rule 8 the user has already covered this batch — they
|
|
111
|
+
// configured auto-approve, so no per-dispatch re-prompts.
|
|
112
|
+
markPlanGateApproved();
|
|
113
|
+
return {
|
|
114
|
+
content: JSON.stringify({
|
|
115
|
+
approved: true,
|
|
116
|
+
auto_approved: true,
|
|
117
|
+
approval_ids: ids,
|
|
118
|
+
effective_risk: eff.level,
|
|
119
|
+
raised_by: eff.raisedBy,
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// Plan-gate: for multi-change requests, ask once whether to apply all,
|
|
124
|
+
// review each, or cancel. Apply-all is the default and the happy path —
|
|
125
|
+
// it skips the redundant per-change prompts that produced the "3 prompts
|
|
126
|
+
// for one task" complaint. Review-each falls back to the per-change
|
|
127
|
+
// gate. Cancel rejects the whole plan.
|
|
128
|
+
let skipPerChangeGates = false;
|
|
129
|
+
if (changes.length > 1) {
|
|
130
|
+
const mode = await renderPlanGate(args.summary, changes, args.transport, eff);
|
|
131
|
+
if (mode === 'cancel') {
|
|
132
|
+
return {
|
|
133
|
+
content: JSON.stringify({
|
|
134
|
+
approved: false,
|
|
135
|
+
reason: 'plan_rejected',
|
|
136
|
+
effective_risk: eff.level,
|
|
137
|
+
raised_by: eff.raisedBy,
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
skipPerChangeGates = (mode === 'apply_all');
|
|
142
|
+
}
|
|
143
|
+
// Per-change approvals — skipped when the user picked "Apply all" at the
|
|
144
|
+
// plan gate. Mint approval_ids directly for every change in that case.
|
|
145
|
+
//
|
|
146
|
+
// 2026-05-01: in Review mode, tie activate decisions to the matching
|
|
147
|
+
// write decision on the same object. activate is never an independent
|
|
148
|
+
// call — it's the implicit follow-on of a modify/create. Three rules:
|
|
149
|
+
// - sibling write APPROVED → activate auto-approved (no re-prompt).
|
|
150
|
+
// - sibling write SKIPPED → activate auto-skipped (activating a
|
|
151
|
+
// write you just rejected would re-activate stale code, which the
|
|
152
|
+
// user never asked for and produces the worst kind of confusion).
|
|
153
|
+
// - no sibling write → activate prompted on its own (rare —
|
|
154
|
+
// user explicitly asked the model to activate something already
|
|
155
|
+
// written by another flow).
|
|
156
|
+
// Approval IDs are still minted on the approved path so sap_activate's
|
|
157
|
+
// JWT verification stays intact.
|
|
158
|
+
const approvalIds = [];
|
|
159
|
+
const rejections = [];
|
|
160
|
+
const autoApproveActivateFor = new Set();
|
|
161
|
+
const autoSkipActivateFor = new Set();
|
|
162
|
+
const writeKeys = new Set();
|
|
163
|
+
for (const c of changes) {
|
|
164
|
+
if (c.op === 'modify' || c.op === 'create')
|
|
165
|
+
writeKeys.add(`${c.type}:${c.object}`);
|
|
166
|
+
}
|
|
167
|
+
for (const c of changes) {
|
|
168
|
+
const objKey = `${c.type}:${c.object}`;
|
|
169
|
+
let outcome;
|
|
170
|
+
if (skipPerChangeGates) {
|
|
171
|
+
outcome = { approved: true };
|
|
172
|
+
}
|
|
173
|
+
else if (c.op === 'activate' && autoApproveActivateFor.has(objKey)) {
|
|
174
|
+
outcome = { approved: true };
|
|
175
|
+
}
|
|
176
|
+
else if (c.op === 'activate' && autoSkipActivateFor.has(objKey)) {
|
|
177
|
+
outcome = { approved: false, reason: 'sibling_write_skipped' };
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
outcome = await renderPerChangeApprovalV3(c, eff, args.transport);
|
|
181
|
+
}
|
|
182
|
+
if (outcome.approved) {
|
|
183
|
+
if (c.op === 'modify' || c.op === 'create')
|
|
184
|
+
autoApproveActivateFor.add(objKey);
|
|
185
|
+
approvalIds.push(await mintApprovalId({
|
|
186
|
+
object: c.object,
|
|
187
|
+
type: c.type,
|
|
188
|
+
op: c.op,
|
|
189
|
+
session_id: ctx.session.id,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
if (outcome.reason === '__cancel_turn__') {
|
|
194
|
+
return {
|
|
195
|
+
content: JSON.stringify({
|
|
196
|
+
approved: false,
|
|
197
|
+
reason: 'user_cancelled_turn',
|
|
198
|
+
effective_risk: eff.level,
|
|
199
|
+
raised_by: eff.raisedBy,
|
|
200
|
+
}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
// Skipping a write means the matching activate should auto-skip
|
|
204
|
+
// rather than fire a separate prompt offering to activate stale
|
|
205
|
+
// code. Only applies when there's actually a sibling activate in
|
|
206
|
+
// the plan — otherwise this is a no-op flag.
|
|
207
|
+
if ((c.op === 'modify' || c.op === 'create') && writeKeys.has(objKey)) {
|
|
208
|
+
autoSkipActivateFor.add(objKey);
|
|
209
|
+
}
|
|
210
|
+
rejections.push({ object: c.object, op: c.op, reason: outcome.reason ?? 'skipped' });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Tell Rule 8 the user has explicitly approved (some/all of) this
|
|
214
|
+
// batch via the plan-gate / per-change flow. Suppresses the redundant
|
|
215
|
+
// Rule 8 prompt at every subsequent dispatch this turn.
|
|
216
|
+
if (approvalIds.length > 0)
|
|
217
|
+
markPlanGateApproved();
|
|
218
|
+
return {
|
|
219
|
+
content: JSON.stringify({
|
|
220
|
+
approved: approvalIds.length > 0,
|
|
221
|
+
approval_ids: approvalIds,
|
|
222
|
+
skipped: rejections,
|
|
223
|
+
effective_risk: eff.level,
|
|
224
|
+
raised_by: eff.raisedBy,
|
|
225
|
+
}),
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
});
|