moflo 4.8.56-rc.8 → 4.8.56
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/.claude/guidance/shipped/moflo-user-facing-language.md +34 -0
- package/.claude/skills/publish/SKILL.md +164 -0
- package/.claude/skills/reset-epic/SKILL.md +2 -1
- package/package.json +2 -2
- package/src/modules/cli/dist/src/commands/epic.js +51 -4
- package/src/modules/cli/dist/src/epic/runner-adapter.js +43 -1
- package/src/modules/cli/dist/src/epic/spells/auto-merge.yaml +14 -8
- package/src/modules/cli/dist/src/epic/spells/single-branch.yaml +16 -9
- package/src/modules/cli/dist/src/mcp-tools/spell-tools.js +44 -0
- package/src/modules/cli/dist/src/mcp-tools/tool-names.js +1 -0
- package/src/modules/cli/dist/src/version.js +1 -1
- package/src/modules/cli/package.json +2 -2
- package/src/modules/plugins/dist/index.js +0 -1
- package/src/modules/shared/dist/utils/platform.js +0 -2
- package/src/modules/spells/dist/commands/bash-command.js +7 -2
- package/src/modules/spells/dist/commands/github-command.js +36 -0
- package/src/modules/spells/dist/core/permission-disclosure.js +36 -43
- package/src/modules/spells/dist/core/permission-resolver.js +17 -9
- package/src/modules/spells/dist/core/preflight-checker.js +158 -0
- package/src/modules/spells/dist/core/runner.js +57 -8
- package/src/modules/spells/dist/factory/runner-bridge.js +2 -0
- package/src/modules/spells/dist/factory/runner-factory.js +2 -1
- package/src/modules/spells/dist/schema/validator.js +27 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# User-Facing Language Guidelines
|
|
2
|
+
|
|
3
|
+
**Purpose:** Ensure all text shown to end users is approachable and non-alarming. MoFlo is used by developers and non-technical users alike — the language we use in output, prompts, reports, and error messages must be clear to someone who has never written code.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Principles
|
|
8
|
+
|
|
9
|
+
1. **Avoid technical jargon in user-visible output.** Terms like "destructive", "mutation", "elevated privileges", or "sandbox escape" are meaningful to engineers but alarming or confusing to non-technical users. Prefer plain risk-level language: "No risk", "Low risk", "Moderate risk", "Higher risk".
|
|
10
|
+
|
|
11
|
+
2. **Explain what happens, not the mechanism.** Instead of "shell command execution", say "Runs commands on your machine". Instead of "fs:write capability", say "Creates, overwrites, or deletes files".
|
|
12
|
+
|
|
13
|
+
3. **Reserve technical terms for internal code.** Type names, variable names, config keys, log prefixes, and developer documentation can use precise technical language. The boundary is: if the user sees it, simplify it.
|
|
14
|
+
|
|
15
|
+
4. **Err on the side of calm.** When something requires attention, state the facts without dramatizing. "This step modifies files" is better than "WARNING: DESTRUCTIVE OPERATION DETECTED".
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Where This Applies
|
|
20
|
+
|
|
21
|
+
- Permission disclosure / risk analysis reports
|
|
22
|
+
- Error messages shown to users (not debug logs)
|
|
23
|
+
- CLI output and status messages
|
|
24
|
+
- MCP tool descriptions and response messages
|
|
25
|
+
- Spell dry-run and acceptance gate output
|
|
26
|
+
- Any `console.log` that a user will see during normal operation
|
|
27
|
+
|
|
28
|
+
## Where This Does NOT Apply
|
|
29
|
+
|
|
30
|
+
- Internal type definitions and variable names
|
|
31
|
+
- Developer-facing config keys (e.g., `allowDestructive` in spell YAML)
|
|
32
|
+
- Debug/trace logs gated behind verbose flags
|
|
33
|
+
- Code comments and docstrings
|
|
34
|
+
- Test descriptions
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: publish
|
|
3
|
+
description: Bump version, build, test, publish to npm, and install locally
|
|
4
|
+
arguments: "[patch|minor|major] [-rc]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /publish - Version Bump, Build, Test & Publish
|
|
8
|
+
|
|
9
|
+
Automated release pipeline for moflo. Bumps version, commits, builds, tests, runs doctor, publishes to npm, and installs the new version locally.
|
|
10
|
+
|
|
11
|
+
**Arguments:** $ARGUMENTS
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
/publish # patch bump (4.8.56 → 4.8.57)
|
|
17
|
+
/publish minor # minor bump (4.8.56 → 4.9.0)
|
|
18
|
+
/publish major # major bump (4.8.56 → 5.0.0)
|
|
19
|
+
/publish -rc # patch RC bump (4.8.56 → 4.8.57-rc.1, or 4.8.57-rc.1 → 4.8.57-rc.2)
|
|
20
|
+
/publish minor -rc # minor RC bump (4.8.56 → 4.9.0-rc.1)
|
|
21
|
+
/publish patch # explicit patch bump
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Step-by-Step Procedure
|
|
25
|
+
|
|
26
|
+
Follow docs/BUILD.md exactly. Every step must succeed before proceeding to the next.
|
|
27
|
+
|
|
28
|
+
### Step 1: Parse Arguments
|
|
29
|
+
|
|
30
|
+
- Default bump type is `patch` if not specified
|
|
31
|
+
- If `-rc` flag is present, produce a release candidate version
|
|
32
|
+
- Determine the new version string:
|
|
33
|
+
- **Without `-rc`:** Use `npm version <patch|minor|major> --no-git-tag-version`
|
|
34
|
+
- **With `-rc`:** Calculate manually:
|
|
35
|
+
- If current version is already an RC of the same bump level (e.g., `4.8.57-rc.3`), increment the RC number (→ `4.8.57-rc.4`)
|
|
36
|
+
- Otherwise, bump the base version and append `-rc.1` (e.g., `4.8.56` → `4.8.57-rc.1`)
|
|
37
|
+
- Write with: `npm version <new-version> --no-git-tag-version`
|
|
38
|
+
|
|
39
|
+
### Step 2: Rebuild After Version Bump
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm run build
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This syncs the version to `src/modules/cli/src/version.ts` and `src/modules/cli/package.json` via the `version` lifecycle script, then compiles all TypeScript.
|
|
46
|
+
|
|
47
|
+
**Must exit 0.** If it fails, stop and fix the build error.
|
|
48
|
+
|
|
49
|
+
### Step 3: Run Tests
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm test
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Must have 0 test file failures.** If any test files fail, retest them individually to distinguish real failures from flaky ones (per broken window theory). Fix all real failures before proceeding.
|
|
56
|
+
|
|
57
|
+
### Step 4: Run Doctor
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx moflo doctor --fix
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
All checks must pass (warnings are acceptable on Windows for sandbox tier). If doctor finds fixable issues, it will auto-fix them. If manual fixes are needed, stop and address them.
|
|
64
|
+
|
|
65
|
+
### Step 5: Commit & Push
|
|
66
|
+
|
|
67
|
+
Commit the version bump files:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git add package.json package-lock.json src/modules/cli/package.json src/modules/cli/src/version.ts
|
|
71
|
+
git commit -m "chore: bump version to <new-version>"
|
|
72
|
+
git push origin main
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Only commit version-related files. Do not stage unrelated changes.
|
|
76
|
+
|
|
77
|
+
### Step 6: Verify npm Authentication
|
|
78
|
+
|
|
79
|
+
Before publishing, verify npm auth is valid:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm whoami
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If this returns a 401 or "not logged in" error, the auth token in `~/.npmrc` is missing or expired. Ask the user to provide a valid npm publish token.
|
|
86
|
+
|
|
87
|
+
**How to create a token** (provide these instructions to the user if needed):
|
|
88
|
+
1. Go to https://www.npmjs.com/settings/~/tokens
|
|
89
|
+
2. Click "Generate New Token" → select "Granular Access Token"
|
|
90
|
+
3. Set permissions: "Read and write" for the `moflo` package
|
|
91
|
+
4. Copy the token (starts with `npm_...`)
|
|
92
|
+
|
|
93
|
+
Once the user provides the token, write it to `~/.npmrc`:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
echo "//registry.npmjs.org/:_authToken=<token>" > ~/.npmrc
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then verify with `npm whoami` before proceeding.
|
|
100
|
+
|
|
101
|
+
### Step 7: Publish to npm
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm publish --tag <tag>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- For stable releases: `npm publish` (publishes to `latest` tag by default)
|
|
108
|
+
- For RC releases: `npm publish --tag rc` (publishes to `rc` tag so it doesn't become `latest`)
|
|
109
|
+
|
|
110
|
+
**OTP handling:**
|
|
111
|
+
- If npm returns an OTP/one-time-password error, ask the user for their authenticator code
|
|
112
|
+
- Then retry with: `npm publish --otp=<code> --tag <tag>`
|
|
113
|
+
- Tip: Granular access tokens created on npmjs.com do NOT require OTP — prefer those over legacy tokens
|
|
114
|
+
|
|
115
|
+
### Step 8: Verify Publication
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npm view moflo version
|
|
119
|
+
npm view moflo dist-tags
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Confirm the published version matches what we just built.
|
|
123
|
+
|
|
124
|
+
### Step 9: Install Locally
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm install moflo@<new-version> --save-dev
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This updates `package.json` and `package-lock.json` to use the newly published version as a devDependency.
|
|
131
|
+
|
|
132
|
+
### Step 10: Final Commit
|
|
133
|
+
|
|
134
|
+
If `package.json` or `package-lock.json` changed from the install:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
git add package.json package-lock.json
|
|
138
|
+
git commit -m "chore: install moflo@<new-version>"
|
|
139
|
+
git push origin main
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Output
|
|
143
|
+
|
|
144
|
+
Print a summary at the end:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
Publish Summary
|
|
148
|
+
───────────────
|
|
149
|
+
Version: <old> → <new>
|
|
150
|
+
Tag: latest | rc
|
|
151
|
+
Build: passed
|
|
152
|
+
Tests: <N> files passed, <N> tests passed
|
|
153
|
+
Doctor: <N> passed, <N> warnings
|
|
154
|
+
Published: moflo@<new-version>
|
|
155
|
+
Installed: moflo@<new-version> (devDependency)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Important
|
|
159
|
+
|
|
160
|
+
- All commands run from the project root — never cd into subdirectories
|
|
161
|
+
- Never use `--force` on npm publish
|
|
162
|
+
- Never install moflo globally — it is always a local devDependency
|
|
163
|
+
- If any step fails, stop and fix the issue before proceeding — do not skip steps
|
|
164
|
+
- The `prepublishOnly` script in package.json runs `check-version-sync.mjs && npm run build` automatically, so npm publish will fail-fast if versions are out of sync
|
|
@@ -26,7 +26,8 @@ Given an epic issue number, perform ALL of the following:
|
|
|
26
26
|
4. **Delete local branches**: Delete any local branches matching the same patterns (skip the current branch)
|
|
27
27
|
5. **Reopen issues**: Reopen all story issues and the epic itself if any were closed
|
|
28
28
|
6. **Uncheck task lists**: Edit the epic body to uncheck all `- [x]` checkboxes back to `- [ ]`
|
|
29
|
-
7. **
|
|
29
|
+
7. **Clear epic-state memory**: Delete all entries in the `epic-state` memory namespace for this epic and its stories (keys: `epic-<N>`, `story-<N>` for each story). Use `mcp__moflo__memory_list` with namespace `epic-state` to find entries, then `mcp__moflo__memory_delete` for each.
|
|
30
|
+
8. **Verify clean state**: Confirm all stories are OPEN, no related PRs are open, no related branches exist, and epic-state memory is empty
|
|
30
31
|
|
|
31
32
|
## Output
|
|
32
33
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.8.56
|
|
3
|
+
"version": "4.8.56",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. Forked from ruflo/claude-flow with patches applied to source, plus feature-level orchestration.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"@types/js-yaml": "^4.0.9",
|
|
112
112
|
"@types/node": "^20.19.37",
|
|
113
113
|
"eslint": "^8.0.0",
|
|
114
|
-
"moflo": "^4.8.56-rc.
|
|
114
|
+
"moflo": "^4.8.56-rc.20",
|
|
115
115
|
"tsx": "^4.21.0",
|
|
116
116
|
"typescript": "^5.9.3",
|
|
117
117
|
"vitest": "^4.0.0"
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* flo epic reset <epic-number> Clear epic memory state
|
|
13
13
|
*/
|
|
14
14
|
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { execSync } from 'node:child_process';
|
|
15
16
|
import { join, dirname } from 'node:path';
|
|
16
17
|
import { fileURLToPath } from 'node:url';
|
|
17
18
|
import { isEpicIssue, fetchEpicIssue, extractStories, enrichStoryNames, resolveExecutionOrder, } from '../epic/index.js';
|
|
@@ -80,8 +81,34 @@ async function runEpic(source, strategy, dryRun) {
|
|
|
80
81
|
catch {
|
|
81
82
|
// No prior state — fresh run
|
|
82
83
|
}
|
|
84
|
+
// 3b. Reconcile memory with branch state — if the branch doesn't exist
|
|
85
|
+
// or has no commits ahead of main, memory is stale (e.g. after a reset)
|
|
83
86
|
if (completedStories.size > 0) {
|
|
84
|
-
|
|
87
|
+
const slug = issue.title
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
90
|
+
.replace(/^-|-$/g, '')
|
|
91
|
+
.substring(0, 40);
|
|
92
|
+
const epicBranch = `epic/${issueNumber}-${slug}`;
|
|
93
|
+
let branchHasCommits = false;
|
|
94
|
+
try {
|
|
95
|
+
// Check if the epic branch exists (local or remote) and has commits ahead of main
|
|
96
|
+
const revCheck = execSync(`git rev-parse --verify refs/heads/${epicBranch} 2>/dev/null || git rev-parse --verify refs/remotes/origin/${epicBranch} 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
97
|
+
if (revCheck) {
|
|
98
|
+
const aheadCount = execSync(`git rev-list --count main..${revCheck}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
99
|
+
branchHasCommits = parseInt(aheadCount, 10) > 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Branch doesn't exist — memory is stale
|
|
104
|
+
}
|
|
105
|
+
if (branchHasCommits) {
|
|
106
|
+
console.log(`[epic] Resuming: ${completedStories.size} stories already completed (branch verified)`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.log(`[epic] Memory shows ${completedStories.size} completed stories, but epic branch is missing or empty — starting fresh`);
|
|
110
|
+
completedStories = new Set();
|
|
111
|
+
}
|
|
85
112
|
}
|
|
86
113
|
// 4. Build slug for branch name
|
|
87
114
|
const slug = issue.title
|
|
@@ -244,13 +271,33 @@ async function resetEpic(epicNumber) {
|
|
|
244
271
|
return { success: false, message: 'Usage: flo epic reset <epic-number>' };
|
|
245
272
|
}
|
|
246
273
|
console.log(`[epic] Clearing state for epic #${epicNumber}...`);
|
|
274
|
+
// 1. Find all story keys for this epic from memory
|
|
275
|
+
let storyKeys = [];
|
|
247
276
|
try {
|
|
248
|
-
await runEpicSpell(`name: epic-reset\nsteps:\n - id:
|
|
249
|
-
|
|
277
|
+
const searchResult = await runEpicSpell(`name: epic-reset-search\nsteps:\n - id: find-stories\n type: memory\n config:\n action: search\n namespace: epic-state\n query: "epic ${epicNumber} story completed"`, { args: {} });
|
|
278
|
+
if (searchResult.success && searchResult.outputs['find-stories']) {
|
|
279
|
+
const data = searchResult.outputs['find-stories'];
|
|
280
|
+
if (data.results) {
|
|
281
|
+
storyKeys = data.results.map(r => r.key).filter(k => k.startsWith('story-'));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
250
284
|
}
|
|
251
285
|
catch {
|
|
252
|
-
|
|
286
|
+
// Couldn't search — just clear the epic key below
|
|
287
|
+
}
|
|
288
|
+
// 2. Clear epic key + all story keys
|
|
289
|
+
const keysToDelete = [`epic-${epicNumber}`, ...storyKeys];
|
|
290
|
+
let cleared = 0;
|
|
291
|
+
for (const key of keysToDelete) {
|
|
292
|
+
try {
|
|
293
|
+
await runEpicSpell(`name: epic-reset-delete\nsteps:\n - id: delete-key\n type: memory\n config:\n action: write\n namespace: epic-state\n key: "${key}"\n value: null`, { args: {} });
|
|
294
|
+
cleared++;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// Key may not exist — safe to ignore
|
|
298
|
+
}
|
|
253
299
|
}
|
|
300
|
+
console.log(`[epic] Cleared ${cleared} memory entries for epic #${epicNumber}`);
|
|
254
301
|
return { success: true };
|
|
255
302
|
}
|
|
256
303
|
// ============================================================================
|
|
@@ -7,10 +7,24 @@
|
|
|
7
7
|
* Story #197: Thin adapter for running spell YAML from epic command.
|
|
8
8
|
* Story #229: Uses shared engine loader instead of inline dynamic import.
|
|
9
9
|
*/
|
|
10
|
+
import * as readline from 'node:readline';
|
|
10
11
|
import { loadSpellEngine } from '../services/engine-loader.js';
|
|
11
12
|
import { createDashboardMemoryAccessor } from '../services/daemon-dashboard.js';
|
|
12
13
|
/** Cached memory accessor — created once per process. */
|
|
13
14
|
let memoryAccessor = null;
|
|
15
|
+
/** Prompt the user to accept or decline spell permissions. */
|
|
16
|
+
async function promptAcceptPermissions() {
|
|
17
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
18
|
+
try {
|
|
19
|
+
const answer = await new Promise(resolve => {
|
|
20
|
+
rl.question('\n[epic] Accept these permissions? (y/N) ', resolve);
|
|
21
|
+
});
|
|
22
|
+
return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes';
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
rl.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
14
28
|
/**
|
|
15
29
|
* Run a spell YAML string via the spell engine.
|
|
16
30
|
*
|
|
@@ -31,6 +45,34 @@ export async function runEpicSpell(yamlContent, options = {}) {
|
|
|
31
45
|
console.warn('[epic] ⚠ Spell executions will NOT appear in the dashboard');
|
|
32
46
|
}
|
|
33
47
|
}
|
|
34
|
-
|
|
48
|
+
const runOpts = { ...options, projectRoot: process.cwd(), ...(memoryAccessor ? { memory: memoryAccessor } : {}) };
|
|
49
|
+
const result = await engine.runSpellFromContent(yamlContent, undefined, runOpts);
|
|
50
|
+
// Auto-accept permissions on first run: the spell runner already printed
|
|
51
|
+
// the full risk analysis to the console. The user initiated the epic
|
|
52
|
+
// command, so we accept on their behalf and retry immediately.
|
|
53
|
+
const hasAcceptanceError = !result.success &&
|
|
54
|
+
result.errors.some(e => e.code === 'ACCEPTANCE_REQUIRED');
|
|
55
|
+
if (hasAcceptanceError) {
|
|
56
|
+
const accepted = await promptAcceptPermissions();
|
|
57
|
+
if (!accepted) {
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
// Use the already-loaded engine module (dynamic import) for spells internals.
|
|
61
|
+
// Static cross-package imports break when installed as a dependency because
|
|
62
|
+
// the relative paths from dist/ don't match the source layout.
|
|
63
|
+
const spells = engine;
|
|
64
|
+
const { parseSpell, StepCommandRegistry, builtinCommands, analyzeSpellPermissions, recordAcceptance } = spells;
|
|
65
|
+
const projectRoot = process.cwd();
|
|
66
|
+
const parsed = parseSpell(yamlContent);
|
|
67
|
+
const stepRegistry = new StepCommandRegistry();
|
|
68
|
+
for (const cmd of builtinCommands) {
|
|
69
|
+
stepRegistry.register(cmd, 'built-in');
|
|
70
|
+
}
|
|
71
|
+
const report = analyzeSpellPermissions(parsed.definition, stepRegistry);
|
|
72
|
+
await recordAcceptance(projectRoot, parsed.definition.name, report.permissionHash);
|
|
73
|
+
console.log(`[epic] Permissions accepted for "${parsed.definition.name}" — retrying...\n`);
|
|
74
|
+
return engine.runSpellFromContent(yamlContent, undefined, runOpts);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
35
77
|
}
|
|
36
78
|
//# sourceMappingURL=runner-adapter.js.map
|
|
@@ -33,14 +33,6 @@ arguments:
|
|
|
33
33
|
mofloLevel: hooks
|
|
34
34
|
|
|
35
35
|
steps:
|
|
36
|
-
# Step 0: Pre-flight — fail fast if worktree is dirty or has unmerged files
|
|
37
|
-
- id: preflight-check
|
|
38
|
-
type: bash
|
|
39
|
-
config:
|
|
40
|
-
command: "if [ -n \"$(git diff --name-only --diff-filter=U 2>/dev/null)\" ]; then echo 'ERROR: Unmerged files detected. Resolve conflicts before running epic.' >&2; git diff --name-only --diff-filter=U 2>/dev/null >&2; exit 1; fi && if [ -n \"$(git -c core.autocrlf=false -c core.safecrlf=false status --porcelain 2>/dev/null)\" ]; then echo 'ERROR: Working tree is dirty. Commit, stash, or discard changes before running epic.' >&2; git -c core.autocrlf=false -c core.safecrlf=false status --short 2>/dev/null >&2; exit 1; fi"
|
|
41
|
-
timeout: 120000
|
|
42
|
-
failOnError: true
|
|
43
|
-
|
|
44
36
|
# Step 1: Initialize epic state in memory (enables resume)
|
|
45
37
|
- id: init-state
|
|
46
38
|
type: memory
|
|
@@ -62,8 +54,22 @@ steps:
|
|
|
62
54
|
output: story_results
|
|
63
55
|
steps:
|
|
64
56
|
# 1: Checkout and pull latest base branch
|
|
57
|
+
# Preflights run once BEFORE any step (not per iteration) — fail fast
|
|
58
|
+
# on dirty tree, missing auth, or unreachable remote.
|
|
65
59
|
- id: checkout-base
|
|
66
60
|
type: bash
|
|
61
|
+
preflight:
|
|
62
|
+
- name: "no unmerged files"
|
|
63
|
+
command: "git diff --name-only --diff-filter=U"
|
|
64
|
+
expectExitCode: 0
|
|
65
|
+
- name: "working tree clean (tracked changes)"
|
|
66
|
+
command: "git diff --quiet"
|
|
67
|
+
- name: "working tree clean (staged changes)"
|
|
68
|
+
command: "git diff --cached --quiet"
|
|
69
|
+
- name: "gh cli authenticated"
|
|
70
|
+
command: "gh auth status"
|
|
71
|
+
- name: "origin remote configured"
|
|
72
|
+
command: "git remote get-url origin"
|
|
67
73
|
config:
|
|
68
74
|
command: "git stash --include-untracked -q 2>/dev/null; git checkout {args.base_branch} && git pull origin {args.base_branch}; git stash pop -q 2>/dev/null || true"
|
|
69
75
|
failOnError: true
|
|
@@ -38,14 +38,6 @@ arguments:
|
|
|
38
38
|
mofloLevel: hooks
|
|
39
39
|
|
|
40
40
|
steps:
|
|
41
|
-
# Step 0: Pre-flight — fail fast if worktree is dirty or has unmerged files
|
|
42
|
-
- id: preflight-check
|
|
43
|
-
type: bash
|
|
44
|
-
config:
|
|
45
|
-
command: "if [ -n \"$(git diff --name-only --diff-filter=U 2>/dev/null)\" ]; then echo 'ERROR: Unmerged files detected. Resolve conflicts before running epic.' >&2; git diff --name-only --diff-filter=U 2>/dev/null >&2; exit 1; fi && if [ -n \"$(git -c core.autocrlf=false -c core.safecrlf=false status --porcelain 2>/dev/null)\" ]; then echo 'ERROR: Working tree is dirty. Commit, stash, or discard changes before running epic.' >&2; git -c core.autocrlf=false -c core.safecrlf=false status --short 2>/dev/null >&2; exit 1; fi"
|
|
46
|
-
timeout: 120000
|
|
47
|
-
failOnError: true
|
|
48
|
-
|
|
49
41
|
# Step 1: Initialize epic state in memory (enables resume)
|
|
50
42
|
- id: init-state
|
|
51
43
|
type: memory
|
|
@@ -59,9 +51,20 @@ steps:
|
|
|
59
51
|
stories: "{args.stories}"
|
|
60
52
|
branch: "epic/{args.epic_number}-{args.epic_slug}"
|
|
61
53
|
|
|
62
|
-
# Step
|
|
54
|
+
# Step 2: Create epic branch from base
|
|
55
|
+
# Preflights run BEFORE any step — fail fast on dirty tree or auth issues
|
|
63
56
|
- id: create-branch
|
|
64
57
|
type: bash
|
|
58
|
+
preflight:
|
|
59
|
+
- name: "no unmerged files"
|
|
60
|
+
command: "git diff --name-only --diff-filter=U"
|
|
61
|
+
expectExitCode: 0
|
|
62
|
+
- name: "working tree clean (tracked changes)"
|
|
63
|
+
command: "git diff --quiet"
|
|
64
|
+
- name: "working tree clean (staged changes)"
|
|
65
|
+
command: "git diff --cached --quiet"
|
|
66
|
+
- name: "gh cli authenticated"
|
|
67
|
+
command: "gh auth status"
|
|
65
68
|
config:
|
|
66
69
|
command: "git stash --include-untracked -q 2>/dev/null; git checkout {args.base_branch} && git pull origin {args.base_branch} && (git show-ref --verify --quiet refs/heads/epic/{args.epic_number}-{args.epic_slug} && git checkout epic/{args.epic_number}-{args.epic_slug} || git checkout -b epic/{args.epic_number}-{args.epic_slug}); git stash pop -q 2>/dev/null || true"
|
|
67
70
|
timeout: 120000
|
|
@@ -104,8 +107,12 @@ steps:
|
|
|
104
107
|
epic: "{args.epic_number}"
|
|
105
108
|
|
|
106
109
|
# Step 3: Push epic branch
|
|
110
|
+
# Preflight: verify origin remote exists so push won't fail mid-way
|
|
107
111
|
- id: push-branch
|
|
108
112
|
type: bash
|
|
113
|
+
preflight:
|
|
114
|
+
- name: "origin remote configured"
|
|
115
|
+
command: "git remote get-url origin"
|
|
109
116
|
config:
|
|
110
117
|
command: "git push -u origin epic/{args.epic_number}-{args.epic_slug}"
|
|
111
118
|
timeout: 120000
|
|
@@ -607,5 +607,49 @@ export const spellTools = [
|
|
|
607
607
|
return { action, error: `Unknown action: ${action}. Use 'list' or 'info'.` };
|
|
608
608
|
},
|
|
609
609
|
},
|
|
610
|
+
// --------------------------------------------------------------------------
|
|
611
|
+
// spell_accept — Accept a spell's permissions after reviewing risk analysis
|
|
612
|
+
// --------------------------------------------------------------------------
|
|
613
|
+
{
|
|
614
|
+
name: 'spell_accept',
|
|
615
|
+
description: 'Accept a spell\'s permissions after reviewing the risk analysis. Required before first execution.',
|
|
616
|
+
category: 'spell',
|
|
617
|
+
inputSchema: {
|
|
618
|
+
type: 'object',
|
|
619
|
+
properties: {
|
|
620
|
+
name: { type: 'string', description: 'Spell name (as shown in the risk analysis)' },
|
|
621
|
+
},
|
|
622
|
+
required: ['name'],
|
|
623
|
+
},
|
|
624
|
+
handler: async (input) => {
|
|
625
|
+
const spellName = input.name;
|
|
626
|
+
// Dynamically import to avoid circular deps with the spell engine
|
|
627
|
+
const { recordAcceptance } = await import('../../../../modules/spells/src/core/permission-acceptance.js');
|
|
628
|
+
const { analyzeSpellPermissions } = await import('../../../../modules/spells/src/core/permission-disclosure.js');
|
|
629
|
+
const { StepCommandRegistry } = await import('../../../../modules/spells/src/core/step-command-registry.js');
|
|
630
|
+
const { builtinCommands } = await import('../../../../modules/spells/src/commands/index.js');
|
|
631
|
+
const projectRoot = findProjectRoot();
|
|
632
|
+
// Resolve the spell definition
|
|
633
|
+
const registry = await getRegistry();
|
|
634
|
+
const loaded = registry.resolve(spellName);
|
|
635
|
+
if (!loaded) {
|
|
636
|
+
return { error: `Spell not found in grimoire: ${spellName}` };
|
|
637
|
+
}
|
|
638
|
+
// Build a step command registry for permission analysis
|
|
639
|
+
const stepRegistry = new StepCommandRegistry();
|
|
640
|
+
for (const cmd of builtinCommands) {
|
|
641
|
+
stepRegistry.register(cmd, 'built-in');
|
|
642
|
+
}
|
|
643
|
+
const report = analyzeSpellPermissions(loaded.definition, stepRegistry);
|
|
644
|
+
await recordAcceptance(projectRoot, loaded.definition.name, report.permissionHash);
|
|
645
|
+
return {
|
|
646
|
+
accepted: true,
|
|
647
|
+
spell: loaded.definition.name,
|
|
648
|
+
permissionHash: report.permissionHash,
|
|
649
|
+
overallRisk: report.overallRisk,
|
|
650
|
+
message: `Permissions accepted for "${loaded.definition.name}". You can now cast this spell.`,
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
},
|
|
610
654
|
];
|
|
611
655
|
//# sourceMappingURL=spell-tools.js.map
|
|
@@ -10,6 +10,7 @@ export const TOOL_SPELL_LIST = 'spell_list';
|
|
|
10
10
|
export const TOOL_SPELL_STATUS = 'spell_status';
|
|
11
11
|
export const TOOL_SPELL_CANCEL = 'spell_cancel';
|
|
12
12
|
export const TOOL_SPELL_TEMPLATE = 'spell_template';
|
|
13
|
+
export const TOOL_SPELL_ACCEPT = 'spell_accept';
|
|
13
14
|
// Memory tools
|
|
14
15
|
export const TOOL_MEMORY_STORE = 'memory_store';
|
|
15
16
|
export const TOOL_MEMORY_RETRIEVE = 'memory_retrieve';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moflo/cli",
|
|
3
|
-
"version": "4.8.56
|
|
3
|
+
"version": "4.8.56",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"types": "dist/src/index.d.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"preversion": "echo 'ERROR: Run npm version from the repo root, not from src/modules/cli/' && exit 1",
|
|
33
|
-
"build": "tsc && node -e \"const{cpSync}=require('fs');cpSync('src/epic/
|
|
33
|
+
"build": "tsc && node -e \"const{cpSync}=require('fs');cpSync('src/epic/spells','dist/src/epic/spells',{recursive:true})\"",
|
|
34
34
|
"test": "vitest run",
|
|
35
35
|
"test:plugin-store": "npx tsx src/plugins/tests/standalone-test.ts",
|
|
36
36
|
"test:pattern-store": "npx tsx src/transfer/store/tests/standalone-test.ts",
|
|
@@ -102,7 +102,6 @@ export { Security, validateString, validateNumber, validateBoolean, validateArra
|
|
|
102
102
|
// ============================================================================
|
|
103
103
|
export const VERSION = '3.0.0-alpha.1';
|
|
104
104
|
export const SDK_VERSION = '1.0.0';
|
|
105
|
-
export const MODULE_ID = '@claude-flow/plugins';
|
|
106
105
|
// ============================================================================
|
|
107
106
|
// Quick Start Utilities
|
|
108
107
|
// ============================================================================
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-platform utilities for shell commands and path handling.
|
|
3
3
|
*/
|
|
4
|
-
/** Date of the cross-platform audit */
|
|
5
|
-
export const PLATFORM_AUDIT_DATE = '2026-04-01';
|
|
6
4
|
/** True when running on Windows */
|
|
7
5
|
export const IS_WINDOWS = process.platform === 'win32';
|
|
8
6
|
/** Platform-appropriate null device for stderr/stdout redirection */
|
|
@@ -134,7 +134,7 @@ export const bashCommand = {
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
const elapsed = () => `${((Date.now() - start) / 1000).toFixed(1)}s`;
|
|
137
|
-
const cmdPreview = command.length > 80 ? command.slice(0, 77) + '...' : command;
|
|
137
|
+
const cmdPreview = redactSensitiveFlags(command.length > 80 ? command.slice(0, 77) + '...' : command);
|
|
138
138
|
// Resolve shell: prefer Git Bash on Windows to avoid WSL bash hanging.
|
|
139
139
|
const resolvedShell = platform() === 'win32' ? resolveGitBash() : 'bash';
|
|
140
140
|
// ── OS sandbox wrapping (#410 macOS, #411 Linux) ────────────────────
|
|
@@ -347,7 +347,7 @@ function resolveGitBash() {
|
|
|
347
347
|
* Interactive Claude invocations are left untouched.
|
|
348
348
|
*/
|
|
349
349
|
const CLAUDE_HEADLESS_RE = /\bclaude\b[^|;&]*\s-p\s/;
|
|
350
|
-
const EXISTING_PERM_FLAGS_RE = /\s
|
|
350
|
+
const EXISTING_PERM_FLAGS_RE = /\s*(?:--dangerously-skip-permissions|--permission-mode\s+\S+)\b/g;
|
|
351
351
|
const EXISTING_ALLOWED_TOOLS_RE = /\s*--allowedTools\s+[^\s]+/g;
|
|
352
352
|
function applyClaudePermissions(command, explicitLevel, capabilities) {
|
|
353
353
|
if (!CLAUDE_HEADLESS_RE.test(command))
|
|
@@ -364,6 +364,11 @@ function applyClaudePermissions(command, explicitLevel, capabilities) {
|
|
|
364
364
|
rewritten = rewritten.replace(/ {2,}/g, ' ');
|
|
365
365
|
return rewritten;
|
|
366
366
|
}
|
|
367
|
+
// ── Log redaction — strip alarming flags from heartbeat output ───────────
|
|
368
|
+
const REDACT_FLAGS_RE = /\s*(?:--dangerously-skip-permissions|--permission-mode\s+\S+)\b/g;
|
|
369
|
+
function redactSensitiveFlags(cmd) {
|
|
370
|
+
return cmd.replace(REDACT_FLAGS_RE, '').replace(/ {2,}/g, ' ');
|
|
371
|
+
}
|
|
367
372
|
// ── Best-effort path extraction for scope enforcement ────────────────────
|
|
368
373
|
/**
|
|
369
374
|
* Extract absolute paths from a shell command string.
|
|
@@ -37,6 +37,41 @@ const githubPrerequisites = [
|
|
|
37
37
|
},
|
|
38
38
|
];
|
|
39
39
|
// ============================================================================
|
|
40
|
+
// Preflights — runtime state checks
|
|
41
|
+
// ============================================================================
|
|
42
|
+
/** Confirm an issue/PR number referenced in config actually exists. */
|
|
43
|
+
const githubPreflights = [
|
|
44
|
+
{
|
|
45
|
+
name: 'issue-exists',
|
|
46
|
+
check: async (config) => {
|
|
47
|
+
if (typeof config.issue !== 'number')
|
|
48
|
+
return { passed: true };
|
|
49
|
+
// Skip for create-type actions that don't require the issue to exist.
|
|
50
|
+
if (config.action === 'issue-edit' || config.action === 'issue-fetch' || config.action === 'comment' || config.action === 'label') {
|
|
51
|
+
const result = await execAsync(`gh issue view ${config.issue} --json number`, 5000);
|
|
52
|
+
if (result.exitCode === 0)
|
|
53
|
+
return { passed: true };
|
|
54
|
+
return { passed: false, reason: `issue #${config.issue} not found or not accessible` };
|
|
55
|
+
}
|
|
56
|
+
return { passed: true };
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'pr-exists',
|
|
61
|
+
check: async (config) => {
|
|
62
|
+
if (typeof config.pr !== 'number')
|
|
63
|
+
return { passed: true };
|
|
64
|
+
if (config.action === 'pr-merge' || config.action === 'pr-find') {
|
|
65
|
+
const result = await execAsync(`gh pr view ${config.pr} --json number`, 5000);
|
|
66
|
+
if (result.exitCode === 0)
|
|
67
|
+
return { passed: true };
|
|
68
|
+
return { passed: false, reason: `PR #${config.pr} not found or not accessible` };
|
|
69
|
+
}
|
|
70
|
+
return { passed: true };
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
// ============================================================================
|
|
40
75
|
// GitHub Step Command (thin wrapper delegating to github-cli tool)
|
|
41
76
|
// ============================================================================
|
|
42
77
|
export const githubCommand = {
|
|
@@ -45,6 +80,7 @@ export const githubCommand = {
|
|
|
45
80
|
capabilities: [{ type: 'shell' }, { type: 'net' }],
|
|
46
81
|
defaultMofloLevel: 'none',
|
|
47
82
|
prerequisites: githubPrerequisites,
|
|
83
|
+
preflight: githubPreflights,
|
|
48
84
|
configSchema: {
|
|
49
85
|
type: 'object',
|
|
50
86
|
properties: {
|
|
@@ -16,37 +16,34 @@
|
|
|
16
16
|
import { resolvePermissions, } from './permission-resolver.js';
|
|
17
17
|
import { checkCapabilities } from './capability-validator.js';
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
|
-
// ============================================================================
|
|
20
|
-
// Destructive Capability Classification
|
|
21
|
-
// ============================================================================
|
|
22
19
|
/**
|
|
23
|
-
* Capabilities classified as
|
|
20
|
+
* Capabilities classified as higher-risk — can permanently modify or delete
|
|
24
21
|
* data, spawn processes, or access credentials. Users must be made aware.
|
|
25
22
|
*/
|
|
26
|
-
const
|
|
23
|
+
const HIGHER_RISK_CAPABILITIES = new Set([
|
|
27
24
|
'shell',
|
|
28
25
|
'fs:write',
|
|
29
26
|
'browser:evaluate',
|
|
30
27
|
'credentials',
|
|
31
28
|
]);
|
|
32
29
|
/**
|
|
33
|
-
* Capabilities classified as
|
|
34
|
-
* autonomous processes.
|
|
30
|
+
* Capabilities classified as moderate-risk — can read private data or spawn
|
|
31
|
+
* autonomous processes. Worth calling out but not directly destructive.
|
|
35
32
|
*/
|
|
36
|
-
const
|
|
33
|
+
const MODERATE_RISK_CAPABILITIES = new Set([
|
|
37
34
|
'agent',
|
|
38
35
|
'net',
|
|
39
36
|
'browser',
|
|
40
37
|
]);
|
|
41
38
|
/** Human-readable risk explanations for each capability. */
|
|
42
39
|
const RISK_EXPLANATIONS = {
|
|
43
|
-
'shell': '
|
|
44
|
-
'fs:write': '
|
|
45
|
-
'browser:evaluate': '
|
|
46
|
-
'credentials': '
|
|
47
|
-
'agent': '
|
|
48
|
-
'net': '
|
|
49
|
-
'browser': '
|
|
40
|
+
'shell': 'Runs shell commands on your machine',
|
|
41
|
+
'fs:write': 'Creates, overwrites, or deletes files',
|
|
42
|
+
'browser:evaluate': 'Executes JavaScript in a browser context',
|
|
43
|
+
'credentials': 'Accesses stored secrets and API keys',
|
|
44
|
+
'agent': 'Spawns autonomous AI sub-agents',
|
|
45
|
+
'net': 'Makes network requests to external services',
|
|
46
|
+
'browser': 'Launches and controls browser sessions',
|
|
50
47
|
};
|
|
51
48
|
// ============================================================================
|
|
52
49
|
// Analysis Functions
|
|
@@ -104,18 +101,18 @@ export function analyzeSpellPermissions(definition, registry) {
|
|
|
104
101
|
function classifyCapabilities(caps) {
|
|
105
102
|
const warnings = [];
|
|
106
103
|
for (const cap of caps) {
|
|
107
|
-
if (
|
|
104
|
+
if (HIGHER_RISK_CAPABILITIES.has(cap.type)) {
|
|
108
105
|
warnings.push({
|
|
109
106
|
capability: cap.type,
|
|
110
|
-
riskLevel: '
|
|
107
|
+
riskLevel: 'higher',
|
|
111
108
|
explanation: RISK_EXPLANATIONS[cap.type] ?? `Has ${cap.type} access`,
|
|
112
109
|
scope: cap.scope,
|
|
113
110
|
});
|
|
114
111
|
}
|
|
115
|
-
else if (
|
|
112
|
+
else if (MODERATE_RISK_CAPABILITIES.has(cap.type)) {
|
|
116
113
|
warnings.push({
|
|
117
114
|
capability: cap.type,
|
|
118
|
-
riskLevel: '
|
|
115
|
+
riskLevel: 'moderate',
|
|
119
116
|
explanation: RISK_EXPLANATIONS[cap.type] ?? `Has ${cap.type} access`,
|
|
120
117
|
scope: cap.scope,
|
|
121
118
|
});
|
|
@@ -124,11 +121,11 @@ function classifyCapabilities(caps) {
|
|
|
124
121
|
return warnings;
|
|
125
122
|
}
|
|
126
123
|
function computeRiskLevel(warnings) {
|
|
127
|
-
if (warnings.some(w => w.riskLevel === '
|
|
128
|
-
return '
|
|
129
|
-
if (warnings.some(w => w.riskLevel === '
|
|
130
|
-
return '
|
|
131
|
-
return '
|
|
124
|
+
if (warnings.some(w => w.riskLevel === 'higher'))
|
|
125
|
+
return 'higher';
|
|
126
|
+
if (warnings.some(w => w.riskLevel === 'moderate'))
|
|
127
|
+
return 'moderate';
|
|
128
|
+
return 'none';
|
|
132
129
|
}
|
|
133
130
|
// ============================================================================
|
|
134
131
|
// Permission Hash (for acceptance tracking)
|
|
@@ -159,18 +156,19 @@ function computePermissionHash(steps) {
|
|
|
159
156
|
// ============================================================================
|
|
160
157
|
// Formatting — Human-Readable Output
|
|
161
158
|
// ============================================================================
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
159
|
+
const RISK_LABELS = {
|
|
160
|
+
none: '[No risk]',
|
|
161
|
+
low: '[Low risk]',
|
|
162
|
+
moderate: '[Moderate risk]',
|
|
163
|
+
higher: '[Higher risk]',
|
|
166
164
|
};
|
|
167
165
|
/**
|
|
168
166
|
* Format a single step's permission report for display.
|
|
169
167
|
*/
|
|
170
168
|
export function formatStepPermissionReport(report) {
|
|
171
169
|
const lines = [];
|
|
172
|
-
const
|
|
173
|
-
lines.push(` ${
|
|
170
|
+
const label = RISK_LABELS[report.riskLevel];
|
|
171
|
+
lines.push(` ${label} ${report.stepId} (${report.stepType})`);
|
|
174
172
|
lines.push(` Permission level: ${report.permissionLevel}`);
|
|
175
173
|
if (report.resolved.allowedTools) {
|
|
176
174
|
lines.push(` Allowed tools: ${report.resolved.allowedTools.join(', ')}`);
|
|
@@ -179,13 +177,11 @@ export function formatStepPermissionReport(report) {
|
|
|
179
177
|
lines.push(` Allowed tools: ALL (autonomous)`);
|
|
180
178
|
}
|
|
181
179
|
if (report.warnings.length > 0) {
|
|
182
|
-
lines.push(' Warnings:');
|
|
183
180
|
for (const w of report.warnings) {
|
|
184
181
|
const scopeNote = w.scope?.length
|
|
185
182
|
? ` (scoped to: ${w.scope.join(', ')})`
|
|
186
183
|
: '';
|
|
187
|
-
|
|
188
|
-
lines.push(` ${marker} ${w.capability}: ${w.explanation}${scopeNote}`);
|
|
184
|
+
lines.push(` - ${w.capability}: ${w.explanation}${scopeNote}`);
|
|
189
185
|
}
|
|
190
186
|
}
|
|
191
187
|
return lines.join('\n');
|
|
@@ -196,22 +192,19 @@ export function formatStepPermissionReport(report) {
|
|
|
196
192
|
*/
|
|
197
193
|
export function formatSpellPermissionReport(report) {
|
|
198
194
|
const lines = [];
|
|
199
|
-
lines.push(
|
|
200
|
-
lines.push(`Overall
|
|
195
|
+
lines.push(`--- Risk Analysis: ${report.spellName} ---`);
|
|
196
|
+
lines.push(`Overall: ${RISK_LABELS[report.overallRisk]}`);
|
|
201
197
|
lines.push(`Permission hash: ${report.permissionHash}`);
|
|
202
198
|
lines.push('');
|
|
203
199
|
for (const step of report.steps) {
|
|
204
200
|
lines.push(formatStepPermissionReport(step));
|
|
205
201
|
lines.push('');
|
|
206
202
|
}
|
|
207
|
-
if (report.overallRisk === '
|
|
208
|
-
const
|
|
209
|
-
lines.push(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const caps = s.warnings
|
|
213
|
-
.filter(w => w.riskLevel === 'destructive')
|
|
214
|
-
.map(w => w.capability);
|
|
203
|
+
if (report.overallRisk === 'higher' || report.overallRisk === 'moderate') {
|
|
204
|
+
const riskySteps = report.steps.filter(s => s.riskLevel === 'higher' || s.riskLevel === 'moderate');
|
|
205
|
+
lines.push(`--- ${riskySteps.length} step(s) require elevated permissions ---`);
|
|
206
|
+
for (const s of riskySteps) {
|
|
207
|
+
const caps = s.warnings.map(w => w.capability);
|
|
215
208
|
lines.push(` - ${s.stepId}: ${caps.join(', ')}`);
|
|
216
209
|
}
|
|
217
210
|
lines.push('');
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
* Permission Resolver — Least-Privilege Escalation for Spell Steps
|
|
3
3
|
*
|
|
4
4
|
* Determines the minimum Claude Code CLI permission flags needed for a step.
|
|
5
|
-
*
|
|
6
|
-
* but varies --allowedTools to enforce least-privilege:
|
|
5
|
+
* Uses the least-permissive --permission-mode for each level:
|
|
7
6
|
*
|
|
8
|
-
* readonly → Read,Glob,Grep (
|
|
9
|
-
* standard → Edit,Write,Read,Glob,Grep (
|
|
10
|
-
* elevated → Edit,Write,Bash,Read,Glob,Grep (
|
|
11
|
-
* autonomous → (no --allowedTools restriction) (
|
|
7
|
+
* readonly → Read,Glob,Grep (no permission bypass)
|
|
8
|
+
* standard → Edit,Write,Read,Glob,Grep (--permission-mode acceptEdits)
|
|
9
|
+
* elevated → Edit,Write,Bash,Read,Glob,Grep (--permission-mode bypassPermissions)
|
|
10
|
+
* autonomous → (no --allowedTools restriction) (--permission-mode bypassPermissions)
|
|
12
11
|
*
|
|
13
12
|
* Resolution order:
|
|
14
13
|
* 1. Explicit `permissionLevel` on the step definition → use directly
|
|
@@ -37,8 +36,17 @@ export function resolvePermissions(explicitLevel, capabilities, additionalTools)
|
|
|
37
36
|
const level = explicitLevel && isValidPermissionLevel(explicitLevel)
|
|
38
37
|
? explicitLevel
|
|
39
38
|
: deriveFromCapabilities(capabilities);
|
|
40
|
-
const args = [
|
|
39
|
+
const args = [];
|
|
40
|
+
const needsBypass = level === 'elevated' || level === 'autonomous';
|
|
41
41
|
let allowedTools;
|
|
42
|
+
// Only bypass permissions when the step actually needs it (shell/autonomous)
|
|
43
|
+
if (needsBypass) {
|
|
44
|
+
args.push('--permission-mode', 'bypassPermissions');
|
|
45
|
+
}
|
|
46
|
+
else if (level === 'standard') {
|
|
47
|
+
args.push('--permission-mode', 'acceptEdits');
|
|
48
|
+
}
|
|
49
|
+
// readonly: no permission mode needed — Read/Glob/Grep don't require approval
|
|
42
50
|
if (level !== 'autonomous') {
|
|
43
51
|
const baseTools = [...TOOL_SETS[level]];
|
|
44
52
|
if (additionalTools) {
|
|
@@ -53,7 +61,7 @@ export function resolvePermissions(explicitLevel, capabilities, additionalTools)
|
|
|
53
61
|
return {
|
|
54
62
|
level,
|
|
55
63
|
cliArgs: args,
|
|
56
|
-
skipPermissions:
|
|
64
|
+
skipPermissions: needsBypass,
|
|
57
65
|
allowedTools: allowedTools ? Object.freeze([...allowedTools]) : undefined,
|
|
58
66
|
};
|
|
59
67
|
}
|
|
@@ -64,7 +72,7 @@ export function resolvePermissions(explicitLevel, capabilities, additionalTools)
|
|
|
64
72
|
* @param explicitLevel - Optional explicit `permissionLevel` from step config.
|
|
65
73
|
* @param capabilities - The step's effective capabilities.
|
|
66
74
|
* @param additionalTools - Extra tools beyond the permission level's defaults.
|
|
67
|
-
* @returns The complete command string (e.g. `claude --
|
|
75
|
+
* @returns The complete command string (e.g. `claude --permission-mode acceptEdits --allowedTools Edit,Write,Read,Glob,Grep -p "..."`)
|
|
68
76
|
*/
|
|
69
77
|
export function buildClaudeCommand(prompt, explicitLevel, capabilities, additionalTools) {
|
|
70
78
|
const resolved = resolvePermissions(explicitLevel, capabilities, additionalTools);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight Checker
|
|
3
|
+
*
|
|
4
|
+
* Runs runtime-state validation BEFORE any step executes. Complements
|
|
5
|
+
* prerequisite-checker.ts (static capability probes) by validating the
|
|
6
|
+
* actual state each step depends on (e.g. issue is open, branch is clean).
|
|
7
|
+
*
|
|
8
|
+
* Two sources contribute preflights:
|
|
9
|
+
* 1. Step commands may declare a `preflight` array on their interface —
|
|
10
|
+
* these run with the resolved step config + args.
|
|
11
|
+
* 2. Step definitions may declare a YAML `preflight:` list of shell
|
|
12
|
+
* commands — these cover ad-hoc runtime checks for generic steps
|
|
13
|
+
* (e.g. a bash step that needs `git diff --quiet` first).
|
|
14
|
+
*
|
|
15
|
+
* All preflights for all steps run in parallel before step execution begins.
|
|
16
|
+
* Any failure aborts the spell with a structured PRECHECK_FAILED error.
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { interpolateString } from './interpolation.js';
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Collection
|
|
22
|
+
// ============================================================================
|
|
23
|
+
/**
|
|
24
|
+
* Collect every preflight that will run for this spell.
|
|
25
|
+
* Walks every step, pulls both step-command preflights and YAML preflights,
|
|
26
|
+
* and binds each with the step's own context/config.
|
|
27
|
+
*/
|
|
28
|
+
export function collectPreflights(definition, registry, context) {
|
|
29
|
+
const out = [];
|
|
30
|
+
collectFromSteps(definition.steps, registry, context, out, 0);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function collectFromSteps(steps, registry, context, out, startIndex) {
|
|
34
|
+
let index = startIndex;
|
|
35
|
+
for (const step of steps) {
|
|
36
|
+
const command = registry.get(step.type);
|
|
37
|
+
// 1. Step-command preflights
|
|
38
|
+
if (command?.preflight) {
|
|
39
|
+
for (const check of command.preflight) {
|
|
40
|
+
out.push(bindCommandPreflight(step, index, check, context));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// 2. Declarative YAML preflights
|
|
44
|
+
if (step.preflight) {
|
|
45
|
+
for (const spec of step.preflight) {
|
|
46
|
+
out.push(bindYamlPreflight(step, index, spec, context));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Recurse into nested steps (loops/conditions)
|
|
50
|
+
if (step.steps && step.steps.length > 0) {
|
|
51
|
+
index = collectFromSteps(step.steps, registry, context, out, index + 1);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
index++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return index;
|
|
58
|
+
}
|
|
59
|
+
function bindCommandPreflight(step, stepIndex, check, context) {
|
|
60
|
+
return {
|
|
61
|
+
stepId: step.id,
|
|
62
|
+
stepIndex,
|
|
63
|
+
name: check.name,
|
|
64
|
+
run: () => {
|
|
65
|
+
const ctx = {
|
|
66
|
+
args: context.args,
|
|
67
|
+
credentials: context.credentials,
|
|
68
|
+
stepId: step.id,
|
|
69
|
+
stepIndex,
|
|
70
|
+
};
|
|
71
|
+
return check.check(step.config, ctx);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function bindYamlPreflight(step, stepIndex, spec, context) {
|
|
76
|
+
return {
|
|
77
|
+
stepId: step.id,
|
|
78
|
+
stepIndex,
|
|
79
|
+
name: spec.name,
|
|
80
|
+
run: async () => {
|
|
81
|
+
const command = interpolateSafe(spec.command, context.args);
|
|
82
|
+
const expected = spec.expectExitCode ?? 0;
|
|
83
|
+
const timeoutMs = spec.timeoutMs ?? 10_000;
|
|
84
|
+
const actualExitCode = await runShellExitCode(command, timeoutMs);
|
|
85
|
+
if (actualExitCode === expected)
|
|
86
|
+
return { passed: true };
|
|
87
|
+
return {
|
|
88
|
+
passed: false,
|
|
89
|
+
reason: `command "${command}" exited with ${actualExitCode}, expected ${expected}`,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Execution
|
|
96
|
+
// ============================================================================
|
|
97
|
+
/** Run all preflights in parallel. Errors are treated as failures. */
|
|
98
|
+
export async function checkPreflights(preflights) {
|
|
99
|
+
return Promise.all(preflights.map(async (pf) => {
|
|
100
|
+
try {
|
|
101
|
+
const result = await pf.run();
|
|
102
|
+
return {
|
|
103
|
+
stepId: pf.stepId,
|
|
104
|
+
name: pf.name,
|
|
105
|
+
passed: result.passed,
|
|
106
|
+
reason: result.reason,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
stepId: pf.stepId,
|
|
112
|
+
name: pf.name,
|
|
113
|
+
passed: false,
|
|
114
|
+
reason: err.message,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
/** Format failed preflights into a user-friendly error message. */
|
|
120
|
+
export function formatPreflightErrors(results) {
|
|
121
|
+
const failed = results.filter(r => !r.passed);
|
|
122
|
+
if (failed.length === 0)
|
|
123
|
+
return '';
|
|
124
|
+
const lines = ['Preflight checks failed:'];
|
|
125
|
+
for (const f of failed) {
|
|
126
|
+
lines.push(` - [${f.stepId}] ${f.name}${f.reason ? `: ${f.reason}` : ''}`);
|
|
127
|
+
}
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
}
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// Internals
|
|
132
|
+
// ============================================================================
|
|
133
|
+
function interpolateSafe(template, args) {
|
|
134
|
+
try {
|
|
135
|
+
const ctx = { args, variables: {} };
|
|
136
|
+
return interpolateString(template, ctx);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return template;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function runShellExitCode(command, timeoutMs) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const shell = process.platform === 'win32' ? process.env.ComSpec ?? 'cmd.exe' : '/bin/sh';
|
|
145
|
+
const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command];
|
|
146
|
+
const child = execFile(shell, shellArgs, { timeout: timeoutMs }, (err) => {
|
|
147
|
+
if (err && typeof err.code === 'number') {
|
|
148
|
+
resolve(err.code);
|
|
149
|
+
}
|
|
150
|
+
else if (err && err.signal) {
|
|
151
|
+
resolve(124); // timeout-ish
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
child.on('exit', (code) => resolve(code ?? 1));
|
|
155
|
+
child.on('error', () => resolve(1));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=preflight-checker.js.map
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Spell Runner
|
|
3
|
-
*
|
|
4
|
-
* Thin orchestrator that drives a parsed SpellDefinition step by step,
|
|
5
|
-
* delegating step execution, validation, loop iteration, rollback,
|
|
6
|
-
* credential masking, and timeout handling to focused modules.
|
|
7
|
-
*/
|
|
8
|
-
export const ENGINE_VERSION = '1.0.0';
|
|
9
1
|
import { ConnectorAccessorImpl } from './connector-accessor.js';
|
|
10
2
|
import { validateSpellDefinition, resolveArguments } from '../schema/validator.js';
|
|
11
3
|
import { compareMofloLevels } from './capability-validator.js';
|
|
@@ -17,8 +9,11 @@ import { rollbackSteps } from './rollback-orchestrator.js';
|
|
|
17
9
|
import { buildCredentialPatterns, addCredentialPattern, collectCredentialNames } from './credential-masker.js';
|
|
18
10
|
import { executeSingleStep } from './step-executor.js';
|
|
19
11
|
import { collectPrerequisites, checkPrerequisites, formatPrerequisiteErrors } from './prerequisite-checker.js';
|
|
12
|
+
import { collectPreflights, checkPreflights, formatPreflightErrors } from './preflight-checker.js';
|
|
20
13
|
import { DENY_ALL_GATEWAY } from './capability-gateway.js';
|
|
21
14
|
import { resolveEffectiveSandbox, formatSandboxLog, DEFAULT_SANDBOX_CONFIG, } from './platform-sandbox.js';
|
|
15
|
+
import { checkAcceptance, recordAcceptance } from './permission-acceptance.js';
|
|
16
|
+
import { analyzeSpellPermissions, formatSpellPermissionReport } from './permission-disclosure.js';
|
|
22
17
|
export class SpellCaster {
|
|
23
18
|
registry;
|
|
24
19
|
credentials;
|
|
@@ -63,6 +58,45 @@ export class SpellCaster {
|
|
|
63
58
|
details: argErrors,
|
|
64
59
|
}], definition.name);
|
|
65
60
|
}
|
|
61
|
+
// ---------------------------------------------------------------
|
|
62
|
+
// Permission acceptance gate: first-run spells show a risk
|
|
63
|
+
// analysis and block execution until the user explicitly accepts
|
|
64
|
+
// via the spell_accept MCP tool.
|
|
65
|
+
// ---------------------------------------------------------------
|
|
66
|
+
if (!options.dryRun && !options.skipAcceptanceCheck && options.projectRoot) {
|
|
67
|
+
const permReport = analyzeSpellPermissions(definition, this.registry);
|
|
68
|
+
const acceptance = await checkAcceptance(options.projectRoot, definition.name, permReport.permissionHash);
|
|
69
|
+
if (!acceptance.accepted) {
|
|
70
|
+
// Auto-accept spells with no real risk — no need to prompt the user
|
|
71
|
+
if (permReport.overallRisk === 'none' || permReport.overallRisk === 'low') {
|
|
72
|
+
await recordAcceptance(options.projectRoot, definition.name, permReport.permissionHash);
|
|
73
|
+
console.log(`[spell] Auto-accepted "${definition.name}" (${permReport.overallRisk} risk)`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const reason = acceptance.reason === 'hash-mismatch'
|
|
77
|
+
? 'Spell permissions have changed since last acceptance'
|
|
78
|
+
: 'First run — reviewing spell permissions';
|
|
79
|
+
const report = formatSpellPermissionReport(permReport);
|
|
80
|
+
console.log(`[spell] ${reason}`);
|
|
81
|
+
console.log(`[spell] Running automatic dry-run validation...\n`);
|
|
82
|
+
console.log(report);
|
|
83
|
+
// Run dry-run validation so the user also sees structural issues
|
|
84
|
+
const dryResult = await dryRunValidate(definition, resolvedArgs, defValidation, options, this.registry, (variables, wfId, stepIndex) => this.buildContext(variables, resolvedArgs, wfId, stepIndex, options.signal));
|
|
85
|
+
if (!dryResult.valid) {
|
|
86
|
+
console.log('\n[spell] Dry-run validation found errors:');
|
|
87
|
+
for (const err of [...dryResult.definitionErrors, ...dryResult.argumentErrors]) {
|
|
88
|
+
console.log(` - ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Block — user must explicitly accept
|
|
92
|
+
console.log(`\n[spell] To accept these permissions, run: spell_accept({ name: "${definition.name}" })`);
|
|
93
|
+
return this.failureResult(spellId, startTime, [{
|
|
94
|
+
code: 'ACCEPTANCE_REQUIRED',
|
|
95
|
+
message: `${reason}. Review the risk analysis above and accept with spell_accept({ name: "${definition.name}" }).`,
|
|
96
|
+
}], definition.name);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
66
100
|
// Pre-flight prerequisite checks (Story #193)
|
|
67
101
|
if (!options.dryRun) {
|
|
68
102
|
const prerequisites = collectPrerequisites(definition, this.registry);
|
|
@@ -75,6 +109,21 @@ export class SpellCaster {
|
|
|
75
109
|
}], definition.name);
|
|
76
110
|
}
|
|
77
111
|
}
|
|
112
|
+
// Preflight runtime-state checks — fail fast before any step runs.
|
|
113
|
+
const preflights = collectPreflights(definition, this.registry, {
|
|
114
|
+
args: resolvedArgs,
|
|
115
|
+
credentials: this.credentials,
|
|
116
|
+
});
|
|
117
|
+
if (preflights.length > 0) {
|
|
118
|
+
const preflightResults = await checkPreflights(preflights);
|
|
119
|
+
if (preflightResults.some(r => !r.passed)) {
|
|
120
|
+
console.log(`[spell] ${formatPreflightErrors(preflightResults)}`);
|
|
121
|
+
return this.failureResult(spellId, startTime, [{
|
|
122
|
+
code: 'PREFLIGHT_FAILED',
|
|
123
|
+
message: formatPreflightErrors(preflightResults),
|
|
124
|
+
}], definition.name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
78
127
|
}
|
|
79
128
|
if (options.dryRun) {
|
|
80
129
|
const dryResult = await dryRunValidate(definition, resolvedArgs, defValidation, options, this.registry, (variables, wfId, stepIndex) => this.buildContext(variables, resolvedArgs, wfId, stepIndex, options.signal));
|
|
@@ -29,6 +29,7 @@ export async function bridgeRunSpell(content, sourceFile, args, options = {}) {
|
|
|
29
29
|
signal: controller.signal,
|
|
30
30
|
memory: options.memory,
|
|
31
31
|
credentials: options.credentials,
|
|
32
|
+
...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
|
|
32
33
|
});
|
|
33
34
|
return result;
|
|
34
35
|
}
|
|
@@ -48,6 +49,7 @@ export async function bridgeExecuteSpell(definition, args, options = {}) {
|
|
|
48
49
|
return await runner.run(definition, args, {
|
|
49
50
|
spellId,
|
|
50
51
|
signal: controller.signal,
|
|
52
|
+
...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
55
|
finally {
|
|
@@ -76,7 +76,8 @@ export async function runSpellFromContent(content, sourceFile, options = {}) {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
const runner = createRunner(options);
|
|
79
|
-
|
|
79
|
+
// Strip factory-only fields; keep RunSpellOptions (extends RunnerOptions) for runner.run()
|
|
80
|
+
const { args = {}, stepDirs: _s, credentials: _c, memory: _m, connectorRegistry: _cr, ...runnerOptions } = options;
|
|
80
81
|
return runner.run(definition, args, runnerOptions);
|
|
81
82
|
}
|
|
82
83
|
// ============================================================================
|
|
@@ -155,6 +155,33 @@ function validateSteps(steps, errors, stepIds, outputVars, options, prefix = 'st
|
|
|
155
155
|
message: `invalid permissionLevel: "${step.permissionLevel}". Valid levels: ${VALID_PERMISSION_LEVELS.join(', ')}`,
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
|
+
// Validate declarative preflight specs (runtime state checks)
|
|
159
|
+
if (step.preflight !== undefined) {
|
|
160
|
+
if (!Array.isArray(step.preflight)) {
|
|
161
|
+
errors.push({ path: `${path}.preflight`, message: 'preflight must be an array' });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
step.preflight.forEach((pf, pi) => {
|
|
165
|
+
const pfPath = `${path}.preflight[${pi}]`;
|
|
166
|
+
if (!pf || typeof pf !== 'object') {
|
|
167
|
+
errors.push({ path: pfPath, message: 'preflight entry must be an object' });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (typeof pf.name !== 'string' || pf.name.length === 0) {
|
|
171
|
+
errors.push({ path: `${pfPath}.name`, message: 'preflight.name is required' });
|
|
172
|
+
}
|
|
173
|
+
if (typeof pf.command !== 'string' || pf.command.length === 0) {
|
|
174
|
+
errors.push({ path: `${pfPath}.command`, message: 'preflight.command is required' });
|
|
175
|
+
}
|
|
176
|
+
if (pf.expectExitCode !== undefined && typeof pf.expectExitCode !== 'number') {
|
|
177
|
+
errors.push({ path: `${pfPath}.expectExitCode`, message: 'expectExitCode must be a number' });
|
|
178
|
+
}
|
|
179
|
+
if (pf.timeoutMs !== undefined && (typeof pf.timeoutMs !== 'number' || pf.timeoutMs <= 0)) {
|
|
180
|
+
errors.push({ path: `${pfPath}.timeoutMs`, message: 'timeoutMs must be a positive number' });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
158
185
|
// Recurse into nested steps (condition/loop/parallel)
|
|
159
186
|
if (step.steps && Array.isArray(step.steps)) {
|
|
160
187
|
validateSteps(step.steps, errors, stepIds, outputVars, options, `${path}.steps`, spellLevel);
|