@pugi/cli 0.1.0-beta.35 → 0.1.0-beta.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/gate.js +144 -53
- package/dist/core/permissions/index.js +3 -1
- package/dist/core/permissions/mode.js +132 -60
- package/dist/core/permissions/state.js +33 -7
- package/dist/core/repl/slash-commands.js +16 -12
- package/dist/core/session.js +48 -0
- package/dist/runtime/cli.js +4 -4
- package/dist/runtime/commands/mcp.js +66 -11
- package/dist/runtime/commands/permissions.js +11 -9
- package/dist/runtime/commands/plan.js +4 -4
- package/dist/runtime/version.js +1 -1
- package/dist/tui/input-box.js +24 -1
- package/dist/tui/permissions-picker.js +14 -6
- package/dist/tui/repl.js +29 -1
- package/package.json +2 -2
|
@@ -1,102 +1,174 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Permission modes — canonical
|
|
2
|
+
* Permission modes — Wave 7 canonical 6-mode taxonomy (Claude Code parity).
|
|
3
3
|
*
|
|
4
|
-
* Pugi
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Pugi α6 shipped a 4-mode taxonomy (`plan | ask | allow | bypass`) that
|
|
5
|
+
* proved fine для daily use but diverged from Claude Code's 6-mode
|
|
6
|
+
* surface (`default | acceptEdits | plan | auto | dontAsk |
|
|
7
|
+
* bypassPermissions`). Wave 7 Sprint 1 epic #2 closes the parity gap so
|
|
8
|
+
* operators coming from Claude Code see the same mode names + same
|
|
9
|
+
* Shift+Tab cycle.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
* with a deterministic sentinel; the model is expected
|
|
12
|
-
* to surface a plan, not execute it.
|
|
13
|
-
* - `ask` — every tool execution prompts the operator. Default
|
|
14
|
-
* mode for new operators; the safe ground state.
|
|
15
|
-
* - `allow` — every tool executes without per-call prompts, BUT
|
|
16
|
-
* the policy hook layer (skill-steering, denial audit,
|
|
17
|
-
* destructive deny-list) still fires.
|
|
18
|
-
* - `bypass` — same as allow but ALSO skips policy hooks. Power-user
|
|
19
|
-
* mode for trusted scripted runs; surface a banner on
|
|
20
|
-
* entry so an operator who flips here by accident sees
|
|
21
|
-
* they have disengaged the audit layer.
|
|
11
|
+
* Canonical 6 modes (Wave 7):
|
|
22
12
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
13
|
+
* - `default` — every tool call asks the operator. Safe
|
|
14
|
+
* ground state, replaces α6 `ask`.
|
|
15
|
+
* - `acceptEdits` — auto-allow write/edit on workspace files,
|
|
16
|
+
* ask for everything else (bash, dispatch).
|
|
17
|
+
* Matches CC's "trust file edits только".
|
|
18
|
+
* - `plan` — read-only proposal mode. Write/dispatch
|
|
19
|
+
* refused with deterministic sentinel; the
|
|
20
|
+
* model surfaces a plan, не executes.
|
|
21
|
+
* - `auto` — classifier decides per-call. Phase 1
|
|
22
|
+
* (this PR) ships regex allowlist (safe
|
|
23
|
+
* commands) + regex denylist (destructive).
|
|
24
|
+
* Anything else falls back to ask.
|
|
25
|
+
* - `dontAsk` — auto-allow everything except `permissions.deny`
|
|
26
|
+
* list. Replaces α6 `allow`.
|
|
27
|
+
* - `bypassPermissions` — skip ALL checks including deny list +
|
|
28
|
+
* policy hooks. Has a circuit-breaker for
|
|
29
|
+
* catastrophic patterns (rm -rf /, fork bomb,
|
|
30
|
+
* dd if=/) that refuses regardless of mode.
|
|
31
|
+
* Replaces α6 `bypass`.
|
|
32
|
+
*
|
|
33
|
+
* Backwards-compat aliases: the α6 short names (`ask`, `allow`, `bypass`)
|
|
34
|
+
* map to the new canonical names via `MODE_ALIASES`. `parsePermissionMode`
|
|
35
|
+
* resolves aliases so existing session.json files keep working.
|
|
36
|
+
*
|
|
37
|
+
* Rename mapping (α6 → α7):
|
|
38
|
+
* ask → default
|
|
39
|
+
* allow → dontAsk
|
|
40
|
+
* bypass → bypassPermissions
|
|
41
|
+
* (plan, acceptEdits, auto unchanged / new)
|
|
29
42
|
*/
|
|
30
43
|
/**
|
|
31
|
-
* Closed list —
|
|
44
|
+
* Closed list — used by Shift+Tab cycle (in order), input validation,
|
|
45
|
+
* and slash-command help. Order matches Claude Code's documented
|
|
46
|
+
* Shift+Tab progression: default → acceptEdits → plan → auto → dontAsk
|
|
47
|
+
* → bypassPermissions → wrap к default.
|
|
32
48
|
*/
|
|
33
49
|
export const PERMISSION_MODES = Object.freeze([
|
|
50
|
+
'default',
|
|
51
|
+
'acceptEdits',
|
|
34
52
|
'plan',
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
53
|
+
'auto',
|
|
54
|
+
'dontAsk',
|
|
55
|
+
'bypassPermissions',
|
|
38
56
|
]);
|
|
39
57
|
/**
|
|
40
58
|
* Default mode applied when no `--mode` flag, no per-workspace session
|
|
41
|
-
* state, and no `defaultPermissionMode`
|
|
42
|
-
* default cautious (`
|
|
43
|
-
* is treated as a new operator who
|
|
44
|
-
* call.
|
|
59
|
+
* state, and no `defaultPermissionMode` в `~/.pugi/config.json`. We
|
|
60
|
+
* default cautious (`default` mode = prompt every call) — an operator
|
|
61
|
+
* who has not configured anything is treated as a new operator who
|
|
62
|
+
* deserves visibility into every tool call.
|
|
45
63
|
*/
|
|
46
|
-
export const DEFAULT_PERMISSION_MODE = '
|
|
64
|
+
export const DEFAULT_PERMISSION_MODE = 'default';
|
|
47
65
|
/**
|
|
48
|
-
*
|
|
66
|
+
* Backwards-compat aliases: α6 short names map to α7 canonical names.
|
|
67
|
+
* `parsePermissionMode` consults this table так existing session.json
|
|
68
|
+
* files + scripts that pass `--mode ask` keep working without breaking.
|
|
69
|
+
*
|
|
70
|
+
* Aliases are one-way: persistence writes the canonical name, so a
|
|
71
|
+
* session that started on `ask` is migrated to `default` on next save.
|
|
72
|
+
*/
|
|
73
|
+
const MODE_ALIASES = Object.freeze({
|
|
74
|
+
ask: 'default',
|
|
75
|
+
allow: 'dontAsk',
|
|
76
|
+
bypass: 'bypassPermissions',
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* Type guard для arbitrary string input (CLI flag, session.json
|
|
49
80
|
* deserialization). Returns false for casing variants — caller is
|
|
50
|
-
* expected to lowercase before testing.
|
|
81
|
+
* expected to lowercase before testing. Aliases are NOT accepted by
|
|
82
|
+
* this predicate; use `parsePermissionMode` for alias resolution.
|
|
51
83
|
*/
|
|
52
84
|
export function isPermissionMode(value) {
|
|
53
85
|
return typeof value === 'string' && PERMISSION_MODES.includes(value);
|
|
54
86
|
}
|
|
55
87
|
/**
|
|
56
|
-
* Parse + validate a mode string. Returns null
|
|
88
|
+
* Parse + validate a mode string. Returns null для invalid input so the
|
|
57
89
|
* caller can surface a typed error (`unknown mode: <value>`) instead of
|
|
58
90
|
* throwing from a parse helper.
|
|
91
|
+
*
|
|
92
|
+
* Resolves α6 aliases (`ask` → `default`, `allow` → `dontAsk`,
|
|
93
|
+
* `bypass` → `bypassPermissions`) for backwards compatibility.
|
|
94
|
+
*
|
|
95
|
+
* Case-handling: lowercases for canonical names but matches camelCase
|
|
96
|
+
* names case-insensitively too (так `acceptedits` resolves to
|
|
97
|
+
* `acceptEdits`). The aliases table covers the legacy lowercase tokens.
|
|
59
98
|
*/
|
|
60
99
|
export function parsePermissionMode(value) {
|
|
61
|
-
const
|
|
62
|
-
|
|
100
|
+
const trimmed = value.trim();
|
|
101
|
+
if (trimmed.length === 0)
|
|
102
|
+
return null;
|
|
103
|
+
// Direct canonical match first (preserves camelCase capitalisation).
|
|
104
|
+
if (isPermissionMode(trimmed))
|
|
105
|
+
return trimmed;
|
|
106
|
+
// Case-insensitive canonical match — operator typed `acceptedits`.
|
|
107
|
+
const lower = trimmed.toLowerCase();
|
|
108
|
+
const canonical = PERMISSION_MODES.find((m) => m.toLowerCase() === lower);
|
|
109
|
+
if (canonical)
|
|
110
|
+
return canonical;
|
|
111
|
+
// α6 alias fallthrough.
|
|
112
|
+
const aliased = MODE_ALIASES[lower];
|
|
113
|
+
if (aliased)
|
|
114
|
+
return aliased;
|
|
115
|
+
return null;
|
|
63
116
|
}
|
|
64
117
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
118
|
+
* Wave 7 Shift+Tab cycle — advance to the next mode in the canonical
|
|
119
|
+
* order, wrapping from the last back to the first. Pure helper so the
|
|
120
|
+
* TUI binding can call it without re-implementing the cycle logic.
|
|
121
|
+
*/
|
|
122
|
+
export function nextPermissionMode(current) {
|
|
123
|
+
const idx = PERMISSION_MODES.indexOf(current);
|
|
124
|
+
if (idx === -1)
|
|
125
|
+
return DEFAULT_PERMISSION_MODE;
|
|
126
|
+
const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length];
|
|
127
|
+
return next ?? DEFAULT_PERMISSION_MODE;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Map the canonical 6-mode taxonomy to the legacy SDK enum used by
|
|
131
|
+
* `@pugi/sdk::permissionModeSchema`. The SDK enum already contains all
|
|
132
|
+
* 6 names so the map is identity для the modes that align; the two
|
|
133
|
+
* α6-only legacy names (`ask`, `allow`) are not part of the canonical
|
|
134
|
+
* set anymore — `default` and `dontAsk` are их replacements.
|
|
74
135
|
*
|
|
75
136
|
* Callers that need the legacy enum (existing bash classifier, settings
|
|
76
|
-
* persistence) should funnel through this helper so the mapping
|
|
77
|
-
* one place.
|
|
137
|
+
* persistence) should funnel through this helper so the mapping stays
|
|
138
|
+
* в one place.
|
|
78
139
|
*/
|
|
79
140
|
export function toLegacyMode(mode) {
|
|
80
141
|
switch (mode) {
|
|
142
|
+
case 'default':
|
|
143
|
+
// SDK enum doesn't carry `default`; the closest legacy semantic is
|
|
144
|
+
// `ask` (prompt-every-call). Persistence layers that round-trip
|
|
145
|
+
// через the SDK enum get back `ask`, which `parsePermissionMode`
|
|
146
|
+
// re-maps to `default` via the alias table. Round-trip safe.
|
|
147
|
+
return 'ask';
|
|
148
|
+
case 'acceptEdits':
|
|
149
|
+
return 'acceptEdits';
|
|
81
150
|
case 'plan':
|
|
82
151
|
return 'plan';
|
|
83
|
-
case '
|
|
84
|
-
return 'ask';
|
|
85
|
-
case 'allow':
|
|
152
|
+
case 'auto':
|
|
86
153
|
return 'auto';
|
|
87
|
-
case '
|
|
154
|
+
case 'dontAsk':
|
|
155
|
+
return 'dontAsk';
|
|
156
|
+
case 'bypassPermissions':
|
|
88
157
|
return 'bypassPermissions';
|
|
89
158
|
}
|
|
90
159
|
}
|
|
91
160
|
/**
|
|
92
|
-
* One-line human-readable summary surfaced by the `/permissions` table
|
|
93
|
-
* and `pugi --help` text.
|
|
94
|
-
*
|
|
161
|
+
* One-line human-readable summary surfaced by the `/permissions` table,
|
|
162
|
+
* the Ink picker, and `pugi --help` text. Each line carries a safety
|
|
163
|
+
* hint ("safe-by-default" / "use carefully" / "power-user only") so an
|
|
164
|
+
* operator скимming the picker knows the risk profile at a glance.
|
|
95
165
|
*/
|
|
96
166
|
export const PERMISSION_MODE_GLOSS = Object.freeze({
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
167
|
+
default: 'Prompt before every tool call. Safe-by-default for new operators.',
|
|
168
|
+
acceptEdits: 'Auto-allow file edit/write; ask for bash + dispatch. Safe-by-default.',
|
|
169
|
+
plan: 'Read-only — propose, never execute. Write + dispatch refused.',
|
|
170
|
+
auto: 'Classifier decides per call (safe regex allowlist; falls back к ask). Use carefully.',
|
|
171
|
+
dontAsk: 'Execute tools without prompts; deny-list still applies. Use carefully.',
|
|
172
|
+
bypassPermissions: 'Skip ALL checks AND policy hooks; circuit-breaker on catastrophic patterns. Power-user only.',
|
|
101
173
|
});
|
|
102
174
|
//# sourceMappingURL=mode.js.map
|
|
@@ -22,8 +22,27 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
|
|
|
22
22
|
import { dirname, resolve } from 'node:path';
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
24
|
import { z } from 'zod';
|
|
25
|
-
import { DEFAULT_PERMISSION_MODE,
|
|
26
|
-
|
|
25
|
+
import { DEFAULT_PERMISSION_MODE, parsePermissionMode, } from './mode.js';
|
|
26
|
+
/**
|
|
27
|
+
* Wave 7: zod enum for the canonical 6-mode taxonomy. Includes α6
|
|
28
|
+
* aliases (`ask`, `allow`, `bypass`) as accepted input — Zod parses
|
|
29
|
+
* them, the helpers below remap к canonical names before returning к
|
|
30
|
+
* the caller. Persistence always writes the canonical name so the file
|
|
31
|
+
* migrates forward on next save.
|
|
32
|
+
*/
|
|
33
|
+
const permissionModeEnum = z.enum([
|
|
34
|
+
// Canonical Wave 7 names.
|
|
35
|
+
'default',
|
|
36
|
+
'acceptEdits',
|
|
37
|
+
'plan',
|
|
38
|
+
'auto',
|
|
39
|
+
'dontAsk',
|
|
40
|
+
'bypassPermissions',
|
|
41
|
+
// α6 backwards-compat aliases — resolved via parsePermissionMode.
|
|
42
|
+
'ask',
|
|
43
|
+
'allow',
|
|
44
|
+
'bypass',
|
|
45
|
+
]);
|
|
27
46
|
const sessionStateSchema = z
|
|
28
47
|
.object({
|
|
29
48
|
permissionMode: permissionModeEnum.optional(),
|
|
@@ -74,7 +93,12 @@ export function getCurrentMode(workspaceRoot) {
|
|
|
74
93
|
try {
|
|
75
94
|
const raw = readFileSync(path, 'utf8');
|
|
76
95
|
const parsed = sessionStateSchema.parse(JSON.parse(raw));
|
|
77
|
-
|
|
96
|
+
if (typeof parsed.permissionMode !== 'string')
|
|
97
|
+
return null;
|
|
98
|
+
// Wave 7: parsePermissionMode resolves α6 aliases (`ask`, `allow`,
|
|
99
|
+
// `bypass`) to their canonical Wave 7 names. A session file written
|
|
100
|
+
// by α6.x is silently upgraded on read.
|
|
101
|
+
return parsePermissionMode(parsed.permissionMode);
|
|
78
102
|
}
|
|
79
103
|
catch {
|
|
80
104
|
return null;
|
|
@@ -112,9 +136,9 @@ export function getPreviousMode(workspaceRoot) {
|
|
|
112
136
|
try {
|
|
113
137
|
const raw = readFileSync(path, 'utf8');
|
|
114
138
|
const parsed = sessionStateSchema.parse(JSON.parse(raw));
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
139
|
+
if (typeof parsed.previousPermissionMode !== 'string')
|
|
140
|
+
return null;
|
|
141
|
+
return parsePermissionMode(parsed.previousPermissionMode);
|
|
118
142
|
}
|
|
119
143
|
catch {
|
|
120
144
|
return null;
|
|
@@ -157,7 +181,9 @@ export function getGlobalDefaultMode(homeDir = homedir()) {
|
|
|
157
181
|
try {
|
|
158
182
|
const raw = readFileSync(path, 'utf8');
|
|
159
183
|
const parsed = globalConfigSchema.parse(JSON.parse(raw));
|
|
160
|
-
|
|
184
|
+
if (typeof parsed.defaultPermissionMode !== 'string')
|
|
185
|
+
return null;
|
|
186
|
+
return parsePermissionMode(parsed.defaultPermissionMode);
|
|
161
187
|
}
|
|
162
188
|
catch {
|
|
163
189
|
return null;
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* `/quit` confirmation and `/help` footer - never inline.
|
|
30
30
|
*/
|
|
31
31
|
import { listRoles } from '../agents/registry.js';
|
|
32
|
+
import { PERMISSION_MODES, parsePermissionMode, } from '../permissions/index.js';
|
|
32
33
|
/**
|
|
33
34
|
* Deterministic stub copy returned by the Tier 3 commands. Spec'd
|
|
34
35
|
* inline so the unit test can pin the exact text without poking at
|
|
@@ -86,7 +87,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
86
87
|
// Settings
|
|
87
88
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
88
89
|
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
89
|
-
{ name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan /
|
|
90
|
+
{ name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (default / acceptEdits / plan / auto / dontAsk / bypassPermissions) (also: /plan, Shift+Tab cycle)', group: 'Settings' },
|
|
90
91
|
{ name: 'plan', args: '[--back | --persist] [<prompt>]', gloss: 'Switch to plan mode (read-only). Same as /permissions plan, slicker UX.', group: 'Settings' },
|
|
91
92
|
{ name: 'model', args: '[<slug>]', gloss: 'Show or select the active model. Bare /model lists tier-gated options', group: 'Settings' },
|
|
92
93
|
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
@@ -332,26 +333,29 @@ export function parseSlashCommand(input) {
|
|
|
332
333
|
}
|
|
333
334
|
case 'permissions':
|
|
334
335
|
case 'perms': {
|
|
335
|
-
//
|
|
336
|
+
// Wave 7: `/permissions [mode] [--persist] [--confirm]`.
|
|
336
337
|
//
|
|
337
338
|
// Argument grammar (single line, no quoting):
|
|
338
|
-
// /permissions
|
|
339
|
-
// /permissions plan|
|
|
340
|
-
// /permissions
|
|
341
|
-
//
|
|
339
|
+
// /permissions -> show + table
|
|
340
|
+
// /permissions default|acceptEdits|plan|auto|dontAsk -> flip mode
|
|
341
|
+
// /permissions bypassPermissions --confirm -> flip to
|
|
342
|
+
// bypassPermissions (refused
|
|
343
|
+
// без --confirm — safety)
|
|
342
344
|
// /permissions <mode> --persist -> also write to ~/.pugi/config.json
|
|
343
345
|
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
+
// α6 aliases (`ask`, `allow`, `bypass`) are accepted и mapped to
|
|
347
|
+
// their Wave 7 canonical names via `parsePermissionMode`.
|
|
346
348
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
347
349
|
if (tokens.length === 0) {
|
|
348
350
|
return { kind: 'permissions', persist: false, confirmBypass: false };
|
|
349
351
|
}
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
+
const headRaw = tokens[0] ?? '';
|
|
353
|
+
const mode = parsePermissionMode(headRaw);
|
|
354
|
+
if (!mode) {
|
|
355
|
+
const modeList = [...PERMISSION_MODES].join('|');
|
|
352
356
|
return {
|
|
353
357
|
kind: 'error',
|
|
354
|
-
message: `Usage: /permissions [
|
|
358
|
+
message: `Usage: /permissions [${modeList}] [--persist] [--confirm]; unknown mode '${headRaw}'`,
|
|
355
359
|
};
|
|
356
360
|
}
|
|
357
361
|
const flags = tokens.slice(1);
|
|
@@ -371,7 +375,7 @@ export function parseSlashCommand(input) {
|
|
|
371
375
|
};
|
|
372
376
|
}
|
|
373
377
|
}
|
|
374
|
-
return { kind: 'permissions', mode
|
|
378
|
+
return { kind: 'permissions', mode, persist, confirmBypass };
|
|
375
379
|
}
|
|
376
380
|
case 'init': {
|
|
377
381
|
// β1 Sl11: surface the init flow inside the REPL. Tail args
|
package/dist/core/session.js
CHANGED
|
@@ -62,6 +62,54 @@ export async function fireSessionStartMvp(session) {
|
|
|
62
62
|
return 0;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Wave 7 P1 — fire the v2 `SessionStart` event from `~/.pugi/hooks.json`
|
|
67
|
+
* (global) + `<workspaceRoot>/.pugi/hooks.json` (project). Companion to
|
|
68
|
+
* `fireSessionStartMvp`; both surfaces run because they read different
|
|
69
|
+
* config files.
|
|
70
|
+
*
|
|
71
|
+
* Headless by default (no trust prompt) — the v2 trust ledger gates
|
|
72
|
+
* first-run executions. Operators with no prior trust decision will see
|
|
73
|
+
* the SessionStart hook skipped with a `denied by trust ledger` stderr
|
|
74
|
+
* note; running `pugi hooks trust allow <command>` enrolls it.
|
|
75
|
+
*
|
|
76
|
+
* Returns the number of hooks that ran (excluding trust-denied skips).
|
|
77
|
+
* Never throws.
|
|
78
|
+
*/
|
|
79
|
+
export async function fireSessionStartV2(session) {
|
|
80
|
+
try {
|
|
81
|
+
const { fireSessionStart } = await import('./hooks/v2/index.js');
|
|
82
|
+
const outcome = await fireSessionStart({
|
|
83
|
+
sessionId: session.id,
|
|
84
|
+
workspaceRoot: session.root,
|
|
85
|
+
transcriptPath: session.eventsPath,
|
|
86
|
+
permissionMode: 'ask',
|
|
87
|
+
});
|
|
88
|
+
return outcome.results.filter((r) => r.exitCode !== -1).length;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Wave 7 P1 — fire the v2 `SessionEnd` event. Called by the REPL
|
|
96
|
+
* teardown path. Companion to `fireSessionStartV2`.
|
|
97
|
+
*/
|
|
98
|
+
export async function fireSessionEndV2(session) {
|
|
99
|
+
try {
|
|
100
|
+
const { fireSessionEnd } = await import('./hooks/v2/index.js');
|
|
101
|
+
const outcome = await fireSessionEnd({
|
|
102
|
+
sessionId: session.id,
|
|
103
|
+
workspaceRoot: session.root,
|
|
104
|
+
transcriptPath: session.eventsPath,
|
|
105
|
+
permissionMode: 'ask',
|
|
106
|
+
});
|
|
107
|
+
return outcome.results.filter((r) => r.exitCode !== -1).length;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
65
113
|
export function recordCommandStarted(session, command) {
|
|
66
114
|
if (!session.enabled)
|
|
67
115
|
return;
|
package/dist/runtime/cli.js
CHANGED
|
@@ -755,7 +755,7 @@ async function dispatchRewind(args, flags, _session) {
|
|
|
755
755
|
async function dispatchPermissions(args, flags, _session) {
|
|
756
756
|
const head = args[0];
|
|
757
757
|
if (head && parsePermissionMode(head) === null) {
|
|
758
|
-
writeOutput(flags, { error: 'unknown_mode', mode: head }, `Unknown mode '${head}'. Allowed: plan,
|
|
758
|
+
writeOutput(flags, { error: 'unknown_mode', mode: head }, `Unknown mode '${head}'. Allowed: default, acceptEdits, plan, auto, dontAsk, bypassPermissions (α6 aliases ask/allow/bypass accepted).`);
|
|
759
759
|
process.exitCode = 1;
|
|
760
760
|
return;
|
|
761
761
|
}
|
|
@@ -780,8 +780,8 @@ async function dispatchPermissions(args, flags, _session) {
|
|
|
780
780
|
await runPermissionsCommand({
|
|
781
781
|
mode: chosen,
|
|
782
782
|
persist: Boolean(flags.persist),
|
|
783
|
-
// The picker selection IS the confirm gesture for `
|
|
784
|
-
confirmBypass: chosen === '
|
|
783
|
+
// The picker selection IS the confirm gesture for `bypassPermissions`.
|
|
784
|
+
confirmBypass: chosen === 'bypassPermissions' ? true : Boolean(flags.confirm),
|
|
785
785
|
}, {
|
|
786
786
|
workspaceRoot: process.cwd(),
|
|
787
787
|
writeOutput: (text) => writeOutput(flags, { text }, text),
|
|
@@ -1524,7 +1524,7 @@ function parseArgs(argv) {
|
|
|
1524
1524
|
else if (arg === '--mode') {
|
|
1525
1525
|
const next = argv[index + 1];
|
|
1526
1526
|
if (!next || next.startsWith('--')) {
|
|
1527
|
-
throw new Error('--mode requires plan|ask|allow|bypass');
|
|
1527
|
+
throw new Error('--mode requires default|acceptEdits|plan|auto|dontAsk|bypassPermissions (α6 aliases ask|allow|bypass accepted)');
|
|
1528
1528
|
}
|
|
1529
1529
|
flags.mode = next;
|
|
1530
1530
|
index += 1;
|
|
@@ -10,7 +10,9 @@ import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
|
|
|
10
10
|
import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
|
|
11
11
|
import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
|
|
12
12
|
import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
|
|
13
|
+
import { buildOrchestratorTools } from '../../core/mcp/orchestrator-tools.js';
|
|
13
14
|
import { serveHttp } from '../../core/mcp/http-server.js';
|
|
15
|
+
import { resolveActiveCredential, DEFAULT_API_URL } from '../../core/credentials.js';
|
|
14
16
|
import { listMcpPermissions, clearMcpPermission, } from '../../core/mcp/permission.js';
|
|
15
17
|
export async function runMcpCommand(args, ctx) {
|
|
16
18
|
const sub = args[0] ?? 'list';
|
|
@@ -74,6 +76,15 @@ const USAGE_LINES = [
|
|
|
74
76
|
' --allow-write Expose edit/write (default off — explicit opt-in).',
|
|
75
77
|
' --allow-bash Expose the bash tool (default off — explicit opt-in).',
|
|
76
78
|
' --no-bash Deprecated alias (bash is already off by default).',
|
|
79
|
+
' --orchestrator Expose pugi.run / pugi.read / pugi.write /',
|
|
80
|
+
' pugi.dispatch / pugi.publish / pugi.deploy instead of',
|
|
81
|
+
' the engine surface. Designed for external Claude Code',
|
|
82
|
+
' / Cursor sessions driving fix-publish-test loops.',
|
|
83
|
+
' Each tool family is gated by an env switch:',
|
|
84
|
+
' PUGI_MCP_EXEC_ENABLED=1 enables pugi.run',
|
|
85
|
+
' PUGI_MCP_PUBLISH_ENABLED=1 enables pugi.publish',
|
|
86
|
+
' PUGI_MCP_DEPLOY_ENABLED=1 enables pugi.deploy',
|
|
87
|
+
' PUGI_MCP_WORKSPACE_ROOT=... overrides cwd for path validation',
|
|
77
88
|
' perms list Show cached per-(server, tool) decisions',
|
|
78
89
|
' perms reset <server>:<tool> Forget one cached decision',
|
|
79
90
|
];
|
|
@@ -536,16 +547,23 @@ async function runMcpServe(args, ctx) {
|
|
|
536
547
|
const readOnly = flags.readOnly === true;
|
|
537
548
|
const writeAllowed = !readOnly && flags.writeAllowed;
|
|
538
549
|
const bashAllowed = !readOnly && flags.bashAllowed;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
550
|
+
// Wave 7 P1 — when `--orchestrator` is set the surface swaps to the
|
|
551
|
+
// CLI-orchestrator family (pugi.run / pugi.read / pugi.write /
|
|
552
|
+
// pugi.dispatch / pugi.publish / pugi.deploy). The engine surface is
|
|
553
|
+
// intentionally dropped — the two are mutually exclusive on the wire
|
|
554
|
+
// to keep tool-name resolution unambiguous on the consumer side.
|
|
555
|
+
const tools = flags.orchestrator
|
|
556
|
+
? buildOrchestratorTools(buildOrchestratorContext(ctx.workspaceRoot))
|
|
557
|
+
: buildPugiMcpTools(toolCtx, {
|
|
558
|
+
bashAllowed,
|
|
559
|
+
// Keep the legacy contract: `readOnly` for the tool-builder means
|
|
560
|
+
// "do not advertise edit/write tools". Bash advertisement is gated
|
|
561
|
+
// by the independent `bashAllowed` knob. So the builder sees
|
|
562
|
+
// `readOnly = true` whenever the operator did not opt into write
|
|
563
|
+
// explicitly, which preserves the deny-by-default surface for
|
|
564
|
+
// edit/write but no longer accidentally suppresses bash.
|
|
565
|
+
readOnly: readOnly || !writeAllowed,
|
|
566
|
+
});
|
|
549
567
|
// β4 r1 P1 #2 — deny-by-default permissionGate. The MCP cache + FSM
|
|
550
568
|
// are consulted on every dispatch; allow_always-cached entries pass
|
|
551
569
|
// silently, allow_once entries pass and self-clear, deny entries
|
|
@@ -604,6 +622,7 @@ async function runMcpServe(args, ctx) {
|
|
|
604
622
|
command: 'mcp.serve',
|
|
605
623
|
transport: 'http',
|
|
606
624
|
url: handle.url,
|
|
625
|
+
surface: flags.orchestrator ? 'orchestrator' : 'engine',
|
|
607
626
|
bearerTokenSource: handle.bearerTokenAutoGenerated
|
|
608
627
|
? 'auto-generated (see stderr)'
|
|
609
628
|
: explicitToken === envToken
|
|
@@ -649,7 +668,7 @@ async function runMcpServe(args, ctx) {
|
|
|
649
668
|
// the wire; nothing is printed unless the parent agent sends a
|
|
650
669
|
// request that returns a response. Operator sees one info line on
|
|
651
670
|
// stderr so they know the server is up.
|
|
652
|
-
process.stderr.write(`pugi-mcp (stdio): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
|
|
671
|
+
process.stderr.write(`pugi-mcp (stdio, ${flags.orchestrator ? 'orchestrator' : 'engine'}): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
|
|
653
672
|
await serveStdio({
|
|
654
673
|
server,
|
|
655
674
|
stdin: ctx.stdin ?? process.stdin,
|
|
@@ -665,6 +684,7 @@ function parseServeFlags(args) {
|
|
|
665
684
|
readOnly: false,
|
|
666
685
|
writeAllowed: false,
|
|
667
686
|
bashAllowed: false,
|
|
687
|
+
orchestrator: false,
|
|
668
688
|
};
|
|
669
689
|
for (let i = 0; i < args.length; i += 1) {
|
|
670
690
|
const arg = args[i] ?? '';
|
|
@@ -722,6 +742,9 @@ function parseServeFlags(args) {
|
|
|
722
742
|
// so existing operator scripts do not error.
|
|
723
743
|
flags.bashAllowed = false;
|
|
724
744
|
}
|
|
745
|
+
else if (arg === '--orchestrator') {
|
|
746
|
+
flags.orchestrator = true;
|
|
747
|
+
}
|
|
725
748
|
else if (arg === '--help') {
|
|
726
749
|
// Caller renders USAGE_LINES. We surface the same via top-level
|
|
727
750
|
// dispatch — nothing to do here, just don't error.
|
|
@@ -755,9 +778,41 @@ function buildServePermissionGate(opts) {
|
|
|
755
778
|
return false;
|
|
756
779
|
if (tool.permission === 'edit' && !opts.writeAllowed)
|
|
757
780
|
return false;
|
|
781
|
+
// `network` is the permission class used by orchestrator tools
|
|
782
|
+
// (pugi.dispatch / pugi.publish / pugi.deploy). The env capability
|
|
783
|
+
// gates inside each tool's `execute` body provide the per-family
|
|
784
|
+
// kill switch, so the serve-time gate is permissive here. The
|
|
785
|
+
// server's overall `permissionGate` is already deny-most-other —
|
|
786
|
+
// adding a third boolean knob (`networkAllowed`) would create more
|
|
787
|
+
// ways to misconfigure than to protect. Wave 7 P1 (2026-05-28).
|
|
758
788
|
return true;
|
|
759
789
|
};
|
|
760
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* Build the OrchestratorToolContext for `pugi mcp serve --orchestrator`.
|
|
793
|
+
* Reads from process.env + the credentials store. Encapsulated so tests
|
|
794
|
+
* never need to mock the resolveActiveCredential path — they call
|
|
795
|
+
* `buildOrchestratorTools` directly with a hand-rolled context.
|
|
796
|
+
*
|
|
797
|
+
* Wave 7 P1 (2026-05-28).
|
|
798
|
+
*/
|
|
799
|
+
function buildOrchestratorContext(workspaceRoot) {
|
|
800
|
+
const envRoot = process.env.PUGI_MCP_WORKSPACE_ROOT;
|
|
801
|
+
const root = envRoot && envRoot.length > 0 ? resolve(envRoot) : workspaceRoot;
|
|
802
|
+
const credential = resolveActiveCredential();
|
|
803
|
+
return {
|
|
804
|
+
workspaceRoot: root,
|
|
805
|
+
pugiBin: process.env.PUGI_MCP_PUGI_BIN ?? 'pugi',
|
|
806
|
+
apiUrl: credential?.apiUrl ?? DEFAULT_API_URL,
|
|
807
|
+
apiKey: credential?.apiKey ?? null,
|
|
808
|
+
capabilities: {
|
|
809
|
+
exec: process.env.PUGI_MCP_EXEC_ENABLED === '1',
|
|
810
|
+
publish: process.env.PUGI_MCP_PUBLISH_ENABLED === '1',
|
|
811
|
+
deploy: process.env.PUGI_MCP_DEPLOY_ENABLED === '1',
|
|
812
|
+
},
|
|
813
|
+
sshAlias: process.env.PUGI_MCP_SSH_ALIAS ?? 'codeforge',
|
|
814
|
+
};
|
|
815
|
+
}
|
|
761
816
|
function parseHttpBinding(input) {
|
|
762
817
|
// Accept `:7100`, `7100`, or `host:7100`.
|
|
763
818
|
let host = '127.0.0.1';
|
|
@@ -29,9 +29,10 @@ export async function runPermissionsCommand(command, ctx) {
|
|
|
29
29
|
renderModeTable(ctx);
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
-
if (command.mode === '
|
|
33
|
-
ctx.writeOutput('
|
|
34
|
-
ctx.writeOutput('
|
|
32
|
+
if (command.mode === 'bypassPermissions' && !command.confirmBypass) {
|
|
33
|
+
ctx.writeOutput('bypassPermissions disables policy hooks (skill steering, denial tracking) AND skips the deny-list.');
|
|
34
|
+
ctx.writeOutput('Catastrophic patterns (rm -rf /, fork bomb, dd if=/) still trip the circuit-breaker, но that is the only guardrail left.');
|
|
35
|
+
ctx.writeOutput('Run `/permissions bypassPermissions --confirm` to acknowledge before flipping.');
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
37
38
|
setCurrentMode(ctx.workspaceRoot, command.mode);
|
|
@@ -42,8 +43,8 @@ export async function runPermissionsCommand(command, ctx) {
|
|
|
42
43
|
? ' Persisted to ~/.pugi/config.json for future sessions.'
|
|
43
44
|
: '';
|
|
44
45
|
ctx.writeOutput(`Permission mode set to '${command.mode}'.${persistedHint} ${PERMISSION_MODE_GLOSS[command.mode]}`);
|
|
45
|
-
if (command.mode === '
|
|
46
|
-
ctx.writeOutput('
|
|
46
|
+
if (command.mode === 'bypassPermissions') {
|
|
47
|
+
ctx.writeOutput('bypassPermissions — all tools execute without prompts AND policy hooks disabled (circuit-breaker still trips on rm -rf /). Switch back with /permissions dontAsk.');
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
/**
|
|
@@ -68,12 +69,13 @@ function renderCurrentMode(ctx) {
|
|
|
68
69
|
*/
|
|
69
70
|
function renderModeTable(ctx) {
|
|
70
71
|
ctx.writeOutput('');
|
|
71
|
-
ctx.writeOutput('Permission modes:');
|
|
72
|
+
ctx.writeOutput('Permission modes (Shift+Tab cycles in REPL):');
|
|
72
73
|
for (const mode of PERMISSION_MODES) {
|
|
73
|
-
|
|
74
|
+
// Wave 7: longest canonical name is `bypassPermissions` (17 chars).
|
|
75
|
+
ctx.writeOutput(` ${mode.padEnd(18)} ${PERMISSION_MODE_GLOSS[mode]}`);
|
|
74
76
|
}
|
|
75
77
|
ctx.writeOutput('');
|
|
76
|
-
ctx.writeOutput('Switch with `/permissions <mode> [--persist]`.
|
|
78
|
+
ctx.writeOutput('Switch with `/permissions <mode> [--persist]`. bypassPermissions requires `--confirm`.');
|
|
77
79
|
}
|
|
78
80
|
/**
|
|
79
81
|
* Render the one-shot banner shown on session boot when the effective
|
|
@@ -82,7 +84,7 @@ function renderModeTable(ctx) {
|
|
|
82
84
|
* but the caller is responsible for the once-only semantics.
|
|
83
85
|
*/
|
|
84
86
|
export function renderBypassBanner(writeOutput) {
|
|
85
|
-
writeOutput('
|
|
87
|
+
writeOutput('bypassPermissions — all tools execute without prompts AND policy hooks disabled (circuit-breaker still trips on rm -rf /). Switch back with /permissions dontAsk.');
|
|
86
88
|
}
|
|
87
89
|
/**
|
|
88
90
|
* Resolve the effective mode + the layered source label used by the
|