@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.
Files changed (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. 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
+ });