@element47/ag 4.5.1 → 4.5.2

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 (48) hide show
  1. package/README.md +106 -4
  2. package/dist/cli/repl.d.ts +7 -2
  3. package/dist/cli/repl.d.ts.map +1 -1
  4. package/dist/cli/repl.js +180 -22
  5. package/dist/cli/repl.js.map +1 -1
  6. package/dist/cli.js +8 -8
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/__tests__/agent.test.js +42 -1
  9. package/dist/core/__tests__/agent.test.js.map +1 -1
  10. package/dist/core/__tests__/context.test.js +37 -0
  11. package/dist/core/__tests__/context.test.js.map +1 -1
  12. package/dist/core/__tests__/guardrails.test.d.ts +2 -0
  13. package/dist/core/__tests__/guardrails.test.d.ts.map +1 -0
  14. package/dist/core/__tests__/guardrails.test.js +400 -0
  15. package/dist/core/__tests__/guardrails.test.js.map +1 -0
  16. package/dist/core/__tests__/permission-manager.test.d.ts +2 -0
  17. package/dist/core/__tests__/permission-manager.test.d.ts.map +1 -0
  18. package/dist/core/__tests__/permission-manager.test.js +246 -0
  19. package/dist/core/__tests__/permission-manager.test.js.map +1 -0
  20. package/dist/core/__tests__/streaming.test.js +8 -1
  21. package/dist/core/__tests__/streaming.test.js.map +1 -1
  22. package/dist/core/agent.d.ts +9 -0
  23. package/dist/core/agent.d.ts.map +1 -1
  24. package/dist/core/agent.js +50 -15
  25. package/dist/core/agent.js.map +1 -1
  26. package/dist/core/guardrails.d.ts +32 -0
  27. package/dist/core/guardrails.d.ts.map +1 -0
  28. package/dist/core/guardrails.js +149 -0
  29. package/dist/core/guardrails.js.map +1 -0
  30. package/dist/core/loader.d.ts +6 -2
  31. package/dist/core/loader.d.ts.map +1 -1
  32. package/dist/core/loader.js +23 -9
  33. package/dist/core/loader.js.map +1 -1
  34. package/dist/core/permissions.d.ts +60 -0
  35. package/dist/core/permissions.d.ts.map +1 -0
  36. package/dist/core/permissions.js +252 -0
  37. package/dist/core/permissions.js.map +1 -0
  38. package/dist/core/registry.d.ts.map +1 -1
  39. package/dist/core/registry.js +16 -0
  40. package/dist/core/registry.js.map +1 -1
  41. package/dist/core/skills.d.ts.map +1 -1
  42. package/dist/core/skills.js +26 -3
  43. package/dist/core/skills.js.map +1 -1
  44. package/dist/core/types.d.ts +15 -1
  45. package/dist/core/types.d.ts.map +1 -1
  46. package/dist/memory/__tests__/memory.test.js +28 -0
  47. package/dist/memory/__tests__/memory.test.js.map +1 -1
  48. package/package.json +1 -1
package/README.md CHANGED
@@ -127,6 +127,46 @@ export default {
127
127
 
128
128
  That's it. No config, no registry. Use `/tools` in the REPL to see what's loaded.
129
129
 
130
+ ### Permission Keys
131
+
132
+ By default, custom tools require approval for every call (or you allow all calls with `toolname(*)`). To enable fine-grained permission patterns, add a `permissionKey` to your tool:
133
+
134
+ ```js
135
+ // .ag/tools/deploy.mjs
136
+ export default {
137
+ type: "function",
138
+ function: {
139
+ name: "deploy",
140
+ description: "Deploy to an environment",
141
+ parameters: {
142
+ type: "object",
143
+ properties: {
144
+ target: { type: "string", enum: ["staging", "production"] },
145
+ branch: { type: "string" }
146
+ },
147
+ required: ["target"]
148
+ }
149
+ },
150
+ permissionKey: { qualifier: "target" },
151
+ execute: async ({ target, branch }) => { /* ... */ }
152
+ };
153
+ ```
154
+
155
+ Now permission patterns can target specific argument values:
156
+
157
+ | Pattern | Effect |
158
+ |---------|--------|
159
+ | `deploy(staging)` | Allow staging deploys |
160
+ | `deploy(production)` | Allow production deploys |
161
+ | `deploy(*)` | Allow all deploys |
162
+
163
+ **`permissionKey` fields:**
164
+
165
+ - `qualifier` (required) — arg name whose value becomes the pattern qualifier. E.g., `{ qualifier: "target" }` + `target: "staging"` produces `deploy(staging)`.
166
+ - `value` (optional) — arg name whose value is matched by the glob portion. E.g., `{ qualifier: "action", value: "path" }` produces `mytool(read:configs/**)`.
167
+
168
+ Without `permissionKey`, the only available pattern is `toolname(*)`.
169
+
130
170
  ## Skills
131
171
 
132
172
  Skills are reusable prompt instructions (with optional tools) that the agent activates on-demand. Browse and install from [skills.sh](https://skills.sh):
@@ -234,22 +274,84 @@ Or set it permanently:
234
274
 
235
275
  ## Permissions
236
276
 
237
- In REPL mode, ag prompts before executing mutating operations:
277
+ In REPL mode, ag prompts before executing mutating operations. You can allow once, remember for the session, or save to the project:
238
278
 
239
279
  ```
240
- ? bash: npm test (y/n) y
280
+ ? bash: npm test (y)es / (a)lways / (p)roject / (n)o a
281
+ + Session rule: bash(npm:*)
241
282
  ✓ [bash] All tests passed
242
- ? file(write): src/utils.ts (y/n) y
283
+ ? file(write): src/utils.ts (y)es / (a)lways / (p)roject / (n)o p
284
+ + Saved to .ag/permissions.json: file(write:src/**)
243
285
  ✓ [file] Wrote src/utils.ts (24 lines, 680B)
244
286
  ```
245
287
 
288
+ **Prompt options:**
289
+ - **y** — allow this one time
290
+ - **a** — allow and remember the pattern for this session
291
+ - **p** — allow and save the pattern to project (`.ag/permissions.json`)
292
+ - **n** — deny this one time
293
+
294
+ ### Pattern Syntax
295
+
296
+ Patterns use `Tool(qualifier:glob)` format:
297
+
298
+ | Pattern | Matches |
299
+ |---------|---------|
300
+ | `bash(npm:*)` | Any bash command starting with `npm` |
301
+ | `bash(git:*)` | Any bash command starting with `git` |
302
+ | `file(write:src/**)` | File writes anywhere under `src/` |
303
+ | `file(edit:*)` | All file edits |
304
+ | `git(commit)` | Git commit |
305
+ | `web(fetch:*github.com*)` | Fetch from GitHub domains |
306
+ | `bash(*)` | All bash commands |
307
+ | `*` | Everything |
308
+
309
+ ### Rule Scopes
310
+
311
+ | Scope | Storage | Lifetime |
312
+ |-------|---------|----------|
313
+ | Session | In-memory | Until REPL exits |
314
+ | Project | `.ag/permissions.json` | Persists across sessions |
315
+ | Global | `~/.ag/permissions.json` | Persists everywhere |
316
+
317
+ Deny rules always override allow rules. Use `/permissions` to manage rules interactively.
318
+
319
+ ### Built-in Classifications
320
+
246
321
  **Always allowed (no prompt):** `file(read)`, `file(list)`, `grep(*)`, `memory(*)`, `plan(*)`, `skill(*)`, `git(status)`, `web(search)`
247
322
 
248
323
  **Prompted:** `bash`, `file(write)`, `file(edit)`, `git(commit/push/branch)`, `web(fetch)`
249
324
 
250
325
  **Always blocked:** `rm -rf /`, fork bombs, `sudo rm`, pipe-to-shell (enforced in code regardless of approval)
251
326
 
252
- Skip prompts with `ag -y` or `--yes`. One-shot mode (`ag "query"`) auto-approves.
327
+ Skip all prompts with `ag -y` or `--yes`. One-shot mode (`ag "query"`) auto-approves.
328
+
329
+ ## Guardrails
330
+
331
+ All externally-loaded tools and skills are scanned at load time for prompt injection and other security issues. This applies to:
332
+
333
+ - Custom tools (`.mjs` files in `~/.ag/tools/` and `.ag/tools/`)
334
+ - Skills (`SKILL.md` files in `~/.ag/skills/` and `.ag/skills/`)
335
+ - Skills installed from the registry via `/skill add`
336
+
337
+ **What gets checked:**
338
+
339
+ | Category | Severity | Examples |
340
+ |----------|----------|---------|
341
+ | Direct injection | Block | "ignore previous instructions", "system override", "reveal prompt" |
342
+ | Encoded payloads | Block | Base64-encoded injection attempts, HTML entity obfuscation |
343
+ | Hidden content | Block | HTML comments with instructions, zero-width characters, control chars |
344
+ | Exfiltration | Block/Warn | `fetch()` calls in descriptions (block), URLs/emails (warn) |
345
+ | Suspicious overrides | Warn | "bypass security", "auto-approve", "run without permission" |
346
+
347
+ **Blocked** items are skipped entirely with a warning. **Warned** items still load but emit a warning to stderr:
348
+
349
+ ```
350
+ Warning: evil-tool.mjs blocked by guardrails: tool "evil" description: prompt injection: "ignore previous instructions"
351
+ Warning: shady-tool.mjs: tool "shady" description: description contains URL
352
+ ```
353
+
354
+ When installing a skill from the registry, files are scanned before being written to disk. If the core `SKILL.md` is blocked, the entire installation is aborted.
253
355
 
254
356
  ## Streaming
255
357
 
@@ -1,14 +1,19 @@
1
1
  import { Interface } from 'node:readline';
2
2
  import { Agent } from '../core/agent.js';
3
+ import { PermissionManager } from '../core/permissions.js';
3
4
  import type { ConfirmToolCall } from '../core/types.js';
4
5
  export declare function createConfirmCallback(sharedRl?: Interface): ConfirmToolCall & {
5
6
  pauseSpinner: (() => void) | null;
6
7
  };
8
+ export declare function createPermissionCallback(pm: PermissionManager, sharedRl?: Interface): ConfirmToolCall & {
9
+ pauseSpinner: (() => void) | null;
10
+ };
7
11
  export declare class REPL {
8
12
  private readonly agent;
9
13
  private readonly rl;
10
- private readonly confirmCb;
11
- constructor(agent: Agent, confirmCb?: ReturnType<typeof createConfirmCallback>);
14
+ private readonly pm;
15
+ private confirmCb;
16
+ constructor(agent: Agent, pm?: PermissionManager, confirmCb?: ReturnType<typeof createPermissionCallback>);
12
17
  start(): Promise<void>;
13
18
  private handleCommand;
14
19
  private ask;
@@ -1 +1 @@
1
- {"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../src/cli/repl.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,SAAS,EAAsB,MAAM,eAAe,CAAC;AAC/E,OAAO,EAAE,KAAK,EAA0B,MAAM,kBAAkB,CAAC;AAKjE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAoBxD,wBAAgB,qBAAqB,CAAC,QAAQ,CAAC,EAAE,SAAS,GAAG,eAAe,GAAG;IAAE,YAAY,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;CAAE,CAuBnH;AA8GD,qBAAa,IAAI;IACf,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkD;gBAEhE,KAAK,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,UAAU,CAAC,OAAO,qBAAqB,CAAC;IAcxE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YA6Ld,aAAa;IAwU3B,OAAO,CAAC,GAAG;CAGZ"}
1
+ {"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../src/cli/repl.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,SAAS,EAAsB,MAAM,eAAe,CAAC;AAC/E,OAAO,EAAE,KAAK,EAA0B,MAAM,kBAAkB,CAAC;AAKjE,OAAO,EAAE,iBAAiB,EAAgB,MAAM,wBAAwB,CAAC;AACzE,OAAO,KAAK,EAAE,eAAe,EAAiB,MAAM,kBAAkB,CAAC;AAqCvE,wBAAgB,qBAAqB,CAAC,QAAQ,CAAC,EAAE,SAAS,GAAG,eAAe,GAAG;IAAE,YAAY,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;CAAE,CAgBnH;AAED,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,iBAAiB,EAAE,QAAQ,CAAC,EAAE,SAAS,GAAG,eAAe,GAAG;IAAE,YAAY,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;CAAE,CAiD7I;AA8GD,qBAAa,IAAI;IACf,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,EAAE,CAA2B;IAC9C,OAAO,CAAC,SAAS,CAAqD;gBAE1D,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,EAAE,iBAAiB,EAAE,SAAS,CAAC,EAAE,UAAU,CAAC,OAAO,wBAAwB,CAAC;IAqBnG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAwMd,aAAa;IA8Y3B,OAAO,CAAC,GAAG;CAGZ"}
package/dist/cli/repl.js CHANGED
@@ -3,6 +3,7 @@ import { loadConfig, saveConfig, configPath } from '../core/config.js';
3
3
  import { searchRegistry, installSkill, removeSkill, formatInstalls } from '../core/registry.js';
4
4
  import { C, renderMarkdown } from '../core/colors.js';
5
5
  import { VERSION } from '../core/version.js';
6
+ import { inferPattern } from '../core/permissions.js';
6
7
  function truncateCommand(command, maxLen = 80) {
7
8
  const firstLine = command.split('\n')[0];
8
9
  if (firstLine.length <= maxLen) {
@@ -19,29 +20,90 @@ function formatToolSummary(toolName, args) {
19
20
  default: return `${toolName}(${JSON.stringify(args).slice(0, 80)})`;
20
21
  }
21
22
  }
23
+ function promptQuestion(prompt, sharedRl) {
24
+ if (sharedRl) {
25
+ // Shared readline already manages terminal — no raw mode changes needed
26
+ return new Promise(resolve => sharedRl.question(prompt, resolve));
27
+ }
28
+ // Fallback: create a temporary readline; exit raw mode so it can work properly
29
+ const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
30
+ if (wasRaw)
31
+ process.stdin.setRawMode(false);
32
+ return new Promise(resolve => {
33
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
34
+ rl.question(prompt, a => { rl.close(); resolve(a); });
35
+ }).finally(() => { if (wasRaw)
36
+ process.stdin.setRawMode(true); });
37
+ }
38
+ const SIMPLE_OPTS = ` ${C.cyan}y${C.reset}/${C.cyan}n${C.reset} > `;
39
+ const FULL_OPTS = ` ${C.cyan}y${C.reset}/${C.cyan}n${C.reset} ${C.dim}a${C.reset}=${C.dim}always ${C.dim}p${C.reset}=${C.dim}project ${C.dim}d${C.reset}=${C.dim}deny session ${C.dim}D${C.reset}=${C.dim}deny project${C.reset} > `;
22
40
  export function createConfirmCallback(sharedRl) {
23
41
  const cb = async (toolName, args) => {
24
- // Stop any active spinner before prompting
42
+ // Stop any active spinner before prompting — clear line to avoid garbled output
25
43
  if (cb.pauseSpinner) {
26
44
  cb.pauseSpinner();
27
45
  cb.pauseSpinner = null;
28
46
  }
47
+ process.stderr.write('\x1b[K'); // ensure current line is clear
29
48
  const summary = formatToolSummary(toolName, args);
30
- if (sharedRl) {
31
- return new Promise(resolve => {
32
- sharedRl.question(` ${C.yellow}?${C.reset} ${C.dim}${summary}${C.reset} ${C.yellow}(y/n)${C.reset} `, answer => {
33
- resolve(answer.trim().toLowerCase() === 'n' ? 'deny' : 'allow');
34
- });
35
- });
49
+ process.stderr.write(` ${C.yellow}?${C.reset} ${C.dim}${summary}${C.reset}\n`);
50
+ const answer = await promptQuestion(SIMPLE_OPTS, sharedRl);
51
+ if (answer.trim().toLowerCase() === 'n') {
52
+ process.stderr.write(` ${C.red}−${C.reset} ${C.dim}Denied${C.reset}\n`);
53
+ return 'deny';
54
+ }
55
+ return 'allow';
56
+ };
57
+ cb.pauseSpinner = null;
58
+ return cb;
59
+ }
60
+ export function createPermissionCallback(pm, sharedRl) {
61
+ const cb = async (toolName, args, permissionKey) => {
62
+ // Check permission rules first
63
+ const decision = pm.check(toolName, args, permissionKey);
64
+ if (decision === 'allow')
65
+ return 'allow';
66
+ if (decision === 'deny')
67
+ return 'deny';
68
+ // Stop any active spinner before prompting — clear line to avoid garbled output
69
+ if (cb.pauseSpinner) {
70
+ cb.pauseSpinner();
71
+ cb.pauseSpinner = null;
72
+ }
73
+ process.stderr.write('\x1b[K'); // ensure current line is clear
74
+ const summary = formatToolSummary(toolName, args);
75
+ const pattern = inferPattern(toolName, args, permissionKey);
76
+ process.stderr.write(` ${C.yellow}?${C.reset} ${C.dim}${summary}${C.reset}\n`);
77
+ const answer = await promptQuestion(FULL_OPTS, sharedRl);
78
+ // Check for uppercase D before lowercasing
79
+ const raw = answer.trim();
80
+ if (raw === 'D') {
81
+ pm.addRule({ pattern, effect: 'deny' }, 'project');
82
+ pm.save('project');
83
+ process.stderr.write(` ${C.red}−${C.reset} ${C.dim}Saved deny to .ag/permissions.json: ${pattern}${C.reset}\n`);
84
+ return 'deny';
85
+ }
86
+ const choice = raw.toLowerCase();
87
+ switch (choice) {
88
+ case 'a':
89
+ pm.addRule({ pattern, effect: 'allow' }, 'session');
90
+ process.stderr.write(` ${C.green}+${C.reset} ${C.dim}Session rule: ${pattern}${C.reset}\n`);
91
+ return 'allow';
92
+ case 'p':
93
+ pm.addRule({ pattern, effect: 'allow' }, 'project');
94
+ pm.save('project');
95
+ process.stderr.write(` ${C.green}+${C.reset} ${C.dim}Saved to .ag/permissions.json: ${pattern}${C.reset}\n`);
96
+ return 'allow';
97
+ case 'n':
98
+ process.stderr.write(` ${C.red}−${C.reset} ${C.dim}Denied: ${pattern}${C.reset}\n`);
99
+ return 'deny';
100
+ case 'd':
101
+ pm.addRule({ pattern, effect: 'deny' }, 'session');
102
+ process.stderr.write(` ${C.red}−${C.reset} ${C.dim}Session deny: ${pattern}${C.reset}\n`);
103
+ return 'deny';
104
+ default: // 'y' or anything else = allow once
105
+ return 'allow';
36
106
  }
37
- // Fallback: create a temporary readline (non-REPL usage)
38
- const rl = createInterface({ input: process.stdin, output: process.stderr });
39
- return new Promise(resolve => {
40
- rl.question(` ${C.yellow}?${C.reset} ${C.dim}${summary}${C.reset} ${C.yellow}(y/n)${C.reset} `, answer => {
41
- rl.close();
42
- resolve(answer.trim().toLowerCase() === 'n' ? 'deny' : 'allow');
43
- });
44
- });
45
107
  };
46
108
  cb.pauseSpinner = null;
47
109
  return cb;
@@ -145,12 +207,21 @@ function maskKey(key) {
145
207
  export class REPL {
146
208
  agent;
147
209
  rl;
210
+ pm;
148
211
  confirmCb;
149
- constructor(agent, confirmCb) {
212
+ constructor(agent, pm, confirmCb) {
150
213
  this.agent = agent;
214
+ this.pm = pm ?? null;
151
215
  this.rl = createInterface({ input: process.stdin, output: process.stderr });
152
216
  // Rebind the confirm callback to use the shared readline
153
- if (confirmCb) {
217
+ if (confirmCb && pm) {
218
+ const shared = createPermissionCallback(pm, this.rl);
219
+ shared.pauseSpinner = confirmCb.pauseSpinner;
220
+ this.confirmCb = shared;
221
+ this.agent.setConfirmToolCall(shared);
222
+ }
223
+ else if (confirmCb) {
224
+ // Fallback: no permission manager, use basic confirm
154
225
  const shared = createConfirmCallback(this.rl);
155
226
  shared.pauseSpinner = confirmCb.pauseSpinner;
156
227
  this.confirmCb = shared;
@@ -163,16 +234,28 @@ export class REPL {
163
234
  async start() {
164
235
  const stats = this.agent.getStats();
165
236
  const skills = this.agent.getSkills();
237
+ console.error('');
166
238
  console.error(`${C.bold}ag v${VERSION}${C.reset} ${C.dim}(${this.agent.getModel()} via OpenRouter)${C.reset}`);
239
+ const customTools = this.agent.getTools().filter(t => !t.isBuiltin);
167
240
  const loaded = [
168
241
  stats.globalMemory && 'global',
169
242
  stats.projectMemory && 'project',
170
243
  stats.planCount > 0 && `${stats.planCount} plan(s)`,
171
244
  skills.length > 0 && `${skills.length} skill(s)`,
245
+ customTools.length > 0 && `${customTools.length} tool(s)`,
172
246
  ].filter(Boolean);
173
247
  if (loaded.length > 0) {
174
248
  console.error(`${C.dim}Loaded: ${loaded.join(', ')}${C.reset}`);
175
249
  }
250
+ for (const t of customTools) {
251
+ const desc = t.description.slice(0, 60) + (t.description.length > 60 ? '...' : '');
252
+ console.error(` ${C.green}+${C.reset} ${C.cyan}${t.name}${C.reset} ${C.dim}${desc}${C.reset}`);
253
+ }
254
+ for (const f of this.agent.getToolFailures()) {
255
+ const label = f.name ?? f.file;
256
+ const reason = f.reason.split('\n')[0].slice(0, 60) + (f.reason.length > 60 ? '...' : '');
257
+ console.error(` ${C.red}−${C.reset} ${C.cyan}${label}${C.reset} ${C.red}${reason}${C.reset}`);
258
+ }
176
259
  const activePlan = this.agent.getActivePlanName();
177
260
  if (activePlan) {
178
261
  const label = activePlan.replace(/^\d{4}-\d{2}-\d{2}T[\d-]+-?/, '').replace(/-/g, ' ').trim() || activePlan;
@@ -253,8 +336,7 @@ export class REPL {
253
336
  const parts = lineBuf.split('\n');
254
337
  lineBuf = final ? '' : (parts.pop() || '');
255
338
  for (let i = 0; i < parts.length; i++) {
256
- const rendered = renderMarkdown(parts[i]);
257
- process.stderr.write(rendered + '\n');
339
+ process.stderr.write(renderMarkdown(parts[i]) + '\n');
258
340
  }
259
341
  if (final && lineBuf) {
260
342
  process.stderr.write(renderMarkdown(lineBuf));
@@ -403,6 +485,11 @@ export class REPL {
403
485
  console.error(` ${C.cyan}/skill search [query]${C.reset} Search skills.sh`);
404
486
  console.error(` ${C.cyan}/skill add <source>${C.reset} Install skill from registry`);
405
487
  console.error(` ${C.cyan}/skill remove <name>${C.reset} Uninstall a skill`);
488
+ console.error(` ${C.cyan}/permissions${C.reset} Show permission rules`);
489
+ console.error(` ${C.cyan}/permissions allow <p>${C.reset} Add allow rule (session)`);
490
+ console.error(` ${C.cyan}/permissions deny <p>${C.reset} Add deny rule (session)`);
491
+ console.error(` ${C.cyan}/permissions save${C.reset} Save session rules to project`);
492
+ console.error(` ${C.cyan}/permissions clear${C.reset} Clear session rules`);
406
493
  console.error(` ${C.cyan}/exit${C.reset} Exit`);
407
494
  console.error('');
408
495
  break;
@@ -599,12 +686,16 @@ export class REPL {
599
686
  // Apply immediately: toggle confirmation prompts in this session
600
687
  if (parsed) {
601
688
  this.agent.setConfirmToolCall(null);
689
+ this.confirmCb = null;
602
690
  console.error(`${C.green}Auto-approve enabled — tool calls will no longer prompt.${C.reset}`);
603
691
  }
604
692
  else {
605
- const freshCb = createConfirmCallback(this.rl);
693
+ const freshCb = this.pm
694
+ ? createPermissionCallback(this.pm, this.rl)
695
+ : createConfirmCallback(this.rl);
606
696
  if (this.confirmCb)
607
697
  freshCb.pauseSpinner = this.confirmCb.pauseSpinner;
698
+ this.confirmCb = freshCb;
608
699
  this.agent.setConfirmToolCall(freshCb);
609
700
  console.error(`${C.yellow}Auto-approve disabled — tool calls will prompt again.${C.reset}`);
610
701
  }
@@ -628,9 +719,12 @@ export class REPL {
628
719
  this.agent.setModel('anthropic/claude-sonnet-4.6');
629
720
  }
630
721
  if (key === 'autoApprove') {
631
- const freshCb = createConfirmCallback(this.rl);
722
+ const freshCb = this.pm
723
+ ? createPermissionCallback(this.pm, this.rl)
724
+ : createConfirmCallback(this.rl);
632
725
  if (this.confirmCb)
633
726
  freshCb.pauseSpinner = this.confirmCb.pauseSpinner;
727
+ this.confirmCb = freshCb;
634
728
  this.agent.setConfirmToolCall(freshCb);
635
729
  }
636
730
  console.error(`${C.yellow}Config: ${key} removed${C.reset}\n`);
@@ -670,7 +764,8 @@ export class REPL {
670
764
  const tools = this.agent.getTools();
671
765
  console.error(`${C.bold}Tools (${tools.length}):${C.reset}`);
672
766
  for (const t of tools) {
673
- console.error(` ${C.cyan}${t.name}${C.reset} ${C.dim}${t.description.slice(0, 60)}${t.description.length > 60 ? '...' : ''}${C.reset}`);
767
+ const prefix = t.isBuiltin ? ' ' : `${C.green}+${C.reset}`;
768
+ console.error(` ${prefix} ${C.cyan}${t.name}${C.reset} ${C.dim}${t.description.slice(0, 60)}${t.description.length > 60 ? '...' : ''}${C.reset}`);
674
769
  }
675
770
  console.error('');
676
771
  break;
@@ -743,6 +838,69 @@ export class REPL {
743
838
  }
744
839
  break;
745
840
  }
841
+ // ── /permissions ────────────────────────────────────────────────────
842
+ case 'permissions':
843
+ case 'perms': {
844
+ if (!this.pm) {
845
+ console.error(`${C.dim}Permissions not available (auto-approve mode).${C.reset}\n`);
846
+ break;
847
+ }
848
+ const subCmd = args[0]?.toLowerCase();
849
+ if (subCmd === 'allow' && args[1]) {
850
+ const pattern = args.slice(1).join(' ');
851
+ this.pm.addRule({ pattern, effect: 'allow' }, 'session');
852
+ console.error(`${C.green}+ Session allow: ${pattern}${C.reset}\n`);
853
+ }
854
+ else if (subCmd === 'deny' && args[1]) {
855
+ const pattern = args.slice(1).join(' ');
856
+ this.pm.addRule({ pattern, effect: 'deny' }, 'session');
857
+ console.error(`${C.green}+ Session deny: ${pattern}${C.reset}\n`);
858
+ }
859
+ else if (subCmd === 'save') {
860
+ const scope = args[1]?.toLowerCase() === 'global' ? 'global' : 'project';
861
+ this.pm.save(scope);
862
+ console.error(`${C.yellow}Saved to ${scope} permissions.${C.reset}\n`);
863
+ }
864
+ else if (subCmd === 'clear') {
865
+ const scope = args[1]?.toLowerCase();
866
+ if (scope === 'project' || scope === 'global') {
867
+ this.pm.clear(scope);
868
+ this.pm.save(scope);
869
+ console.error(`${C.yellow}Cleared ${scope} permissions.${C.reset}\n`);
870
+ }
871
+ else {
872
+ this.pm.clear('session');
873
+ console.error(`${C.yellow}Cleared session permissions.${C.reset}\n`);
874
+ }
875
+ }
876
+ else if (subCmd === 'remove' && args[1]) {
877
+ const pattern = args.slice(1).join(' ');
878
+ const removed = this.pm.removeRule(pattern, 'session')
879
+ || this.pm.removeRule(pattern, 'project')
880
+ || this.pm.removeRule(pattern, 'global');
881
+ if (removed) {
882
+ console.error(`${C.yellow}Removed: ${pattern}${C.reset}\n`);
883
+ }
884
+ else {
885
+ console.error(`${C.dim}No matching rule found.${C.reset}\n`);
886
+ }
887
+ }
888
+ else {
889
+ // List all rules
890
+ const rules = this.pm.getRules();
891
+ if (rules.length === 0) {
892
+ console.error(`${C.dim}No permission rules. Approve with (a)lways or (p)roject to add rules.${C.reset}\n`);
893
+ break;
894
+ }
895
+ console.error(`${C.bold}Permission Rules:${C.reset}`);
896
+ for (const r of rules) {
897
+ const icon = r.effect === 'allow' ? `${C.green}✓` : `${C.red}✗`;
898
+ console.error(` ${icon} ${C.dim}[${r.scope}]${C.reset} ${r.effect} ${C.cyan}${r.pattern}${C.reset}`);
899
+ }
900
+ console.error('');
901
+ }
902
+ break;
903
+ }
746
904
  // ── /exit ─────────────────────────────────────────────────────────
747
905
  case 'exit':
748
906
  case 'quit':