@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +157 -45
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. package/package.json +11 -5
@@ -1,4 +1,57 @@
1
1
  import { basename, resolve } from 'node:path';
2
+ import { classifyBash, listDestructivePatterns, } from './bash-classifier.js';
3
+ /**
4
+ * Map `PermissionAction.kind` to the `HookMatch.permission` taxonomy.
5
+ * The hook taxonomy uses `mcp`/`subagent` slots that the permission
6
+ * engine does not currently emit; `workflow` has no hook-side
7
+ * equivalent so it falls through as undefined (any matching hook with
8
+ * an explicit `permission` filter will not match).
9
+ */
10
+ function toHookPermission(kind) {
11
+ switch (kind) {
12
+ case 'read':
13
+ case 'edit':
14
+ case 'bash':
15
+ case 'network':
16
+ case 'mcp':
17
+ return kind;
18
+ case 'workflow':
19
+ return undefined;
20
+ }
21
+ }
22
+ /**
23
+ * Fire the `PermissionRequest` hook for the given action and decision.
24
+ * Caller is the permission engine's user-facing surface — when the
25
+ * decision is `ask`, hooks can observe (and, with `onFailure: 'block'`,
26
+ * pre-empt) the prompt. The hook system does NOT replace the prompt —
27
+ * a `block` hook simply turns the decision into a refusal that the
28
+ * caller surfaces as a denial.
29
+ *
30
+ * Returns true when no blocking hook objected; false when at least one
31
+ * `onFailure: 'block'` hook returned a non-zero exit. Callers should
32
+ * treat false as "deny this action".
33
+ */
34
+ export async function firePermissionRequestHook(action, decision, hooks, sessionId) {
35
+ hooks.resetBatch();
36
+ const ctx = {
37
+ sessionId,
38
+ event: 'PermissionRequest',
39
+ tool: action.tool,
40
+ permission: toHookPermission(action.kind),
41
+ path: action.kind === 'read' || action.kind === 'edit' ? action.target : undefined,
42
+ payload: { action, decision },
43
+ };
44
+ const matching = hooks.listMatching(ctx);
45
+ const results = await hooks.fire(ctx);
46
+ for (let i = 0; i < matching.length; i += 1) {
47
+ const hook = matching[i];
48
+ const result = results[i];
49
+ if (hook && result && hook.onFailure === 'block' && !result.ok) {
50
+ return false;
51
+ }
52
+ }
53
+ return true;
54
+ }
2
55
  const protectedBasenames = new Set([
3
56
  '.env',
4
57
  '.npmrc',
@@ -9,98 +62,175 @@ const protectedBasenames = new Set([
9
62
  'id_ed25519',
10
63
  ]);
11
64
  const protectedSuffixes = ['.pem', '.key', '.crt', '.p12', '.dump', '.sql'];
12
- // Hard-deny list for `bash` actions. Substring match against the
13
- // normalized (trimmed, original-case) command the model can
14
- // concatenate arguments any way it wants, but if ANY substring on
15
- // this list appears the command is refused before /bin/sh sees it.
16
- //
17
- // Coverage classes:
18
- // - destructive filesystem wipes: rm -rf, dd, mkfs, shred, wipefs
19
- // - destructive system ops: chmod 777, chown -R /, setuid bits
20
- // - dangerous shell tricks: eval, fork bombs, /dev/sda writes
21
- // - git history loss: reset --hard, clean -fdx
22
- // - container / infra wipes: docker system prune, k8s delete --all
23
- // - destructive SQL: DROP DATABASE / DROP TABLE
24
- // - firewall disable: ufw disable, iptables -F
25
- // - credential exfil to stdout: cat ~/.ssh/id_rsa, gpg --export
26
- //
27
- // Code Reviewer P1 2026-05-23 flagged the previous list as missing
28
- // dd/mkfs/chmod 777/eval/fork bomb — added below. List intentionally
29
- // errs toward over-block: a false positive blocks a destructive
30
- // pattern the human can `pugi bash -- run` manually with confirmation,
31
- // while a false negative is catastrophic.
32
- const destructiveBashPatterns = [
33
- // Filesystem wipe
34
- 'rm -rf /',
35
- 'rm -rf ~',
36
- 'rm -rf .',
37
- 'rm -rf *',
38
- 'rm -rf "/',
39
- 'rm -rf "~',
40
- 'dd if=/dev/zero',
41
- 'dd if=/dev/random',
42
- 'dd of=/dev/sda',
43
- 'dd of=/dev/disk',
44
- 'mkfs',
45
- 'shred ',
46
- 'wipefs',
47
- '> /dev/sda',
48
- '> /dev/disk',
49
- // Permission wipe
50
- 'chmod 777 /',
51
- 'chmod -R 777 /',
52
- 'chown -R root /',
53
- // Shell tricks
54
- ':(){ :|:& };:',
55
- 'eval "$',
56
- "eval '$",
57
- // Git history loss
58
- 'git reset --hard',
59
- 'git clean -fdx',
60
- 'git push --force origin main',
61
- 'git push -f origin main',
62
- // Container / infra
63
- 'docker system prune',
64
- 'docker rm -f $(docker',
65
- 'kubectl delete --all',
66
- // SQL destructive. Code Reviewer P2 retro 2026-05-23: the
67
- // substring match was case-sensitive; lowercase `drop database`
68
- // bypassed the gate. We now uppercase-normalise the target
69
- // (`hardDenyReason` below) so both upper- and mixed-case SQL
70
- // hits the same pattern. Patterns themselves stay uppercase
71
- // since they're compared against the uppercased command.
72
- 'DROP DATABASE',
73
- 'DROP TABLE',
74
- 'TRUNCATE TABLE',
75
- // Firewall / network
76
- 'ufw disable',
77
- 'iptables -F',
78
- 'iptables --flush',
79
- // Credential exfil
80
- 'cat ~/.ssh/id_rsa',
81
- 'cat ~/.ssh/id_ed25519',
82
- 'gpg --export-secret',
83
- // SSH config tampering — write paths only. Code Reviewer P2
84
- // retro 2026-05-23: a bare `sshd_config` substring also blocked
85
- // read-only `cat /etc/sshd_config` / `grep PermitRootLogin
86
- // sshd_config`. Scoped to redirections + tee so the model can
87
- // still inspect the file but cannot write to it through bash.
88
- '> sshd_config',
89
- '>> sshd_config',
90
- '> /etc/ssh/sshd_config',
91
- '>> /etc/ssh/sshd_config',
92
- 'tee sshd_config',
93
- 'tee /etc/ssh/sshd_config',
94
- 'tee -a sshd_config',
95
- 'tee -a /etc/ssh/sshd_config',
96
- // History destruction
97
- 'history -c',
98
- ' >/dev/null 2>&1; rm',
99
- ];
65
+ /**
66
+ * Hard-deny list. The list of destructive substrings now lives in
67
+ * `bash-classifier.ts::DESTRUCTIVE_PATTERNS`; this function exposes
68
+ * it as a list for callers (doctor, debug tooling) that need to
69
+ * audit the rule set without re-running `classifyBash`.
70
+ *
71
+ * Code Reviewer P2 retro 2026-05-23: previously this list was
72
+ * duplicated here as `destructiveBashPatterns`. Sprint α5.2 moves it
73
+ * into the classifier so the permission engine and the doctor surface
74
+ * cannot drift.
75
+ */
76
+ export function destructiveBashPatternsList() {
77
+ return listDestructivePatterns();
78
+ }
79
+ /**
80
+ * Class-aware bash permission decision. The matrix:
81
+ *
82
+ * | plan | ask | acceptEdits | auto | dontAsk | bypass
83
+ * read | allow| allow| allow | allow | allow | allow
84
+ * build_test | deny | ask | ask | allow | allow* | allow
85
+ * network | deny | ask | ask | ask | allow* | allow
86
+ * write_workspace | deny | ask | allow | allow | allow* | allow
87
+ * write_protected | deny | ask | ask | ask | deny | ask
88
+ * destructive | deny | deny | deny | deny | deny | deny**
89
+ * unknown | deny | ask | ask | ask | deny | ask
90
+ *
91
+ * * dontAsk allows non-destructive classes when no settings rule
92
+ * contradicts; the bare-mode policy is encoded in the table below.
93
+ * ** destructive can be unlocked ONLY when ALL three hold:
94
+ * - mode === 'bypassPermissions'
95
+ * - PUGI_DESTRUCTIVE_OVERRIDE === '1'
96
+ * - source === 'human'
97
+ * The agent loop never sets `source: 'human'`, so even a runaway
98
+ * agent in bypass mode cannot trigger a destructive deletion.
99
+ */
100
+ export function evaluateBashPermission(cmd, mode, ctx) {
101
+ const classification = classifyBash(cmd, {
102
+ workspaceRoot: ctx.workspaceRoot,
103
+ additionalDirectories: ctx.additionalDirectories,
104
+ });
105
+ return decisionForClass(classification, mode, ctx);
106
+ }
107
+ function decisionForClass(classification, mode, ctx) {
108
+ const { class: klass, reason, matched } = classification;
109
+ const explain = `${reason}${matched ? ` [matched=${matched}]` : ''}`;
110
+ if (klass === 'destructive') {
111
+ const overrideOk = mode === 'bypassPermissions' &&
112
+ ctx.source === 'human' &&
113
+ process.env.PUGI_DESTRUCTIVE_OVERRIDE === '1';
114
+ if (overrideOk) {
115
+ return {
116
+ decision: 'allow',
117
+ reason: `${explain}; destructive override engaged (PUGI_DESTRUCTIVE_OVERRIDE=1, human source, bypassPermissions)`,
118
+ source: 'mode.bypassPermissions.destructive_override',
119
+ };
120
+ }
121
+ return {
122
+ decision: 'deny',
123
+ reason: `${explain}; destructive class is always denied`,
124
+ source: 'classifier.destructive',
125
+ };
126
+ }
127
+ switch (klass) {
128
+ case 'read':
129
+ return { decision: 'allow', reason: explain, source: `classifier.${klass}` };
130
+ case 'build_test':
131
+ return classAwareVerdict(mode, klass, explain, {
132
+ plan: 'deny',
133
+ ask: 'ask',
134
+ acceptEdits: 'ask',
135
+ auto: 'allow',
136
+ dontAsk: 'allow',
137
+ bypassPermissions: 'allow',
138
+ });
139
+ case 'network':
140
+ return classAwareVerdict(mode, klass, explain, {
141
+ plan: 'deny',
142
+ ask: 'ask',
143
+ acceptEdits: 'ask',
144
+ auto: 'ask',
145
+ dontAsk: 'allow',
146
+ bypassPermissions: 'allow',
147
+ });
148
+ case 'write_workspace':
149
+ return classAwareVerdict(mode, klass, explain, {
150
+ plan: 'deny',
151
+ ask: 'ask',
152
+ acceptEdits: 'allow',
153
+ auto: 'allow',
154
+ dontAsk: 'allow',
155
+ bypassPermissions: 'allow',
156
+ });
157
+ case 'write_protected':
158
+ return classAwareVerdict(mode, klass, explain, {
159
+ plan: 'deny',
160
+ ask: 'ask',
161
+ acceptEdits: 'ask',
162
+ auto: 'ask',
163
+ dontAsk: 'deny',
164
+ bypassPermissions: 'ask',
165
+ });
166
+ case 'unknown':
167
+ return classAwareVerdict(mode, klass, explain, {
168
+ plan: 'deny',
169
+ ask: 'ask',
170
+ acceptEdits: 'ask',
171
+ auto: 'ask',
172
+ dontAsk: 'deny',
173
+ bypassPermissions: 'ask',
174
+ });
175
+ }
176
+ }
177
+ function classAwareVerdict(mode, klass, reason, table) {
178
+ const verdict = table[mode];
179
+ const source = `classifier.${klass}.${mode}`;
180
+ switch (verdict) {
181
+ case 'allow':
182
+ return { decision: 'allow', reason, source };
183
+ case 'deny':
184
+ return { decision: 'deny', reason: `${reason}; ${mode} mode denies ${klass}`, source };
185
+ case 'ask':
186
+ return { decision: 'ask', reason, risk: riskForClass(klass) };
187
+ }
188
+ }
189
+ function riskForClass(klass) {
190
+ switch (klass) {
191
+ case 'read':
192
+ return 'low';
193
+ case 'build_test':
194
+ case 'write_workspace':
195
+ return 'medium';
196
+ case 'network':
197
+ case 'write_protected':
198
+ case 'unknown':
199
+ case 'destructive':
200
+ return 'high';
201
+ }
202
+ }
100
203
  export function decidePermission(action, settings, root) {
101
- const hardDeny = hardDenyReason(action);
102
- if (hardDeny) {
103
- return { decision: 'deny', reason: hardDeny, source: 'hard_deny' };
204
+ if (action.kind === 'bash') {
205
+ // Legacy callers (file-tools::bashTool, runtime/cli protected-file
206
+ // probe) still call `decidePermission` for bash. The class-aware
207
+ // engine is preferred; we route through it here so the hard-deny
208
+ // gate stays consistent regardless of entry point.
209
+ //
210
+ // Source defaults to 'agent' because every code path that calls
211
+ // `decidePermission({ kind: 'bash' })` today is reached through
212
+ // the engine loop. Direct human bash invocation goes through the
213
+ // new `evaluateBashPermission` with `source: 'human'`.
214
+ const decision = evaluateBashPermission(action.target, settings.permissions.mode, {
215
+ workspaceRoot: root,
216
+ additionalDirectories: [],
217
+ source: 'agent',
218
+ });
219
+ if (decision.decision === 'deny' && decision.source === 'classifier.destructive') {
220
+ // Locked-source identifier for the retro spec — the existing
221
+ // tests assert `source === 'hard_deny'`. Re-label so the
222
+ // semantic stays the same: a destructive command was blocked
223
+ // by the hard-deny gate.
224
+ return { ...decision, source: 'hard_deny' };
225
+ }
226
+ const signature = `${action.kind}:${action.target}`;
227
+ if (matchesAny(signature, settings.permissions.deny)) {
228
+ return { decision: 'deny', reason: `Denied by rule: ${signature}`, source: 'settings.deny' };
229
+ }
230
+ if (matchesAny(signature, settings.permissions.allow) && decision.decision !== 'deny') {
231
+ return { decision: 'allow', reason: `Allowed by rule: ${signature}`, source: 'settings.allow' };
232
+ }
233
+ return decision;
104
234
  }
105
235
  const protectedReason = protectedTargetReason(action, root);
106
236
  if (protectedReason) {
@@ -134,31 +264,6 @@ export function protectedTargetReason(action, root) {
134
264
  }
135
265
  return null;
136
266
  }
137
- function hardDenyReason(action) {
138
- if (action.kind !== 'bash')
139
- return null;
140
- const normalized = action.target.trim();
141
- // Patterns whose match must be case-insensitive — primarily SQL verbs
142
- // that the model can emit as `drop database`, `Drop Database`, etc.
143
- // Code Reviewer P2 retro 2026-05-23: substring match was previously
144
- // case-sensitive so lowercase SQL silently bypassed the deny list.
145
- // We compare an uppercased copy of the command against the uppercase
146
- // patterns below; non-SQL patterns stay exact-match against the
147
- // original-case target to avoid over-matching benign filenames.
148
- const normalizedUpper = normalized.toUpperCase();
149
- const caseInsensitivePatterns = new Set([
150
- 'DROP DATABASE',
151
- 'DROP TABLE',
152
- 'TRUNCATE TABLE',
153
- ]);
154
- const matched = destructiveBashPatterns.find((pattern) => {
155
- if (caseInsensitivePatterns.has(pattern)) {
156
- return normalizedUpper.includes(pattern);
157
- }
158
- return normalized.includes(pattern);
159
- });
160
- return matched ? `Destructive command blocked: ${matched}` : null;
161
- }
162
267
  function decisionForMode(mode, reason, source, risk) {
163
268
  switch (mode) {
164
269
  case 'plan':
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Client-side concurrent-subagent cap - Sprint α5.7 (ADR-0056
3
+ * acceptance #6, Mac safety memo
4
+ * `feedback_max_3_parallel_agents_mac_safety.md`).
5
+ *
6
+ * Mac-safety memo caps interactive concurrency at 3 active subagents
7
+ * per workstation. The REPL has its own client-side gate that mirrors
8
+ * the global rule for one workstation worth of dispatches:
9
+ *
10
+ * - active count >= 3 → soft warning (operator may still dispatch).
11
+ * - active count >= 5 → hard block unless the operator opted in via
12
+ * PUGI_FORCE_PARALLEL=1.
13
+ *
14
+ * The gate is pure: callers pass the current active count and we return
15
+ * a verdict. The REPL session module reads the dispatcher event stream,
16
+ * tracks active dispatches, and consults this function before
17
+ * forwarding `/brief` to the controller.
18
+ *
19
+ * The hard cap matches the absolute ceiling cited in the Mac memo (3
20
+ * brand-recommended, 5 ABSOLUTE max with the env-flag escape hatch).
21
+ * Bumping either threshold is a memo update, not a code-only change.
22
+ */
23
+ /**
24
+ * Soft warning threshold. At this many active subagents the REPL
25
+ * surfaces a non-blocking nudge. The operator can confirm and proceed.
26
+ */
27
+ export const CAP_WARNING_THRESHOLD = 3;
28
+ /**
29
+ * Hard block threshold. At this many active subagents the REPL refuses
30
+ * the new dispatch unless `PUGI_FORCE_PARALLEL=1` is in the environment.
31
+ */
32
+ export const CAP_HARD_BLOCK_THRESHOLD = 5;
33
+ /**
34
+ * Env flag operators set to override the hard cap. Documented in
35
+ * `pugi doctor` output and in the Mac safety memo. Set this to `1`,
36
+ * `true`, or `yes` to bypass. Anything else (including unset) leaves
37
+ * the cap enforced.
38
+ */
39
+ export const FORCE_PARALLEL_ENV_VAR = 'PUGI_FORCE_PARALLEL';
40
+ /**
41
+ * Compute the cap verdict given the current active subagent count and
42
+ * the operator's environment.
43
+ *
44
+ * The function is pure (no global state, no IO). Callers must pass the
45
+ * env they want considered - production callers pass process.env, tests
46
+ * pass a fixture so the gate is deterministic.
47
+ */
48
+ export function evaluateCap(input) {
49
+ const active = Math.max(0, Math.floor(input.active));
50
+ const env = input.env ?? {};
51
+ const forceParallel = isForceParallelSet(env);
52
+ if (active >= CAP_HARD_BLOCK_THRESHOLD) {
53
+ if (forceParallel) {
54
+ return { kind: 'override', active, envVar: FORCE_PARALLEL_ENV_VAR };
55
+ }
56
+ return { kind: 'block', active, envVar: FORCE_PARALLEL_ENV_VAR };
57
+ }
58
+ if (active >= CAP_WARNING_THRESHOLD) {
59
+ return { kind: 'warn', active };
60
+ }
61
+ return { kind: 'allow' };
62
+ }
63
+ /**
64
+ * Render a one-line operator nudge for a non-allow verdict. The REPL
65
+ * appends this line to the conversation pane on its own row so the
66
+ * cyan colour token survives Ink's whitespace collapsing.
67
+ *
68
+ * Brand voice: power words `on watch`, `workforce`. No em dash, no
69
+ * emoji. Capacity rather than `agents` to avoid colliding with the
70
+ * `/agents` command name in the same frame.
71
+ */
72
+ export function describeVerdict(verdict) {
73
+ switch (verdict.kind) {
74
+ case 'allow':
75
+ return '';
76
+ case 'warn':
77
+ return `Capacity nudge: ${verdict.active} agents already on watch - keep an eye on workstation load.`;
78
+ case 'block':
79
+ return `Capacity block: ${verdict.active} agents on watch is the absolute ceiling. Set ${verdict.envVar}=1 to override.`;
80
+ case 'override':
81
+ return `Capacity cap bypassed (${verdict.envVar}=1) - ${verdict.active} agents already on watch.`;
82
+ }
83
+ }
84
+ function isForceParallelSet(env) {
85
+ const raw = env[FORCE_PARALLEL_ENV_VAR];
86
+ if (typeof raw !== 'string')
87
+ return false;
88
+ const normalized = raw.trim().toLowerCase();
89
+ return normalized === '1' || normalized === 'true' || normalized === 'yes';
90
+ }
91
+ //# sourceMappingURL=cap-warning.js.map