@cspeach/cli 0.9.0 → 1.1.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/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { isAttachableFilename } from '../projects/workspace.js';
|
|
1
3
|
/**
|
|
2
4
|
* Parse `--from @<path>` from a slash-command argument string. The leading `@`
|
|
3
5
|
* is the file-attach convention used elsewhere in the CLI; supports
|
|
@@ -22,3 +24,24 @@ export function parseFromFlag(args) {
|
|
|
22
24
|
const rest = (before + after).replace(/^\s+|\s+$/g, '');
|
|
23
25
|
return { fromPath, rest };
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Forgiveness for the common `--from @<document>` slip.
|
|
29
|
+
*
|
|
30
|
+
* `--from` chains a saved `.cspeach.json` envelope into a downstream skill, but
|
|
31
|
+
* users naturally type it to point at a Word/PDF/text spec ("use this file") —
|
|
32
|
+
* which used to error with a cryptic "unknown target skill" and cancel the
|
|
33
|
+
* whole turn. A document is NEVER a valid `--from` source, so when the token is
|
|
34
|
+
* an attachable doc/text file we silently rewrite `--from @<file>` to a plain
|
|
35
|
+
* `@<file>` attachment (handled by expandTextFileAttachments) and let the turn
|
|
36
|
+
* proceed.
|
|
37
|
+
*
|
|
38
|
+
* Returns the rewritten message + filename, or null when `--from` should run
|
|
39
|
+
* normally (no flag, or the token is a bare envelope fragment / *.cspeach.json).
|
|
40
|
+
*/
|
|
41
|
+
export function coerceDocFromFlagToAttachment(message) {
|
|
42
|
+
const { fromPath } = parseFromFlag(message);
|
|
43
|
+
if (!fromPath || !isAttachableFilename(fromPath))
|
|
44
|
+
return null;
|
|
45
|
+
const rewritten = message.replace(/--from\s+(@(?:"[^"]+"|\S+))/, '$1');
|
|
46
|
+
return { rewritten, filename: basename(fromPath) };
|
|
47
|
+
}
|
|
@@ -52,23 +52,47 @@ export async function loadSafelist() {
|
|
|
52
52
|
return new Set(DEFAULT_SAFELIST);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the ordered list of filename extensions to probe for a given
|
|
57
|
+
* platform. On Windows, PATHEXT extensions (.EXE, .CMD, .BAT, ...) come
|
|
58
|
+
* BEFORE the bare name — npm drops an extensionless Unix shim next to
|
|
59
|
+
* `pnpm.cmd` (e.g. ...\npm\pnpm) that is NOT spawnable with shell:false,
|
|
60
|
+
* so preferring the real .cmd/.exe makes the existence check report a
|
|
61
|
+
* launchable binary. On Unix, only the bare name (shebang scripts and
|
|
62
|
+
* ELF binaries spawn directly).
|
|
63
|
+
*
|
|
64
|
+
* Exported for tests so the ordering invariant can be asserted without
|
|
65
|
+
* touching the filesystem.
|
|
66
|
+
*/
|
|
67
|
+
export function executableExtensions(platform, pathextRaw) {
|
|
68
|
+
if (platform === 'win32') {
|
|
69
|
+
const pathext = (pathextRaw ?? '.COM;.EXE;.BAT;.CMD')
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.split(';')
|
|
72
|
+
.filter((e) => e.length > 0);
|
|
73
|
+
// PATHEXT extensions FIRST, bare name LAST. The bare name still gets
|
|
74
|
+
// probed (commands that ship only an extensionless binary remain
|
|
75
|
+
// findable) but loses to a co-located .exe/.cmd.
|
|
76
|
+
return [...pathext, ''];
|
|
77
|
+
}
|
|
78
|
+
return [''];
|
|
79
|
+
}
|
|
55
80
|
/**
|
|
56
81
|
* Resolve a bare command name to an absolute executable path by walking
|
|
57
|
-
* PATH. Tries the bare name
|
|
58
|
-
* null if nothing matches anywhere on PATH.
|
|
59
|
-
*
|
|
82
|
+
* PATH. Tries PATHEXT extensions before the bare name on Windows. Returns
|
|
83
|
+
* null if nothing matches anywhere on PATH.
|
|
84
|
+
*
|
|
85
|
+
* This is used ONLY as a pre-flight "is it installed?" existence check so
|
|
86
|
+
* the tool can return a friendly "not installed" error. The ACTUAL spawn
|
|
87
|
+
* goes through cross-spawn with the bare command name — cross-spawn does
|
|
88
|
+
* its own PATH + PATHEXT resolution and invokes .cmd via cmd.exe with
|
|
89
|
+
* correct per-arg escaping (which Node's spawn(shell:false) refuses to do
|
|
90
|
+
* since CVE-2024-27980). `platform`/`pathext` overridable for tests.
|
|
60
91
|
*/
|
|
61
|
-
export async function resolveExecutable(name) {
|
|
92
|
+
export async function resolveExecutable(name, platform = process.platform, pathext = process.env.PATHEXT) {
|
|
62
93
|
const PATH = process.env.PATH ?? process.env.Path ?? '';
|
|
63
94
|
const dirs = PATH.split(path.delimiter).filter((d) => d.length > 0);
|
|
64
|
-
|
|
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
|
-
}
|
|
95
|
+
const extensions = executableExtensions(platform, pathext);
|
|
72
96
|
for (const dir of dirs) {
|
|
73
97
|
for (const ext of extensions) {
|
|
74
98
|
const candidate = path.join(dir, name + ext);
|
|
@@ -9,11 +9,125 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as path from 'node:path';
|
|
11
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
12
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Phase 3 sensitive in-root directory prefixes — refused by all filesystem
|
|
14
|
+
* tools. Matched as `rel === prefix` or `rel` starts with `prefix + '/'`.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: the `.env` family is handled separately (by BASENAME, see
|
|
17
|
+
* BLOCKED_BASENAME_RE) so that `.env.local`, `.env.production`, etc. — the
|
|
18
|
+
* files that actually hold secrets in JS projects — are caught too. It is
|
|
19
|
+
* NOT in this list.
|
|
20
|
+
*/
|
|
21
|
+
export const BLOCKED_PREFIXES = ['.cspeach', '.git', '.cspeach-design'];
|
|
22
|
+
/**
|
|
23
|
+
* Skill working subdirectories under `.cspeach/` that are CARVED OUT of the
|
|
24
|
+
* denylist so the upgrade/cca/modernize/test skills can read AND write their
|
|
25
|
+
* own detail files (e.g. `.cspeach/upgrades/ZTEST_..._.json`). The namespace
|
|
26
|
+
* cleanup that moved working files from `.abapforge/<domain>/` to
|
|
27
|
+
* `.cspeach/<domain>/` would otherwise be defeated by the `.cspeach` prefix
|
|
28
|
+
* block. See the carve-out in isDenylistedPath for the exact predicate.
|
|
29
|
+
*/
|
|
30
|
+
export const CSPEACH_WORK_SUBDIRS = ['cca', 'upgrades', 'modernize', 'tests'];
|
|
31
|
+
/**
|
|
32
|
+
* Phase 3 sensitive BASENAMES — refused regardless of directory depth.
|
|
33
|
+
*
|
|
34
|
+
* Matches `.env` exactly or `.env.<suffix>` (e.g. `.env.local`,
|
|
35
|
+
* `.env.production`, `.env.development`, `.env.test`). The `(\.|$)` boundary
|
|
36
|
+
* means `.environment` / `.envrc` are NOT caught — only the real dotenv
|
|
37
|
+
* family. Case-insensitive (NTFS/APFS resolve `.ENV` to the same inode).
|
|
38
|
+
*/
|
|
39
|
+
export const BLOCKED_BASENAME_RE = /^\.env(\.|$)/i;
|
|
40
|
+
/**
|
|
41
|
+
* Human-readable summary of the denylist for error messages.
|
|
42
|
+
* Note: `.cspeach/{cca,upgrades,modernize,tests}/` working files are permitted
|
|
43
|
+
* (skill detail files) — only the rest of `.cspeach/` is blocked.
|
|
44
|
+
*/
|
|
45
|
+
export const DENYLIST_DESCRIPTION = [...BLOCKED_PREFIXES, '.env*'].join(', ');
|
|
46
|
+
/**
|
|
47
|
+
* Executable / script file extensions the filesystem write tools refuse to
|
|
48
|
+
* create. Defense-in-depth: shell_exec / background_run only run a safelisted
|
|
49
|
+
* set of commands, but cross-spawn resolves bare names cwd-first on Windows.
|
|
50
|
+
* If the model could write `<safelisted>.cmd` (e.g. git.cmd) into the project
|
|
51
|
+
* tree it could shadow a real PATH binary. Spawning the absolute PATH-resolved
|
|
52
|
+
* path already neutralizes that at the spawn site; this denylist independently
|
|
53
|
+
* stops the executable shim from ever being planted in the first place — see
|
|
54
|
+
* blockedExecutableExtension, which normalizes Windows filename quirks (trailing
|
|
55
|
+
* dots/spaces, alternate data streams) so a name like `git.cmd ` or `git.cmd::$DATA`
|
|
56
|
+
* cannot slip a real `git.cmd` onto disk past the extension check.
|
|
57
|
+
*
|
|
58
|
+
* Legitimate project scaffolding (Fiori writes .js/.json/.xml/.properties/
|
|
59
|
+
* .yaml/.ts) never needs these, so the block does not impede normal work.
|
|
60
|
+
*
|
|
61
|
+
* Case-insensitive — NTFS/APFS resolve `.CMD` to the same file as `.cmd`.
|
|
62
|
+
*/
|
|
63
|
+
export const BLOCKED_EXECUTABLE_EXTS = new Set([
|
|
64
|
+
'.cmd', '.bat', '.com', '.exe', '.ps1', '.sh', '.msi', '.scr',
|
|
65
|
+
]);
|
|
66
|
+
/** Human-readable summary of the executable-extension block for error messages. */
|
|
67
|
+
export const EXECUTABLE_EXT_DESCRIPTION = [...BLOCKED_EXECUTABLE_EXTS].join(', ');
|
|
68
|
+
/** Sentinel ext returned when the basename carries a `:` (ADS / drive-relative). */
|
|
69
|
+
const BLOCKED_STREAM_SENTINEL = ':stream';
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a Windows basename the way the filesystem does on access:
|
|
72
|
+
* - strip ALL trailing dots and spaces (`git.cmd `, `git.cmd.`, `git.cmd...`
|
|
73
|
+
* all open the file `git.cmd`).
|
|
74
|
+
* Returns the normalized basename. A name that is entirely dots/spaces
|
|
75
|
+
* collapses to '' (which extname() then reports as no extension — safe).
|
|
76
|
+
*/
|
|
77
|
+
function normalizeWindowsBasename(base) {
|
|
78
|
+
return base.replace(/[. ]+$/, '');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Returns the offending extension (lowercased, with leading dot) if `targetPath`
|
|
82
|
+
* names a file the write tools must refuse, else null. Used by file_write /
|
|
83
|
+
* file_edit to refuse planting an executable shim.
|
|
84
|
+
*
|
|
85
|
+
* Accepts a full path or a bare basename (both call sites pass a resolved
|
|
86
|
+
* absolute path; the directory part is irrelevant — only the basename decides).
|
|
87
|
+
*
|
|
88
|
+
* Two pre-extname normalizations close Windows filename-normalization bypasses
|
|
89
|
+
* that would otherwise land an executable-named file on disk while slipping
|
|
90
|
+
* past a naive `path.extname` check:
|
|
91
|
+
*
|
|
92
|
+
* 1. Trailing dots/spaces — Windows strips these on access, so `git.cmd `
|
|
93
|
+
* and `git.cmd.` / `git.cmd...` all resolve to `git.cmd`. We strip them
|
|
94
|
+
* from the basename before taking the extension.
|
|
95
|
+
*
|
|
96
|
+
* 2. A `:` anywhere in the basename — alternate data stream (`git.cmd:foo`,
|
|
97
|
+
* `git.cmd::$DATA`) or drive-relative path. Either way the on-disk file
|
|
98
|
+
* is executable-named; reject outright. (A drive letter's `:` lives in
|
|
99
|
+
* the DIRECTORY part — e.g. basename of `C:\proj\foo.js` is `foo.js` —
|
|
100
|
+
* so legit absolute paths never false-positive here.)
|
|
101
|
+
*/
|
|
102
|
+
export function blockedExecutableExtension(targetPath) {
|
|
103
|
+
const rawBase = path.basename(targetPath);
|
|
104
|
+
// (2) Alternate data stream / drive-relative — the basename must never carry ':'.
|
|
105
|
+
if (rawBase.includes(':'))
|
|
106
|
+
return BLOCKED_STREAM_SENTINEL;
|
|
107
|
+
// (1) Strip trailing dots/spaces the way Windows does on access, THEN take ext.
|
|
108
|
+
const base = normalizeWindowsBasename(rawBase);
|
|
109
|
+
const ext = path.extname(base).toLowerCase();
|
|
110
|
+
return BLOCKED_EXECUTABLE_EXTS.has(ext) ? ext : null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* True if `realAbsPath` is denylisted relative to root — either it falls under
|
|
114
|
+
* one of BLOCKED_PREFIXES (directory prefixes) or its basename is in the
|
|
115
|
+
* `.env` family. Caller should refuse the operation when `blocked` is true.
|
|
116
|
+
*
|
|
117
|
+
* CARVE-OUT — the four skill working subdirectories under `.cspeach/`
|
|
118
|
+
* (CSPEACH_WORK_SUBDIRS = cca/upgrades/modernize/tests) are ALLOWED so skills
|
|
119
|
+
* can read+write their own detail files. A path is carved out (NOT blocked)
|
|
120
|
+
* iff ALL hold:
|
|
121
|
+
* 1. its root-relative path (lowercased, '/'-separated) starts with
|
|
122
|
+
* `.cspeach/<sub>/` for some <sub> in CSPEACH_WORK_SUBDIRS — i.e. it is
|
|
123
|
+
* STRICTLY INSIDE one of those subdirs (the subdir entry itself is not
|
|
124
|
+
* carved out), AND
|
|
125
|
+
* 2. its basename does NOT end with `.cspeach.json` (those envelopes are
|
|
126
|
+
* CLI-owned — direct writes stay blocked), AND
|
|
127
|
+
* 3. its basename is NOT in the `.env` secret family (BLOCKED_BASENAME_RE).
|
|
128
|
+
* The carve-out short-circuits ONLY the `.cspeach` prefix. `.git`,
|
|
129
|
+
* `.cspeach-design`, the `.cspeach` root, non-work subdirs, and `.env*`
|
|
130
|
+
* everywhere else remain blocked.
|
|
17
131
|
*/
|
|
18
132
|
export function isDenylistedPath(realAbsPath, root) {
|
|
19
133
|
const relFromRoot = path.relative(path.resolve(root), realAbsPath).replace(/\\/g, '/');
|
|
@@ -24,12 +138,33 @@ export function isDenylistedPath(realAbsPath, root) {
|
|
|
24
138
|
// `relFromRoot` keeps its ORIGINAL case so callers can log what the model
|
|
25
139
|
// actually requested.
|
|
26
140
|
const relLower = relFromRoot.toLowerCase();
|
|
141
|
+
const base = path.basename(relFromRoot);
|
|
142
|
+
const baseLower = base.toLowerCase();
|
|
143
|
+
// ── CARVE-OUT: .cspeach/<work-subdir>/… — short-circuits ONLY .cspeach. ──
|
|
144
|
+
// Must be strictly INSIDE a work subdir, not a CLI-owned envelope, and not
|
|
145
|
+
// an .env secret. Placed before the prefix loop so it can return early, but
|
|
146
|
+
// gated so it can never bypass .git / .cspeach-design / the .env block.
|
|
147
|
+
for (const sub of CSPEACH_WORK_SUBDIRS) {
|
|
148
|
+
if (relLower.startsWith('.cspeach/' + sub + '/')) {
|
|
149
|
+
if (!baseLower.endsWith('.cspeach.json') && !BLOCKED_BASENAME_RE.test(base)) {
|
|
150
|
+
return { blocked: false, relFromRoot };
|
|
151
|
+
}
|
|
152
|
+
break; // inside a work subdir but failed an exclusion → fall through to block
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Directory-prefix denylist (.cspeach, .git, .cspeach-design).
|
|
27
156
|
for (const prefix of BLOCKED_PREFIXES) {
|
|
28
157
|
const prefixLower = prefix.toLowerCase();
|
|
29
158
|
if (relLower === prefixLower || relLower.startsWith(prefixLower + '/')) {
|
|
30
159
|
return { blocked: true, relFromRoot };
|
|
31
160
|
}
|
|
32
161
|
}
|
|
162
|
+
// Basename denylist — the .env family at ANY depth (.env, .env.local, …).
|
|
163
|
+
// Using the basename (not a prefix) is what catches `.env.production` while
|
|
164
|
+
// still excluding `.environment-notes.md` and `.envrc`.
|
|
165
|
+
if (BLOCKED_BASENAME_RE.test(base)) {
|
|
166
|
+
return { blocked: true, relFromRoot };
|
|
167
|
+
}
|
|
33
168
|
return { blocked: false, relFromRoot };
|
|
34
169
|
}
|
|
35
170
|
export class PathOutsideRootError extends Error {
|
package/dist/tools/_flag.js
CHANGED
|
@@ -15,7 +15,32 @@
|
|
|
15
15
|
export const TOOL_FLAG_PREFIX = 'CSPEACH_TOOL_';
|
|
16
16
|
/** One-shot per process — set of flag-names we've already warned about. */
|
|
17
17
|
const warnedFlags = new Set();
|
|
18
|
+
/**
|
|
19
|
+
* In-memory force-enable set, mirroring renderer/tty.ts's headless-override
|
|
20
|
+
* pattern: consulted BEFORE env detection. A startup hook (REPL + one-shot)
|
|
21
|
+
* calls `enableToolFlags([...])` when the persisted `local_files` config key
|
|
22
|
+
* is true, so the flag-gated filesystem tools become visible to listTools()
|
|
23
|
+
* without the user exporting CSPEACH_TOOL_* env vars.
|
|
24
|
+
*
|
|
25
|
+
* Stored by lowercase tool name (the same name listTools()/registerTool use),
|
|
26
|
+
* so `enableToolFlags(['file_read'])` and `isToolFlagOn('FILE_READ')` agree.
|
|
27
|
+
*/
|
|
28
|
+
const overrideEnabled = new Set();
|
|
29
|
+
/** Force-enable the named tools' flags for this process (override beats env). */
|
|
30
|
+
export function enableToolFlags(toolNames) {
|
|
31
|
+
for (const name of toolNames)
|
|
32
|
+
overrideEnabled.add(name.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
/** Clear all in-memory flag overrides. Test-resettable; safe at runtime too. */
|
|
35
|
+
export function clearToolFlagOverrides() {
|
|
36
|
+
overrideEnabled.clear();
|
|
37
|
+
}
|
|
18
38
|
export function isToolFlagOn(toolName) {
|
|
39
|
+
// Override wins: a startup-applied force-enable makes the tool visible
|
|
40
|
+
// regardless of env. The env path below still runs for un-overridden tools,
|
|
41
|
+
// preserving the CSPEACH_TOOL_<NAME>=on dogfood mechanism + misconfig warn.
|
|
42
|
+
if (overrideEnabled.has(toolName.toLowerCase()))
|
|
43
|
+
return true;
|
|
19
44
|
const flag = `${TOOL_FLAG_PREFIX}${toolName.toUpperCase()}`;
|
|
20
45
|
const raw = process.env[flag];
|
|
21
46
|
if (raw === undefined)
|
package/dist/tools/approval.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import { registerTool } from './index.js';
|
|
3
|
+
import { isHeadless } from '../renderer/tty.js';
|
|
2
4
|
import { effectiveRisk } from '../approvals/risk-floor.js';
|
|
3
|
-
import {
|
|
5
|
+
import { mintChangeApproval } from '../approvals/jwt.js';
|
|
6
|
+
import { canonicalApprovalObject } from '../approvals/canonical.js';
|
|
4
7
|
import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
|
|
5
8
|
import { loadConfig } from '../config/loader.js';
|
|
6
9
|
import { maybeShowAutoApproveNag } from '../approvals/approval-prompt.js';
|
|
7
10
|
import { renderAdvisoryProposal, } from '../approvals/advisory-render.js';
|
|
8
11
|
import { promptAdvisory } from '../approvals/advisory-prompt.js';
|
|
9
12
|
import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
13
|
+
import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
|
|
14
|
+
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
15
|
+
import { displayTransport } from '../approvals/op-labels.js';
|
|
16
|
+
import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVIATION_DETAIL, } from '../commands/plan-gate.js';
|
|
10
17
|
/**
|
|
11
18
|
* Advisory-only replacement for the per-change approval gauntlet.
|
|
12
19
|
*
|
|
@@ -20,6 +27,22 @@ import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
|
20
27
|
*/
|
|
21
28
|
export async function handleAdvisoryApproval(args, _ctx) {
|
|
22
29
|
const render = renderAdvisoryProposal(args);
|
|
30
|
+
// B5 (2026-06-11) — headless: the advisory prompt blocks on stdin that
|
|
31
|
+
// will never answer. Render the proposal (it IS the run's deliverable —
|
|
32
|
+
// the developer reads the captured output and applies manually), skip
|
|
33
|
+
// the prompt, and report a headless-specific directive. NEVER 'applied':
|
|
34
|
+
// nobody confirmed anything.
|
|
35
|
+
if (isHeadless()) {
|
|
36
|
+
console.log(render.displayText);
|
|
37
|
+
console.error(chalk.dim('headless: advisory proposal recorded in output — no prompt, developer applies manually'));
|
|
38
|
+
return {
|
|
39
|
+
content: JSON.stringify({
|
|
40
|
+
advisory_outcome: 'skipped',
|
|
41
|
+
headless: true,
|
|
42
|
+
ai_directive: 'Advisory mode (headless run): the proposal has been rendered into the run output for the developer to review and apply manually later. Nobody confirmed it. Do not attempt any write operations. Summarise what you proposed and continue.',
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
23
46
|
const outcome = await promptAdvisory({
|
|
24
47
|
displayText: render.displayText,
|
|
25
48
|
clipboardText: render.clipboardText,
|
|
@@ -46,6 +69,42 @@ export async function handleAdvisoryApproval(args, _ctx) {
|
|
|
46
69
|
}),
|
|
47
70
|
};
|
|
48
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* §6 escalation exception — the infra base-object TYPE CODES a no-write/design
|
|
74
|
+
* phase (writes:false) may self-create under abap-plan rule 4a. These are the
|
|
75
|
+
* ADT type codes the model puts in `request_approval` `changes[].type`:
|
|
76
|
+
* - DEVC — package (sap_create_object type DEVC)
|
|
77
|
+
* - CTS — transport (sap_transport_create surfaces as {op:'create',type:'CTS'})
|
|
78
|
+
*
|
|
79
|
+
* §6's third member — number range — is intentionally ABSENT: the
|
|
80
|
+
* sap_number_range_intervals tool is a STUB (registered as "do not call", no
|
|
81
|
+
* ADT write), so a number-range op never reaches request_approval. Adding a
|
|
82
|
+
* marker for it would be dead code. Add its type code here if/when that tool
|
|
83
|
+
* ships and starts flowing through the approval gate.
|
|
84
|
+
*
|
|
85
|
+
* DUPLICATION NOTE (deliberate, for a follow-up): the AUDIT side enforces the
|
|
86
|
+
* SAME set in plan-audit.ts (PLAN_AUDIT_PROMPT_CONTRACT §6) — but as PROSE in
|
|
87
|
+
* an LLM prompt ("DEVC (package), transport, or number range"), not code. There
|
|
88
|
+
* is no clean seam to share a predicate because one enforcement layer is a
|
|
89
|
+
* natural-language contract and the other is TypeScript. The two must be kept
|
|
90
|
+
* in sync by hand; this comment and the audit prose both name the same set.
|
|
91
|
+
*/
|
|
92
|
+
const INFRA_BASE_OBJECT_TYPES = new Set(['DEVC', 'CTS']);
|
|
93
|
+
/**
|
|
94
|
+
* TRUE iff EVERY change is a pure infra base-object CREATE — the sanctioned 4a
|
|
95
|
+
* self-create escalation (§6/§7.2). Tightness is the safety property: a single
|
|
96
|
+
* non-infra change (a CLAS/DDLS/… deliverable), any non-create op, or an empty
|
|
97
|
+
* set makes this FALSE, so the writes:false backstop denies it (the correct
|
|
98
|
+
* out-of-lane B3 case). Type is normalized (trim + upper) to match how the
|
|
99
|
+
* model may spell it; op is restricted to 'create' because 4a sanctions
|
|
100
|
+
* self-CREATE only (a transport release / package delete is not an escalation).
|
|
101
|
+
*/
|
|
102
|
+
function isPureInfraEscalation(changes) {
|
|
103
|
+
return (!!changes &&
|
|
104
|
+
changes.length > 0 &&
|
|
105
|
+
changes.every((c) => c.op === 'create' &&
|
|
106
|
+
INFRA_BASE_OBJECT_TYPES.has((c.type ?? '').trim().toUpperCase())));
|
|
107
|
+
}
|
|
49
108
|
registerTool({
|
|
50
109
|
name: 'request_approval',
|
|
51
110
|
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.',
|
|
@@ -64,7 +123,8 @@ registerTool({
|
|
|
64
123
|
op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release'] },
|
|
65
124
|
object: { type: 'string' },
|
|
66
125
|
type: { type: 'string' },
|
|
67
|
-
diff: { type: 'string', description: '
|
|
126
|
+
diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
|
|
127
|
+
package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
|
|
68
128
|
},
|
|
69
129
|
required: ['op', 'object', 'type'],
|
|
70
130
|
},
|
|
@@ -73,11 +133,52 @@ registerTool({
|
|
|
73
133
|
required: ['summary', 'risk', 'changes'],
|
|
74
134
|
},
|
|
75
135
|
handler: async (args, ctx) => {
|
|
136
|
+
// Task 10 (agentic-flow, 2026-07-03) — deviation backstop, BEFORE the
|
|
137
|
+
// approval flow renders anything (including the advisory prompt, which
|
|
138
|
+
// would hang an unattended chain just the same). In guarded mode the
|
|
139
|
+
// write-phase stops are computed from the plan's DECLARED writes field;
|
|
140
|
+
// a phase that declared writes:false and requests a write approval
|
|
141
|
+
// anyway is a plan deviation: DENY without prompting, raise the flag
|
|
142
|
+
// the post-turn chain (plan-chain.ts) turns into blocked + STOPPED.
|
|
143
|
+
// Same error mechanism as the headless fail-fast below: a structured
|
|
144
|
+
// error RESULT so the model wraps up gracefully instead of writing.
|
|
145
|
+
// Guarded-gated: step mode / outside phases are byte-identical.
|
|
146
|
+
//
|
|
147
|
+
// §6/§7.2 escalation exception (audit redesign, Task 4): a PURE infra
|
|
148
|
+
// base-object create (DEVC / transport) under writes:false is the
|
|
149
|
+
// sanctioned rule-4a self-create. The very act of routing through
|
|
150
|
+
// request_approval IS the user-consent gate, so it must NOT be denied here
|
|
151
|
+
// — fall through to the normal approval flow (which prompts the user, whose
|
|
152
|
+
// approval is the 4a consent). Guarded-mode clients could otherwise never
|
|
153
|
+
// run 4a: this backstop blocks it BEFORE the audit even runs. TIGHT: the
|
|
154
|
+
// exception fires only when EVERY change is infra; a deliverable
|
|
155
|
+
// (CLAS/DDLS/…) or a mixed set still denies (correct out-of-lane B3).
|
|
156
|
+
if (isGuardedRunActive() &&
|
|
157
|
+
getCurrentPhaseWrites() === false &&
|
|
158
|
+
!isPureInfraEscalation(args.changes)) {
|
|
159
|
+
const detail = PLAN_DEVIATION_DETAIL;
|
|
160
|
+
notePlanDeviation(detail);
|
|
161
|
+
console.error(chalk.red('plan deviation: this phase declared writes:false but requested a write approval — ' +
|
|
162
|
+
'the write is denied and the guarded chain will stop.'));
|
|
163
|
+
return {
|
|
164
|
+
content: JSON.stringify({
|
|
165
|
+
error: 'plan_deviation',
|
|
166
|
+
detail,
|
|
167
|
+
}),
|
|
168
|
+
is_error: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
76
171
|
let cfg = await loadConfig();
|
|
172
|
+
// Task 2 (ux-wave1) — the approval flow reads the EFFECTIVE write mode:
|
|
173
|
+
// config overlaid by the session override, clamped to the active alias's
|
|
174
|
+
// role ceiling (prd pins advisory-only; qas caps at approval-gated). With
|
|
175
|
+
// no role configured and no session override this is exactly
|
|
176
|
+
// cfg.write_mode — byte-identical behavior.
|
|
177
|
+
const role = cfg.sap[ctx.sapAlias]?.role;
|
|
77
178
|
// Advisory-only short-circuit — never mint approval_ids.
|
|
78
179
|
// The developer applies the change manually in ADT; the AI gets an
|
|
79
180
|
// ai_directive telling it not to attempt any SAP write tools.
|
|
80
|
-
if (cfg.write_mode === 'advisory-only') {
|
|
181
|
+
if (getEffectiveWriteMode(cfg.write_mode, role) === 'advisory-only') {
|
|
81
182
|
return handleAdvisoryApproval(args, { sapAlias: ctx.sapAlias });
|
|
82
183
|
}
|
|
83
184
|
let sapCfg = cfg.sap[ctx.sapAlias];
|
|
@@ -88,24 +189,51 @@ registerTool({
|
|
|
88
189
|
const changes = args.changes;
|
|
89
190
|
const declared = args.risk;
|
|
90
191
|
const eff = effectiveRisk(declared, changes, riskCtx);
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
192
|
+
// Transport shown on the approval surfaces — resolved PER OP to mirror
|
|
193
|
+
// what the write tools will actually do (I2, 2026-07-05 review):
|
|
194
|
+
// create-family tools fall back to the session transport, so the box may
|
|
195
|
+
// show it; modify/delete tools do NOT (resolveWriteTransport uses args
|
|
196
|
+
// only + owning-TR override), so those boxes show only an explicit
|
|
197
|
+
// request transport; activate/release take no transport at all. See
|
|
198
|
+
// displayTransport in approvals/op-labels.ts. The plan gate is a batch
|
|
199
|
+
// summary: session fallback applies only when the batch contains a
|
|
200
|
+
// create (the only op it is true for).
|
|
201
|
+
const sessionTransport = getCurrentTransport();
|
|
202
|
+
const planGateTransport = args.transport
|
|
203
|
+
?? (changes.some((c) => c.op === 'create') ? sessionTransport ?? undefined : undefined);
|
|
204
|
+
// Task 6 (agentic-flow, 2026-07-03) — guarded plan chains disable
|
|
205
|
+
// auto-approve entirely: the user's auto_approve consent was given for
|
|
206
|
+
// hand-driven turns, not for phases the harness auto-dispatched. When the
|
|
207
|
+
// flag is active, skip the nag (it only advertises auto-approve) and fall
|
|
208
|
+
// through to the interactive prompts below. Flag off ⇒ this whole block
|
|
209
|
+
// is a no-op and behaviour stays byte-identical (skill-mode compat).
|
|
210
|
+
const guardedRun = isGuardedRunActive();
|
|
211
|
+
// Task 3 (ux-wave1) — qas systems never auto-approve: the role's promise
|
|
212
|
+
// is "no unattended writes", and a pre-configured auto_approve would mint
|
|
213
|
+
// silently. Treat auto_approve as 'never' on a qas alias — composes with
|
|
214
|
+
// the guarded-run skip above, same skip points, ladder order unchanged.
|
|
215
|
+
// (prd needs nothing here: the advisory clamp already prevents minting.)
|
|
216
|
+
const qasNoAutoApprove = role === 'qas';
|
|
217
|
+
// Progressive-disclosure: offer to upgrade never→low on first low-risk
|
|
218
|
+
// encounter. B5: skipped in headless — the nag's raw-mode keypress wait
|
|
219
|
+
// would hang on piped/closed stdin just like any other prompt. Skipped on
|
|
220
|
+
// qas too: the nag only advertises auto-approve, which qas disables.
|
|
221
|
+
if (!isHeadless() && !guardedRun && !qasNoAutoApprove) {
|
|
222
|
+
await maybeShowAutoApproveNag({ sapAlias: ctx.sapAlias, effectiveRisk: eff.level });
|
|
223
|
+
// Refresh cfg so this approval benefits from a just-enabled auto_approve.
|
|
224
|
+
cfg = await loadConfig();
|
|
225
|
+
sapCfg = cfg.sap[ctx.sapAlias];
|
|
226
|
+
riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
|
|
227
|
+
}
|
|
97
228
|
// 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) ||
|
|
229
|
+
const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
99
230
|
(sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
|
|
100
231
|
if (autoApproveAllowed) {
|
|
101
232
|
const ids = [];
|
|
102
233
|
for (const c of changes) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
op: c.op,
|
|
107
|
-
session_id: ctx.session.id,
|
|
108
|
-
}));
|
|
234
|
+
// Task A3: mint via the canonical path — the JWT stores the same
|
|
235
|
+
// object string the write tools' validators will compare against.
|
|
236
|
+
ids.push(await mintChangeApproval(c, ctx.session.id));
|
|
109
237
|
}
|
|
110
238
|
// Tell Rule 8 the user has already covered this batch — they
|
|
111
239
|
// configured auto-approve, so no per-dispatch re-prompts.
|
|
@@ -120,6 +248,29 @@ registerTool({
|
|
|
120
248
|
}),
|
|
121
249
|
};
|
|
122
250
|
}
|
|
251
|
+
// B5 (2026-06-11) — headless fail-fast. Everything below this point
|
|
252
|
+
// prompts on stdin (plan gate, per-change approvals) and would hang a
|
|
253
|
+
// headless run forever. The headless policy NEVER auto-approves writes:
|
|
254
|
+
// the only non-interactive approval path is the user's own pre-configured
|
|
255
|
+
// auto_approve (handled above — it asks nothing). Return an error RESULT
|
|
256
|
+
// so the model can wrap up gracefully instead of the process hanging.
|
|
257
|
+
if (isHeadless()) {
|
|
258
|
+
console.error(chalk.red('headless: approval requested but headless runs cannot approve writes — ' +
|
|
259
|
+
'use advisory mode (write_mode = "advisory-only") or run interactively.'));
|
|
260
|
+
return {
|
|
261
|
+
content: JSON.stringify({
|
|
262
|
+
approved: false,
|
|
263
|
+
headless: true,
|
|
264
|
+
reason: 'headless_no_approval',
|
|
265
|
+
message: 'Headless run: writes cannot be approved without a user at the terminal, and headless runs never auto-approve. ' +
|
|
266
|
+
'No approval IDs were minted — do not attempt any write operations. ' +
|
|
267
|
+
'Summarise the proposed changes so the user can re-run interactively or switch write_mode to "advisory-only".',
|
|
268
|
+
effective_risk: eff.level,
|
|
269
|
+
raised_by: eff.raisedBy,
|
|
270
|
+
}),
|
|
271
|
+
is_error: true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
123
274
|
// Plan-gate: for multi-change requests, ask once whether to apply all,
|
|
124
275
|
// review each, or cancel. Apply-all is the default and the happy path —
|
|
125
276
|
// it skips the redundant per-change prompts that produced the "3 prompts
|
|
@@ -127,7 +278,7 @@ registerTool({
|
|
|
127
278
|
// gate. Cancel rejects the whole plan.
|
|
128
279
|
let skipPerChangeGates = false;
|
|
129
280
|
if (changes.length > 1) {
|
|
130
|
-
const mode = await renderPlanGate(args.summary, changes,
|
|
281
|
+
const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff);
|
|
131
282
|
if (mode === 'cancel') {
|
|
132
283
|
return {
|
|
133
284
|
content: JSON.stringify({
|
|
@@ -159,13 +310,16 @@ registerTool({
|
|
|
159
310
|
const rejections = [];
|
|
160
311
|
const autoApproveActivateFor = new Set();
|
|
161
312
|
const autoSkipActivateFor = new Set();
|
|
313
|
+
// Sibling matching keys are canonicalized (Task A3) so a decorated modify
|
|
314
|
+
// row ("ZBP_FOO (testclasses include)") still pairs with its bare-named
|
|
315
|
+
// activate sibling ("ZBP_FOO").
|
|
162
316
|
const writeKeys = new Set();
|
|
163
317
|
for (const c of changes) {
|
|
164
318
|
if (c.op === 'modify' || c.op === 'create')
|
|
165
|
-
writeKeys.add(`${c.type}:${c.object}`);
|
|
319
|
+
writeKeys.add(`${c.type}:${canonicalApprovalObject(c.object)}`);
|
|
166
320
|
}
|
|
167
321
|
for (const c of changes) {
|
|
168
|
-
const objKey = `${c.type}:${c.object}`;
|
|
322
|
+
const objKey = `${c.type}:${canonicalApprovalObject(c.object)}`;
|
|
169
323
|
let outcome;
|
|
170
324
|
if (skipPerChangeGates) {
|
|
171
325
|
outcome = { approved: true };
|
|
@@ -177,17 +331,14 @@ registerTool({
|
|
|
177
331
|
outcome = { approved: false, reason: 'sibling_write_skipped' };
|
|
178
332
|
}
|
|
179
333
|
else {
|
|
180
|
-
outcome = await renderPerChangeApprovalV3(c, eff, args.transport);
|
|
334
|
+
outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport));
|
|
181
335
|
}
|
|
182
336
|
if (outcome.approved) {
|
|
183
337
|
if (c.op === 'modify' || c.op === 'create')
|
|
184
338
|
autoApproveActivateFor.add(objKey);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
op: c.op,
|
|
189
|
-
session_id: ctx.session.id,
|
|
190
|
-
}));
|
|
339
|
+
// Task A3: mint via the canonical path (same code path the write
|
|
340
|
+
// tools' validators compare against).
|
|
341
|
+
approvalIds.push(await mintChangeApproval(c, ctx.session.id));
|
|
191
342
|
}
|
|
192
343
|
else {
|
|
193
344
|
if (outcome.reason === '__cancel_turn__') {
|