@pugi/cli 0.1.0-beta.17 → 0.1.0-beta.19
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/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/native-pugi.js +20 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +251 -49
- package/dist/core/file-cache.js +113 -1
- package/dist/core/mcp/client.js +66 -6
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/session.js +261 -9
- package/dist/core/repl/slash-commands.js +67 -4
- package/dist/runtime/cli.js +153 -58
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +18 -4
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/file-tools.js +57 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/status-table.js +7 -0
- package/package.json +2 -2
package/dist/core/file-cache.js
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session file-read cache + stale-read gate.
|
|
3
|
+
*
|
|
4
|
+
* Leak intel L1 (openclaude `FileEditTool.ts`, 2026-05-27 gap analysis
|
|
5
|
+
* §5.1): every FileEdit must validate the operator's last-known view of
|
|
6
|
+
* the file before mutating disk. The gate compares BOTH `mtimeMs` and
|
|
7
|
+
* `sha256(content)` of the file on disk against the record captured at
|
|
8
|
+
* read time:
|
|
9
|
+
*
|
|
10
|
+
* - mtimeMs is a cheap fast-path. If the inode mtime hasn't moved
|
|
11
|
+
* since the read, the content hash cannot have changed (barring a
|
|
12
|
+
* filesystem with hash-on-mtime-skew bugs) and we can short-circuit.
|
|
13
|
+
* - sha256 is the authoritative gate. A user editor that writes back
|
|
14
|
+
* identical content can leave mtime untouched on some filesystems
|
|
15
|
+
* (atomic-rename with preserved metadata), and conversely `touch`
|
|
16
|
+
* bumps mtime without changing content. Hash is the truth.
|
|
17
|
+
*
|
|
18
|
+
* Both signals must agree for the gate to PASS. Any divergence => STALE
|
|
19
|
+
* => refuse the edit, force the model to re-read.
|
|
20
|
+
*
|
|
21
|
+
* Cache lifetime: per-session. `FileReadCache.clear()` is called at
|
|
22
|
+
* session.end (see `core/session.ts`). The cache is intentionally NOT
|
|
23
|
+
* durable across sessions — a re-read after restart is cheap and stale
|
|
24
|
+
* cross-session entries would themselves be a soundness hazard.
|
|
25
|
+
*
|
|
26
|
+
* Exception: writeTool for create-new (path doesn't exist on disk) does
|
|
27
|
+
* not consult the cache. Creating a brand new file has no "last-known
|
|
28
|
+
* view" to invalidate.
|
|
29
|
+
*/
|
|
1
30
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { statSync } from 'node:fs';
|
|
31
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
32
|
import { resolve } from 'node:path';
|
|
33
|
+
export class StaleReadError extends Error {
|
|
34
|
+
reason;
|
|
35
|
+
path;
|
|
36
|
+
constructor(path, reason, detail) {
|
|
37
|
+
super(`stale_read: ${path} — ${detail}. Re-read the file before editing.`);
|
|
38
|
+
this.name = 'StaleReadError';
|
|
39
|
+
this.reason = reason;
|
|
40
|
+
this.path = path;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
4
43
|
export class FileReadCache {
|
|
5
44
|
records = new Map();
|
|
6
45
|
set(record) {
|
|
@@ -9,6 +48,70 @@ export class FileReadCache {
|
|
|
9
48
|
get(root, path) {
|
|
10
49
|
return this.records.get(resolve(root, path));
|
|
11
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate a candidate edit against the cached read record. Returns
|
|
53
|
+
* a tagged-union: `{ stale: false }` when the edit may proceed, or
|
|
54
|
+
* `{ stale: true, reason, detail }` when the gate must refuse.
|
|
55
|
+
*
|
|
56
|
+
* Pure function over the cache + supplied `currentMtimeMs` /
|
|
57
|
+
* `currentContent` — does NOT touch disk. Callers (editTool /
|
|
58
|
+
* writeTool) do their own `statSync` + `readFileSync` because they
|
|
59
|
+
* also need the content for the diff/edit itself.
|
|
60
|
+
*
|
|
61
|
+
* @param root workspace root (used to resolve relative path)
|
|
62
|
+
* @param path workspace-relative file path
|
|
63
|
+
* @param currentMtimeMs `fs.statSync().mtimeMs` of the on-disk file
|
|
64
|
+
* @param currentContent UTF-8 contents of the on-disk file
|
|
65
|
+
*/
|
|
66
|
+
validate(root, path, currentMtimeMs, currentContent) {
|
|
67
|
+
const record = this.get(root, path);
|
|
68
|
+
if (!record) {
|
|
69
|
+
return {
|
|
70
|
+
stale: true,
|
|
71
|
+
reason: 'no_prior_read',
|
|
72
|
+
detail: 'file must be read first',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Fast-path: mtime hasn't moved. Hash check is redundant in the
|
|
76
|
+
// common case but cheap, so we still verify below. Skipping hash
|
|
77
|
+
// when mtime matches would allow a subtle bug class (in-place
|
|
78
|
+
// writers that preserve mtime) to slip through.
|
|
79
|
+
if (currentMtimeMs > record.mtimeMs) {
|
|
80
|
+
// mtime advanced — confirm with hash before flagging. A bump
|
|
81
|
+
// without a content change (e.g. `touch`) shouldn't fire stale.
|
|
82
|
+
const currentHash = hashContent(currentContent);
|
|
83
|
+
if (currentHash !== record.sha256) {
|
|
84
|
+
return {
|
|
85
|
+
stale: true,
|
|
86
|
+
reason: 'mtime_drift',
|
|
87
|
+
detail: `mtime advanced (${record.mtimeMs} → ${currentMtimeMs}) and content hash diverged`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// mtime bumped but content identical — treat as fresh. The cache
|
|
91
|
+
// entry's mtime is intentionally NOT refreshed here; the next
|
|
92
|
+
// edit will hit the same path and the gate will keep agreeing.
|
|
93
|
+
return { stale: false };
|
|
94
|
+
}
|
|
95
|
+
// mtime hasn't moved — hash MUST still match the record. A
|
|
96
|
+
// mismatch is a filesystem-level inconsistency or an in-place
|
|
97
|
+
// editor that preserves mtime; either way, refuse.
|
|
98
|
+
const currentHash = hashContent(currentContent);
|
|
99
|
+
if (currentHash !== record.sha256) {
|
|
100
|
+
return {
|
|
101
|
+
stale: true,
|
|
102
|
+
reason: 'hash_drift',
|
|
103
|
+
detail: 'content hash diverged from last read (mtime unchanged)',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { stale: false };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Drop every cached record. Called by session.end so a fresh REPL
|
|
110
|
+
* session never inherits stale cross-session entries.
|
|
111
|
+
*/
|
|
112
|
+
clear() {
|
|
113
|
+
this.records.clear();
|
|
114
|
+
}
|
|
12
115
|
}
|
|
13
116
|
export function hashContent(content) {
|
|
14
117
|
return createHash('sha256').update(content).digest('hex');
|
|
@@ -26,4 +129,13 @@ export function createReadRecord(root, path, content, source) {
|
|
|
26
129
|
source,
|
|
27
130
|
};
|
|
28
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Convenience helper: does this absolute path exist on disk? Wraps the
|
|
134
|
+
* existsSync import so file-tools.ts can decide between create-new
|
|
135
|
+
* (skip stale gate) and update-existing (apply stale gate) without
|
|
136
|
+
* pulling in another fs import.
|
|
137
|
+
*/
|
|
138
|
+
export function pathExists(absolutePath) {
|
|
139
|
+
return existsSync(absolutePath);
|
|
140
|
+
}
|
|
29
141
|
//# sourceMappingURL=file-cache.js.map
|
package/dist/core/mcp/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
/**
|
|
4
5
|
* Minimal JSON-RPC 2.0 over stdio MCP client for Pugi CLI M1.
|
|
@@ -79,6 +80,22 @@ export async function connect(serverName, config, options = {}) {
|
|
|
79
80
|
env: { ...process.env, ...config.env },
|
|
80
81
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
81
82
|
});
|
|
83
|
+
// L13: optional stderr → log file. We open the stream lazily so a bad
|
|
84
|
+
// path surfaces synchronously to the caller instead of getting buried
|
|
85
|
+
// in the child's stderr handler. `mkdir -p` of the parent dir is the
|
|
86
|
+
// caller's responsibility (the registry does it once per session).
|
|
87
|
+
let logStream;
|
|
88
|
+
if (options.logFile) {
|
|
89
|
+
try {
|
|
90
|
+
logStream = createWriteStream(options.logFile, { flags: 'a' });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Log directory missing or unwritable — degrade silently rather
|
|
94
|
+
// than refuse the handshake. The operator still gets the same
|
|
95
|
+
// surface they had pre-L13 (stderr dropped on the floor).
|
|
96
|
+
logStream = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
82
99
|
const connection = {
|
|
83
100
|
serverName,
|
|
84
101
|
child,
|
|
@@ -86,6 +103,8 @@ export async function connect(serverName, config, options = {}) {
|
|
|
86
103
|
pending: new Map(),
|
|
87
104
|
nextId: 1,
|
|
88
105
|
closed: false,
|
|
106
|
+
...(logStream ? { logStream } : {}),
|
|
107
|
+
startedAt: Date.now(),
|
|
89
108
|
};
|
|
90
109
|
// Attach the spawn-error handler BEFORE the reader: ENOENT and similar
|
|
91
110
|
// are emitted asynchronously on the next tick, and without this
|
|
@@ -193,11 +212,25 @@ export async function callTool(connection, name, args, options = {}) {
|
|
|
193
212
|
* SIGKILL if the child has not exited. Safe to call multiple times.
|
|
194
213
|
*/
|
|
195
214
|
export async function disconnect(connection) {
|
|
196
|
-
|
|
215
|
+
const closeLogStream = () => {
|
|
216
|
+
const stream = connection.logStream;
|
|
217
|
+
if (!stream)
|
|
218
|
+
return;
|
|
219
|
+
try {
|
|
220
|
+
stream.end();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Best-effort — stream may already be destroyed.
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
if (connection.closed) {
|
|
227
|
+
closeLogStream();
|
|
197
228
|
return;
|
|
229
|
+
}
|
|
198
230
|
const { child } = connection;
|
|
199
231
|
if (child.exitCode !== null || child.killed) {
|
|
200
232
|
connection.closed = true;
|
|
233
|
+
closeLogStream();
|
|
201
234
|
return;
|
|
202
235
|
}
|
|
203
236
|
return new Promise((resolveDone) => {
|
|
@@ -207,6 +240,7 @@ export async function disconnect(connection) {
|
|
|
207
240
|
return;
|
|
208
241
|
settled = true;
|
|
209
242
|
connection.closed = true;
|
|
243
|
+
closeLogStream();
|
|
210
244
|
resolveDone();
|
|
211
245
|
};
|
|
212
246
|
child.once('exit', settle);
|
|
@@ -232,6 +266,22 @@ export async function disconnect(connection) {
|
|
|
232
266
|
}, SHUTDOWN_GRACE_MS);
|
|
233
267
|
});
|
|
234
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* L13: cheap liveness probe for `pugi mcp doctor`. True when the child
|
|
271
|
+
* process is still running AND the connection has not been torn down.
|
|
272
|
+
* Does NOT issue a JSON-RPC call — that would race with in-flight tool
|
|
273
|
+
* dispatch and the doctor surface is read-only.
|
|
274
|
+
*/
|
|
275
|
+
export function isAlive(connection) {
|
|
276
|
+
if (connection.closed)
|
|
277
|
+
return false;
|
|
278
|
+
const { child } = connection;
|
|
279
|
+
if (child.killed)
|
|
280
|
+
return false;
|
|
281
|
+
if (child.exitCode !== null)
|
|
282
|
+
return false;
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
235
285
|
function writeFrame(connection, message) {
|
|
236
286
|
if (connection.closed) {
|
|
237
287
|
throw new Error(`mcp server "${connection.serverName}" connection is closed`);
|
|
@@ -283,11 +333,21 @@ function attachReader(connection) {
|
|
|
283
333
|
newlineIndex = buffer.indexOf('\n');
|
|
284
334
|
}
|
|
285
335
|
});
|
|
286
|
-
// stderr is captured
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
connection.child.stderr.on('data', () => {
|
|
336
|
+
// stderr is captured and (when `connect({ logFile })` was set) mirrored
|
|
337
|
+
// to a per-server log file under `.pugi/logs/`. Operators can tail the
|
|
338
|
+
// file via `pugi mcp logs <server>`. Default sink remains "drop on the
|
|
339
|
+
// floor" so the CLI stdout stays pure JSON envelope output.
|
|
340
|
+
connection.child.stderr.on('data', (chunk) => {
|
|
341
|
+
const stream = connection.logStream;
|
|
342
|
+
if (!stream || stream.destroyed)
|
|
343
|
+
return;
|
|
344
|
+
try {
|
|
345
|
+
stream.write(typeof chunk === 'string' ? chunk : chunk);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// Disk full / fd revoked — drop silently. Mirroring is best-effort.
|
|
349
|
+
}
|
|
350
|
+
});
|
|
291
351
|
}
|
|
292
352
|
function handleLine(connection, line) {
|
|
293
353
|
let parsed;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
@@ -38,6 +38,13 @@ import { getMcpTrust } from './trust.js';
|
|
|
38
38
|
const mcpFileSchema = z.object({
|
|
39
39
|
servers: z.record(mcpServerConfigSchema).default({}),
|
|
40
40
|
});
|
|
41
|
+
/**
|
|
42
|
+
* L13: workspace-relative path for per-server log files. Surfaces in
|
|
43
|
+
* `pugi mcp logs <name>` and is mkdir -p'd before the first connect.
|
|
44
|
+
*/
|
|
45
|
+
export function mcpLogPath(workspaceRoot, serverName) {
|
|
46
|
+
return resolve(workspaceRoot, '.pugi/logs', `mcp-${serverName}.log`);
|
|
47
|
+
}
|
|
41
48
|
/**
|
|
42
49
|
* Load and (optionally) connect every approved MCP server defined in the
|
|
43
50
|
* workspace + user configs. Pending and denied servers stay in the
|
|
@@ -45,6 +52,7 @@ const mcpFileSchema = z.object({
|
|
|
45
52
|
*/
|
|
46
53
|
export async function loadMcpRegistry(workspaceRoot, options = {}) {
|
|
47
54
|
const shouldConnect = options.connect !== false;
|
|
55
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5_000;
|
|
48
56
|
const userConfig = readMcpFile(resolve(userHomeDir(), 'mcp.json'));
|
|
49
57
|
const workspaceConfig = readMcpFile(resolve(workspaceRoot, '.pugi/mcp.json'));
|
|
50
58
|
const merged = new Map();
|
|
@@ -52,6 +60,17 @@ export async function loadMcpRegistry(workspaceRoot, options = {}) {
|
|
|
52
60
|
merged.set(name, config);
|
|
53
61
|
for (const [name, config] of Object.entries(workspaceConfig))
|
|
54
62
|
merged.set(name, config);
|
|
63
|
+
// L13: ensure the log dir exists once per session so per-server log
|
|
64
|
+
// streams can `append` without each one having to mkdir -p.
|
|
65
|
+
if (shouldConnect && merged.size > 0) {
|
|
66
|
+
try {
|
|
67
|
+
mkdirSync(resolve(workspaceRoot, '.pugi/logs'), { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Workspace may be read-only (CI sandbox). Log routing degrades
|
|
71
|
+
// silently in that case — see `client.ts::connect`.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
55
74
|
const servers = new Map();
|
|
56
75
|
for (const [name, config] of merged) {
|
|
57
76
|
const ledgerTrust = await getMcpTrust(name);
|
|
@@ -70,7 +89,10 @@ export async function loadMcpRegistry(workspaceRoot, options = {}) {
|
|
|
70
89
|
};
|
|
71
90
|
if (shouldConnect && trust === 'trusted') {
|
|
72
91
|
try {
|
|
73
|
-
const connection = await connect(name, config
|
|
92
|
+
const connection = await connect(name, config, {
|
|
93
|
+
timeoutMs: handshakeTimeoutMs,
|
|
94
|
+
logFile: mcpLogPath(workspaceRoot, name),
|
|
95
|
+
});
|
|
74
96
|
state.connection = connection;
|
|
75
97
|
state.surfacedTools = await listTools(connection);
|
|
76
98
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission gate — Leak L6 canonical 4-mode enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Single dispatch entry point. Every tool call goes through `gate()`
|
|
5
|
+
* before the executor runs the tool body; the executor surfaces the
|
|
6
|
+
* `PermissionDenied` error as a model-readable sentinel so the model
|
|
7
|
+
* can either reformulate the request or wait for the operator to
|
|
8
|
+
* change the mode.
|
|
9
|
+
*
|
|
10
|
+
* Routing matrix (mode × class):
|
|
11
|
+
*
|
|
12
|
+
* | read | write | dispatch
|
|
13
|
+
* plan | allow | deny | deny
|
|
14
|
+
* ask | ask | ask | ask
|
|
15
|
+
* allow | allow | allow | allow
|
|
16
|
+
* bypass | allow | allow | allow (plus: hooks bypassed)
|
|
17
|
+
*
|
|
18
|
+
* In ask mode the gate consults a session-scoped `always-allow` cache
|
|
19
|
+
* keyed by tool name (set when the operator picks "always-allow-tool"
|
|
20
|
+
* in the prompt). The cache is in-memory only — restarting the session
|
|
21
|
+
* resets it, by design (every-session-fresh ask consent).
|
|
22
|
+
*
|
|
23
|
+
* Bypass mode does NOT take a different code path in this module — the
|
|
24
|
+
* `hooksBypassed` flag in the decision payload signals the executor /
|
|
25
|
+
* hook layer to skip policy hooks. The classification logic is the
|
|
26
|
+
* same as `allow` because the gate doesn't own hook execution; the
|
|
27
|
+
* caller decides what to do with the bypass signal.
|
|
28
|
+
*/
|
|
29
|
+
import { getToolClass } from './tool-class.js';
|
|
30
|
+
export const ASK_OPTIONS = Object.freeze([
|
|
31
|
+
'allow-once',
|
|
32
|
+
'always-this-tool',
|
|
33
|
+
'deny-once',
|
|
34
|
+
'always-deny-this-tool',
|
|
35
|
+
]);
|
|
36
|
+
export function createAskAlwaysCache() {
|
|
37
|
+
return {
|
|
38
|
+
alwaysAllowed: new Set(),
|
|
39
|
+
alwaysDenied: new Set(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Apply the operator's answer to an `ask` decision. Caller invokes this
|
|
44
|
+
* after the operator picks an option so the cache stays in sync.
|
|
45
|
+
* Returns the effective decision: `allow-once` / `always-this-tool`
|
|
46
|
+
* become `allow`; `deny-once` / `always-deny-this-tool` become `deny`.
|
|
47
|
+
*
|
|
48
|
+
* `always-*` answers persist to the cache and short-circuit the next
|
|
49
|
+
* gate call for the same tool name within the same session.
|
|
50
|
+
*/
|
|
51
|
+
export function applyAskAnswer(cache, toolName, answer) {
|
|
52
|
+
switch (answer) {
|
|
53
|
+
case 'allow-once':
|
|
54
|
+
return { decision: 'allow', reason: `Allowed once for ${toolName}` };
|
|
55
|
+
case 'always-this-tool':
|
|
56
|
+
cache.alwaysAllowed.add(toolName);
|
|
57
|
+
cache.alwaysDenied.delete(toolName);
|
|
58
|
+
return { decision: 'allow', reason: `Allowed for ${toolName} this session` };
|
|
59
|
+
case 'deny-once':
|
|
60
|
+
return { decision: 'deny', reason: `Denied once for ${toolName}` };
|
|
61
|
+
case 'always-deny-this-tool':
|
|
62
|
+
cache.alwaysDenied.add(toolName);
|
|
63
|
+
cache.alwaysAllowed.delete(toolName);
|
|
64
|
+
return { decision: 'deny', reason: `Denied for ${toolName} this session` };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Permission-denied sentinel. Distinguishable from other tool errors
|
|
69
|
+
* (parse errors, IO failures) so the caller can route the message back
|
|
70
|
+
* to the model with the canonical recovery hint.
|
|
71
|
+
*/
|
|
72
|
+
export class PermissionDenied extends Error {
|
|
73
|
+
name = 'PermissionDenied';
|
|
74
|
+
mode;
|
|
75
|
+
toolName;
|
|
76
|
+
toolClass;
|
|
77
|
+
/**
|
|
78
|
+
* Human-friendly reason surfaced in logs / hook payloads. Distinct
|
|
79
|
+
* from `message` so the spec layer can pattern-match the canonical
|
|
80
|
+
* `PERMISSION_DENIED:` sentinel verbatim while operators see the
|
|
81
|
+
* full explanation in console output.
|
|
82
|
+
*/
|
|
83
|
+
reason;
|
|
84
|
+
constructor(toolName, toolClass, mode, reason) {
|
|
85
|
+
// The base Error.message is the canonical sentinel so default
|
|
86
|
+
// toString() / re-throw paths preserve the format the model and
|
|
87
|
+
// the spec layer pattern-match against.
|
|
88
|
+
super(`PERMISSION_DENIED: ${toolName} blocked in ${mode} mode. Operator can switch with /permissions <mode>.`);
|
|
89
|
+
this.mode = mode;
|
|
90
|
+
this.toolName = toolName;
|
|
91
|
+
this.toolClass = toolClass;
|
|
92
|
+
this.reason = reason;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Render the sentinel message the executor surfaces to the model.
|
|
96
|
+
* The string format is stable so a parent agent / E2E spec can
|
|
97
|
+
* pattern-match `PERMISSION_DENIED: <tool> blocked in <mode> mode.`
|
|
98
|
+
* verbatim. Equivalent to `this.message`; kept as a method so
|
|
99
|
+
* downstream callers can use whichever spelling reads better at the
|
|
100
|
+
* site.
|
|
101
|
+
*/
|
|
102
|
+
toModelMessage() {
|
|
103
|
+
return this.message;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Core dispatch gate. Pure function — no IO, no side effects beyond
|
|
108
|
+
* mutating the caller-supplied `alwaysCache`. Safe to call from any
|
|
109
|
+
* layer (engine adapter, agent-as-tool bridge, doctor command).
|
|
110
|
+
*
|
|
111
|
+
* Argument bag mirrors the executor entry shape:
|
|
112
|
+
* - `toolName` is the registered tool key (e.g. `read`, `write`,
|
|
113
|
+
* `mcp__github__list_issues`).
|
|
114
|
+
* - `args` is the raw arg payload. Currently unused in the routing
|
|
115
|
+
* decision — the matrix only cares about class. Plumbed in
|
|
116
|
+
* because future "always-allow-this-pattern" rules (e.g.
|
|
117
|
+
* `git status` auto-allow) will consume it without changing the
|
|
118
|
+
* callsite contract.
|
|
119
|
+
* - `ctx` carries mode + session-scoped state.
|
|
120
|
+
*/
|
|
121
|
+
export function gate(toolName,
|
|
122
|
+
// Reserved for future pattern-based rules (always-allow `git status`).
|
|
123
|
+
// Suppress unused-argument lint — the contract is stable on purpose.
|
|
124
|
+
_args, ctx) {
|
|
125
|
+
const toolClass = getToolClass(toolName);
|
|
126
|
+
const cache = ctx.alwaysCache;
|
|
127
|
+
// Ask-mode session memory: an explicit "always-deny" beats any other
|
|
128
|
+
// routing because the operator has actively refused this tool.
|
|
129
|
+
if (cache?.alwaysDenied.has(toolName)) {
|
|
130
|
+
return {
|
|
131
|
+
decision: 'deny',
|
|
132
|
+
reason: `Tool ${toolName} denied for the session via /permissions ask`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// "Always-allow" in ask mode skips the prompt for subsequent calls.
|
|
136
|
+
// Plan mode IGNORES the always-allow cache because plan mode's
|
|
137
|
+
// contract is structural (read-only), not consent-based.
|
|
138
|
+
if (cache?.alwaysAllowed.has(toolName) && ctx.permissionMode === 'ask') {
|
|
139
|
+
return {
|
|
140
|
+
decision: 'allow',
|
|
141
|
+
reason: `Tool ${toolName} always-allowed for this session`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
switch (ctx.permissionMode) {
|
|
145
|
+
case 'plan': {
|
|
146
|
+
if (toolClass === 'read') {
|
|
147
|
+
return { decision: 'allow', reason: `Plan mode: read tools allowed (${toolName})` };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
decision: 'deny',
|
|
151
|
+
reason: `Plan mode: ${toolClass} tools blocked. Switch with /permissions allow.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
case 'ask': {
|
|
155
|
+
return {
|
|
156
|
+
decision: 'ask',
|
|
157
|
+
reason: `Ask mode: prompt before ${toolName}`,
|
|
158
|
+
question: buildAskQuestion(toolName, toolClass, ctx.target),
|
|
159
|
+
options: ASK_OPTIONS,
|
|
160
|
+
toolClass,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
case 'allow': {
|
|
164
|
+
return {
|
|
165
|
+
decision: 'allow',
|
|
166
|
+
reason: `Allow mode: ${toolName} executed`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
case 'bypass': {
|
|
170
|
+
return {
|
|
171
|
+
decision: 'allow',
|
|
172
|
+
reason: `Bypass mode: ${toolName} executed (policy hooks skipped)`,
|
|
173
|
+
hooksBypassed: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Build the operator-facing question string for an ask-mode prompt.
|
|
180
|
+
* Kept in one place so the wording stays consistent across the REPL
|
|
181
|
+
* Ink modal and the simpler stdin fallback.
|
|
182
|
+
*/
|
|
183
|
+
function buildAskQuestion(toolName, toolClass, target) {
|
|
184
|
+
const suffix = target ? ` on ${target}` : '';
|
|
185
|
+
return `Allow ${toolName} (${toolClass})${suffix}?`;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=gate.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission gate (Leak L6) public surface.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the canonical 4-mode types, the tool-class classifier,
|
|
5
|
+
* the dispatch gate, and the workspace + global session-state helpers
|
|
6
|
+
* so callers import from one place:
|
|
7
|
+
*
|
|
8
|
+
* import { gate, resolveMode, PermissionDenied } from '<...>/permissions/index.js';
|
|
9
|
+
*
|
|
10
|
+
* Keeps the internal file split (mode / tool-class / gate / state)
|
|
11
|
+
* invisible to consumers — those files are an implementation detail
|
|
12
|
+
* the engine adapter does not need to know about.
|
|
13
|
+
*/
|
|
14
|
+
export { DEFAULT_PERMISSION_MODE, PERMISSION_MODE_GLOSS, PERMISSION_MODES, isPermissionMode, parsePermissionMode, toLegacyMode, } from './mode.js';
|
|
15
|
+
export { getToolClass, listBuiltInToolClasses, } from './tool-class.js';
|
|
16
|
+
export { ASK_OPTIONS, PermissionDenied, applyAskAnswer, createAskAlwaysCache, gate, } from './gate.js';
|
|
17
|
+
export { getCurrentMode, getGlobalDefaultMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, } from './state.js';
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission modes — canonical 4-mode taxonomy (Leak L6).
|
|
3
|
+
*
|
|
4
|
+
* Pugi historically shipped a 6-mode taxonomy in `@pugi/sdk`
|
|
5
|
+
* (`plan | ask | acceptEdits | auto | dontAsk | bypassPermissions`)
|
|
6
|
+
* which the legacy `core/permission.ts` engine maps tools onto. Claude
|
|
7
|
+
* Code, Codex, and the openclaude / openwork leaks all converge on a
|
|
8
|
+
* smaller, sharper 4-mode set:
|
|
9
|
+
*
|
|
10
|
+
* - `plan` — read-only proposal mode. Write/dispatch tools refused
|
|
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.
|
|
22
|
+
*
|
|
23
|
+
* This module owns the union type, the canonical default, and the
|
|
24
|
+
* mode-resolution helper. The runtime gate (`gate.ts`) consumes it; the
|
|
25
|
+
* legacy 6-mode SDK enum remains the system-of-record for bash-class
|
|
26
|
+
* decisions inside `core/permission.ts` — the canonical 4-mode layer
|
|
27
|
+
* sits in front and short-circuits the dispatch decision before bash
|
|
28
|
+
* classification ever runs.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Closed list — useful for input validation and slash-command help.
|
|
32
|
+
*/
|
|
33
|
+
export const PERMISSION_MODES = Object.freeze([
|
|
34
|
+
'plan',
|
|
35
|
+
'ask',
|
|
36
|
+
'allow',
|
|
37
|
+
'bypass',
|
|
38
|
+
]);
|
|
39
|
+
/**
|
|
40
|
+
* Default mode applied when no `--mode` flag, no per-workspace session
|
|
41
|
+
* state, and no `defaultPermissionMode` in `~/.pugi/config.json`. We
|
|
42
|
+
* default cautious (`ask`) — an operator who has not configured anything
|
|
43
|
+
* is treated as a new operator who deserves visibility into every tool
|
|
44
|
+
* call.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_PERMISSION_MODE = 'ask';
|
|
47
|
+
/**
|
|
48
|
+
* Type guard for arbitrary string input (CLI flag, session.json
|
|
49
|
+
* deserialization). Returns false for casing variants — caller is
|
|
50
|
+
* expected to lowercase before testing.
|
|
51
|
+
*/
|
|
52
|
+
export function isPermissionMode(value) {
|
|
53
|
+
return typeof value === 'string' && PERMISSION_MODES.includes(value);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse + validate a mode string. Returns null for invalid input so the
|
|
57
|
+
* caller can surface a typed error (`unknown mode: <value>`) instead of
|
|
58
|
+
* throwing from a parse helper.
|
|
59
|
+
*/
|
|
60
|
+
export function parsePermissionMode(value) {
|
|
61
|
+
const lower = value.trim().toLowerCase();
|
|
62
|
+
return isPermissionMode(lower) ? lower : null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Map the canonical 4-mode taxonomy to the legacy 6-mode SDK enum used
|
|
66
|
+
* by `core/permission.ts::evaluateBashPermission` and friends. The map
|
|
67
|
+
* is intentionally surjective on a narrower target — the canonical
|
|
68
|
+
* layer is the new public contract, the legacy layer is plumbing.
|
|
69
|
+
*
|
|
70
|
+
* plan -> 'plan' (read-only)
|
|
71
|
+
* ask -> 'ask' (prompt every action)
|
|
72
|
+
* allow -> 'auto' (allow non-destructive; deny destructive)
|
|
73
|
+
* bypass -> 'bypassPermissions' (allow everything except destructive override)
|
|
74
|
+
*
|
|
75
|
+
* Callers that need the legacy enum (existing bash classifier, settings
|
|
76
|
+
* persistence) should funnel through this helper so the mapping is in
|
|
77
|
+
* one place.
|
|
78
|
+
*/
|
|
79
|
+
export function toLegacyMode(mode) {
|
|
80
|
+
switch (mode) {
|
|
81
|
+
case 'plan':
|
|
82
|
+
return 'plan';
|
|
83
|
+
case 'ask':
|
|
84
|
+
return 'ask';
|
|
85
|
+
case 'allow':
|
|
86
|
+
return 'auto';
|
|
87
|
+
case 'bypass':
|
|
88
|
+
return 'bypassPermissions';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* One-line human-readable summary surfaced by the `/permissions` table
|
|
93
|
+
* and `pugi --help` text. Kept inline so the strings stay localizable
|
|
94
|
+
* via a single edit point.
|
|
95
|
+
*/
|
|
96
|
+
export const PERMISSION_MODE_GLOSS = Object.freeze({
|
|
97
|
+
plan: 'Read-only — propose, never execute. Write + dispatch tools refused.',
|
|
98
|
+
ask: 'Prompt before every tool call. Default for new operators.',
|
|
99
|
+
allow: 'Execute tools without prompts. Policy hooks still fire.',
|
|
100
|
+
bypass: 'Execute tools without prompts AND skip policy hooks. Power-user only.',
|
|
101
|
+
});
|
|
102
|
+
//# sourceMappingURL=mode.js.map
|