agent-sh 0.5.0 → 0.6.0

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 (37) hide show
  1. package/README.md +12 -43
  2. package/dist/agent/agent-loop.d.ts +1 -0
  3. package/dist/agent/agent-loop.js +119 -26
  4. package/dist/agent/subagent.js +3 -1
  5. package/dist/agent/system-prompt.d.ts +1 -1
  6. package/dist/agent/system-prompt.js +21 -16
  7. package/dist/agent/tools/bash.js +10 -1
  8. package/dist/agent/tools/display.d.ts +13 -0
  9. package/dist/agent/tools/display.js +70 -0
  10. package/dist/agent/tools/edit-file.js +60 -7
  11. package/dist/agent/tools/glob.js +39 -7
  12. package/dist/agent/tools/grep.js +111 -20
  13. package/dist/agent/tools/ls.js +31 -2
  14. package/dist/agent/tools/read-file.d.ts +9 -1
  15. package/dist/agent/tools/read-file.js +50 -4
  16. package/dist/agent/tools/user-shell.js +40 -13
  17. package/dist/agent/tools/write-file.js +9 -1
  18. package/dist/agent/types.d.ts +35 -1
  19. package/dist/core.d.ts +1 -3
  20. package/dist/core.js +7 -11
  21. package/dist/event-bus.d.ts +18 -3
  22. package/dist/extension-loader.d.ts +1 -1
  23. package/dist/extension-loader.js +1 -3
  24. package/dist/extensions/tui-renderer.js +341 -83
  25. package/dist/index.js +41 -36
  26. package/dist/input-handler.js +4 -2
  27. package/dist/settings.js +1 -1
  28. package/dist/shell.js +2 -2
  29. package/dist/utils/diff.js +10 -0
  30. package/dist/utils/markdown.d.ts +1 -0
  31. package/dist/utils/markdown.js +23 -1
  32. package/dist/utils/tool-display.d.ts +4 -0
  33. package/dist/utils/tool-display.js +22 -5
  34. package/examples/extensions/claude-code-bridge/index.ts +8 -12
  35. package/examples/extensions/pi-bridge/index.ts +10 -12
  36. package/examples/extensions/secret-guard.ts +100 -0
  37. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import shellRecall from "./extensions/shell-recall.js";
11
11
  import commandSuggest from "./extensions/command-suggest.js";
12
12
  import { loadExtensions } from "./extension-loader.js";
13
13
  import { getSettings } from "./settings.js";
14
+ import { discoverSkills } from "./agent/skills.js";
14
15
  /**
15
16
  * Capture the user's full shell environment.
16
17
  * This picks up env vars exported in .zshrc/.bashrc that the
@@ -129,8 +130,7 @@ Examples:
129
130
 
130
131
  Inside the shell:
131
132
  Type normally Commands run in your real shell
132
- > <query> Ask the AI agent a question (execute mode)
133
- ? <command> Have the agent run a command in your shell (help mode)
133
+ > <query> Ask the AI agent (it decides how to help)
134
134
  > /help Show available slash commands
135
135
  Ctrl-C Cancel agent response (or signal shell as usual)
136
136
  `);
@@ -210,40 +210,18 @@ async function main() {
210
210
  if (process.env.DEBUG) {
211
211
  console.error('[agent-sh] Shell created');
212
212
  }
213
- // ── Input modes ──────────────────────────────────────────────
213
+ // ── Input mode ───────────────────────────────────────────────
214
214
  bus.emit("input-mode:register", {
215
- id: "execute",
215
+ id: "agent",
216
216
  trigger: ">",
217
- label: "execute",
217
+ label: "agent",
218
218
  promptIcon: "❯",
219
219
  indicator: "●",
220
220
  onSubmit(query, b) {
221
- b.emit("agent:submit", { query, modeLabel: "Execute", modeInstruction: "[mode: execute]" });
221
+ b.emit("agent:submit", { query });
222
222
  },
223
223
  returnToSelf: true,
224
224
  });
225
- bus.emit("input-mode:register", {
226
- id: "help",
227
- trigger: "?",
228
- label: "help",
229
- promptIcon: "❯",
230
- indicator: "❓",
231
- onSubmit(query, b) {
232
- const onToolDone = (e) => {
233
- if (e.kind === "execute") {
234
- b.emit("agent:cancel-request", { silent: true });
235
- }
236
- };
237
- const cleanup = () => {
238
- b.off("agent:tool-completed", onToolDone);
239
- b.off("agent:processing-done", cleanup);
240
- };
241
- b.on("agent:tool-completed", onToolDone);
242
- b.on("agent:processing-done", cleanup);
243
- b.emit("agent:submit", { query, modeLabel: "Help", modeInstruction: "[mode: help]" });
244
- },
245
- returnToSelf: false,
246
- });
247
225
  // ── Extensions ────────────────────────────────────────────────
248
226
  if (process.env.DEBUG) {
249
227
  console.error('[agent-sh] Setting up extensions...');
@@ -259,8 +237,9 @@ async function main() {
259
237
  console.error('[agent-sh] Loading extensions...');
260
238
  }
261
239
  const loadExtensionsTimeoutMs = 10000;
240
+ let loadedExtensions = [];
262
241
  await Promise.race([
263
- loadExtensions(extCtx, config.extensions),
242
+ loadExtensions(extCtx, config.extensions).then((names) => { loadedExtensions = names; }),
264
243
  new Promise((_, reject) => setTimeout(() => reject(new Error(`Extension loading timeout after ${loadExtensionsTimeoutMs}ms`)), loadExtensionsTimeoutMs)),
265
244
  ]).catch((err) => {
266
245
  console.error(`Warning: ${err.message}`);
@@ -268,6 +247,8 @@ async function main() {
268
247
  if (process.env.DEBUG) {
269
248
  console.error('[agent-sh] Extensions loaded');
270
249
  }
250
+ // ── Discover skills ───────────────────────────────────────────
251
+ const skills = discoverSkills(process.cwd());
271
252
  // ── Activate agent backend ────────────────────────────────────
272
253
  // Extensions had their chance to register via agent:register-backend.
273
254
  // If none did, the built-in AgentLoop gets wired to bus events.
@@ -275,15 +256,39 @@ async function main() {
275
256
  // ── Startup banner ───────────────────────────────────────────
276
257
  const settings = getSettings();
277
258
  if (settings.startupBanner !== false) {
278
- const title = core.llmClient
279
- ? `${p.accent}${p.bold}agent-sh${p.reset}${p.dim} · ${core.llmClient.model}${p.reset}`
280
- : `${p.accent}${p.bold}agent-sh${p.reset}`;
281
- const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}?${p.muted} to run in shell · ${p.warning}/help${p.muted} for commands${p.reset}`;
282
259
  const termW = process.stdout.columns || 80;
283
- const borderLine = `${p.muted}${"─".repeat(Math.min(termW, 60))}${p.reset}`;
260
+ const bannerW = Math.min(termW, 60);
261
+ const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
262
+ const info = agentInfo;
263
+ const backendName = info?.name ?? "agent-sh";
264
+ const model = info?.model ?? core.llmClient?.model;
265
+ const provider = info?.provider;
266
+ const modelValue = model
267
+ ? provider ? `${model} [${provider}]` : model
268
+ : null;
269
+ let sections = "";
270
+ sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
271
+ if (modelValue) {
272
+ sections += `\n ${p.muted}Model:${p.reset} ${p.dim}${modelValue}${p.reset}`;
273
+ }
274
+ if (loadedExtensions.length > 0) {
275
+ sections += `\n\n ${p.muted}Extensions:${p.reset}`;
276
+ for (const name of loadedExtensions) {
277
+ sections += `\n ${p.dim}${name}${p.reset}`;
278
+ }
279
+ }
280
+ if (skills.length > 0) {
281
+ sections += `\n\n ${p.muted}Skills:${p.reset}`;
282
+ for (const s of skills) {
283
+ sections += `\n ${p.dim}${s.name}${p.reset}`;
284
+ }
285
+ }
286
+ const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`;
287
+ const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
284
288
  process.stdout.write("\n" + borderLine + "\n" +
285
- " " + title + "\n" +
286
- " " + hint + "\n" +
289
+ " " + productName +
290
+ sections + "\n" +
291
+ "\n " + hint + "\n" +
287
292
  borderLine + "\n\n");
288
293
  }
289
294
  // ── Terminal lifecycle ────────────────────────────────────────
@@ -509,7 +509,8 @@ export class InputHandler {
509
509
  }
510
510
  this.editor.buffer = this.history[this.historyIndex];
511
511
  this.editor.cursor = this.editor.buffer.length;
512
- this.renderModeInput();
512
+ this.clearAutocompleteLines();
513
+ this.writeModePromptLine();
513
514
  }
514
515
  break;
515
516
  case "arrow-down":
@@ -532,7 +533,8 @@ export class InputHandler {
532
533
  this.editor.buffer = this.savedBuffer;
533
534
  }
534
535
  this.editor.cursor = this.editor.buffer.length;
535
- this.renderModeInput();
536
+ this.clearAutocompleteLines();
537
+ this.writeModePromptLine();
536
538
  }
537
539
  break;
538
540
  }
package/dist/settings.js CHANGED
@@ -22,7 +22,7 @@ const DEFAULTS = {
22
22
  shellTailLines: 5,
23
23
  recallExpandMaxLines: 100,
24
24
  maxCommandOutputLines: 3,
25
- readOutputMaxLines: 0,
25
+ readOutputMaxLines: 10,
26
26
  diffMaxLines: 20,
27
27
  skillPaths: [],
28
28
  startupBanner: true,
package/dist/shell.js CHANGED
@@ -274,7 +274,7 @@ export class Shell {
274
274
  const handler = (e) => {
275
275
  clearTimeout(timeout);
276
276
  this.bus.off("shell:command-done", handler);
277
- resolve({ output: e.output, cwd: e.cwd });
277
+ resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
278
278
  };
279
279
  this.bus.on("shell:command-done", handler);
280
280
  this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
@@ -283,7 +283,7 @@ export class Shell {
283
283
  this.paused = true;
284
284
  this.echoSkip = false;
285
285
  this.bus.emit("shell:agent-exec-done", {});
286
- return { ...payload, output: output.output, cwd: output.cwd, done: true };
286
+ return { ...payload, output: output.output, cwd: output.cwd, exitCode: output.exitCode, done: true };
287
287
  });
288
288
  }
289
289
  // ── Public API (used by index.ts) ──
@@ -39,6 +39,16 @@ export function computeDiff(oldText, newText) {
39
39
  // Build LCS table and backtrack to produce diff lines
40
40
  const a = oldText.split("\n");
41
41
  const b = newText.split("\n");
42
+ // Bail out if LCS table would be too large (avoids OOM / hang)
43
+ if (a.length * b.length > 10_000_000) {
44
+ return {
45
+ hunks: [],
46
+ added: b.length,
47
+ removed: a.length,
48
+ isIdentical: false,
49
+ isNewFile: false,
50
+ };
51
+ }
42
52
  const dp = buildLcs(a, b);
43
53
  const raw = backtrack(dp, a, b);
44
54
  let added = 0;
@@ -15,6 +15,7 @@ export declare class MarkdownRenderer {
15
15
  private buffer;
16
16
  private contentWidth;
17
17
  private firstLine;
18
+ private lastLineBlank;
18
19
  private pendingLines;
19
20
  private width;
20
21
  private tableRows;
@@ -83,6 +83,7 @@ export class MarkdownRenderer {
83
83
  buffer = "";
84
84
  contentWidth;
85
85
  firstLine = true;
86
+ lastLineBlank = false;
86
87
  pendingLines = [];
87
88
  width;
88
89
  tableRows = [];
@@ -192,6 +193,9 @@ export class MarkdownRenderer {
192
193
  }
193
194
  // Render rows
194
195
  const hasHeader = sepIdx.includes(1) && dataRows.length > 1;
196
+ // Top border
197
+ const topBorder = colWidths.map((w) => "─".repeat(w)).join(`─┬─`);
198
+ this.writeLine(`${p.dim}┌─${topBorder}─┐${p.reset}`);
195
199
  for (let i = 0; i < dataRows.length; i++) {
196
200
  const row = dataRows[i];
197
201
  const isHeader = hasHeader && i === 0;
@@ -207,6 +211,9 @@ export class MarkdownRenderer {
207
211
  this.writeLine(`${p.dim}├─${sep}─┤${p.reset}`);
208
212
  }
209
213
  }
214
+ // Bottom border
215
+ const bottomBorder = colWidths.map((w) => "─".repeat(w)).join(`─┴─`);
216
+ this.writeLine(`${p.dim}└─${bottomBorder}─┘${p.reset}`);
210
217
  }
211
218
  renderLine(line) {
212
219
  if (line.trim() === "")
@@ -232,6 +239,16 @@ export class MarkdownRenderer {
232
239
  const bq = line.match(/^>\s?(.*)/);
233
240
  if (bq)
234
241
  return `${p.muted}│${p.reset} ${p.dim}${p.italic}${this.renderInline(bq[1] || "")}${p.reset}`;
242
+ // Task list (checkbox items) — must come before generic unordered list
243
+ const task = line.match(/^(\s*)[*\-+]\s+\[([ xX])\]\s+(.*)/);
244
+ if (task) {
245
+ const indent = task[1] || "";
246
+ const checked = task[2] !== " ";
247
+ const box = checked
248
+ ? `${p.success}☑${p.reset}`
249
+ : `${p.dim}☐${p.reset}`;
250
+ return `${indent} ${box} ${this.renderInline(task[3] || "")}`;
251
+ }
235
252
  // Unordered list
236
253
  const ul = line.match(/^(\s*)[*\-+]\s+(.*)/);
237
254
  if (ul) {
@@ -268,9 +285,14 @@ export class MarkdownRenderer {
268
285
  * The line is accumulated internally — call drainLines() to extract.
269
286
  */
270
287
  writeLine(text) {
271
- if (this.firstLine && visibleLen(text) === 0)
288
+ const isBlank = visibleLen(text) === 0;
289
+ if (this.firstLine && isBlank)
290
+ return;
291
+ // Collapse consecutive blank lines to a single one
292
+ if (isBlank && this.lastLineBlank)
272
293
  return;
273
294
  this.firstLine = false;
295
+ this.lastLineBlank = isBlank;
274
296
  this.pendingLines.push(` ${text}`);
275
297
  }
276
298
  }
@@ -6,6 +6,8 @@ export interface ToolCallRender {
6
6
  command?: string;
7
7
  /** Tool kind from ACP (read, edit, execute, search, etc.). */
8
8
  kind?: string;
9
+ /** Custom icon character — when set, tool name is omitted (icon implies tool). */
10
+ icon?: string;
9
11
  /** File locations affected by the tool call. */
10
12
  locations?: {
11
13
  path: string;
@@ -13,6 +15,8 @@ export interface ToolCallRender {
13
15
  }[];
14
16
  /** Raw input parameters sent to the tool. */
15
17
  rawInput?: unknown;
18
+ /** Pre-formatted display detail from tool's formatCall(). Takes precedence over rawInput extraction. */
19
+ displayDetail?: string;
16
20
  }
17
21
  export interface ToolResultRender {
18
22
  exitCode: number | null;
@@ -39,6 +39,7 @@ const KIND_ICONS = {
39
39
  move: "↗",
40
40
  search: "⌕",
41
41
  execute: "▶",
42
+ display: "◇",
42
43
  think: "◇",
43
44
  fetch: "↓",
44
45
  switch_mode: "⇄",
@@ -49,7 +50,10 @@ function kindIcon(kind) {
49
50
  // ── Tool call rendering ──────────────────────────────────────────
50
51
  export function renderToolCall(tool, width) {
51
52
  const mode = selectToolDisplayMode(width);
52
- const icon = kindIcon(tool.kind);
53
+ const icon = tool.icon ?? kindIcon(tool.kind);
54
+ // If the tool registered a custom icon, it's self-describing — omit the name.
55
+ // Otherwise, include the tool name so the user knows what ran.
56
+ const hasCustomIcon = !!tool.icon;
53
57
  if (mode === "summary") {
54
58
  const text = truncateVisible(`${icon} ${tool.title}`, width);
55
59
  return [`${p.warning}${text}${p.reset}`];
@@ -58,7 +62,10 @@ export function renderToolCall(tool, width) {
58
62
  // Build a compact detail string to append after the title
59
63
  let detail = "";
60
64
  const cwd = process.cwd();
61
- if (mode === "full") {
65
+ if (mode === "full" && tool.displayDetail) {
66
+ detail = tool.displayDetail;
67
+ }
68
+ else if (mode === "full") {
62
69
  if (tool.command) {
63
70
  detail = `$ ${tool.command}`;
64
71
  }
@@ -97,14 +104,24 @@ export function renderToolCall(tool, width) {
97
104
  }
98
105
  }
99
106
  }
100
- // Render as single line: icon + detail (icon implies the tool type)
101
- // Falls back to icon + title when no detail is available
107
+ // Render as single line: icon + kind + detail
102
108
  const maxDetailW = Math.max(1, width - 4);
103
- if (detail) {
109
+ if (detail && hasCustomIcon && tool.kind) {
110
+ const combined = `${tool.kind} ${detail}`;
111
+ const truncated = combined.length > maxDetailW ? combined.slice(0, maxDetailW - 1) + "…" : combined;
112
+ lines.push(`${p.warning}${icon}${p.reset} ${p.dim}${truncated}${p.reset}`);
113
+ }
114
+ else if (detail && hasCustomIcon) {
104
115
  if (detail.length > maxDetailW)
105
116
  detail = detail.slice(0, maxDetailW - 1) + "…";
106
117
  lines.push(`${p.warning}${icon}${p.reset} ${p.dim}${detail}${p.reset}`);
107
118
  }
119
+ else if (detail) {
120
+ const prefix = `${tool.title}: `;
121
+ const combined = prefix + detail;
122
+ const truncated = combined.length > maxDetailW ? combined.slice(0, maxDetailW - 1) + "…" : combined;
123
+ lines.push(`${p.warning}${icon}${p.reset} ${p.dim}${truncated}${p.reset}`);
124
+ }
108
125
  else {
109
126
  lines.push(`${p.warning}${icon} ${tool.title}${p.reset}`);
110
127
  }
@@ -30,8 +30,8 @@ function createUserShellTool(bus: EventBus) {
30
30
 
31
31
  return tool(
32
32
  "user_shell",
33
- "Run a command in the user's live shell (visible in terminal). " +
34
- "Use for cd, export, source, or commands the user wants to see. " +
33
+ "Run a command with lasting effects in the user's live shell (cd, export, " +
34
+ "install packages, start servers) or show output the user wants to see. " +
35
35
  "Set return_output=true only if you need to inspect the result.",
36
36
  {
37
37
  command: z.string().describe("Command to execute in user's shell"),
@@ -71,12 +71,8 @@ export default function activate(ctx: ExtensionContext): void {
71
71
  const listeners: Array<{ event: string; fn: Function }> = [];
72
72
 
73
73
  const wireListeners = () => {
74
- const onSubmit = async ({ query: userQuery, modeInstruction, modeLabel }: any) => {
75
- const prompt = modeInstruction
76
- ? `${modeInstruction}\n${userQuery}`
77
- : userQuery;
78
-
79
- bus.emit("agent:query", { query: userQuery, modeLabel });
74
+ const onSubmit = async ({ query: userQuery }: any) => {
75
+ bus.emit("agent:query", { query: userQuery });
80
76
  bus.emit("agent:processing-start", {});
81
77
 
82
78
  let fullResponseText = "";
@@ -84,7 +80,7 @@ export default function activate(ctx: ExtensionContext): void {
84
80
 
85
81
  try {
86
82
  activeQuery = query({
87
- prompt,
83
+ prompt: userQuery,
88
84
  options: {
89
85
  cwd: process.cwd(),
90
86
  systemPrompt: {
@@ -92,9 +88,9 @@ export default function activate(ctx: ExtensionContext): void {
92
88
  preset: "claude_code",
93
89
  append:
94
90
  "You are running inside agent-sh, a terminal wrapper.\n" +
95
- "EXECUTE mode ('>'): Use your standard tools. Do NOT use user_shell.\n" +
96
- "HELP mode ('?'): Run the command via mcp__agent-sh__user_shell. Just run it, no explanation.\n" +
97
- "Each prompt includes a per-query mode instruction follow it.",
91
+ "Use your standard tools (Read, Edit, Write, Bash, Glob, Grep) for investigation.\n" +
92
+ "Use mcp__agent-sh__user_shell to run commands in the user's live shell when they ask to see output or need lasting effects (cd, install, start servers).\n" +
93
+ "Default to standard tools. Use user_shell when the user is the intended audience for the output or the command has real effects.",
98
94
  },
99
95
  mcpServers: { "agent-sh": shellServer },
100
96
  allowedTools: [
@@ -48,17 +48,16 @@ function createUserShellToolDef(bus: EventBus) {
48
48
  name: "user_shell",
49
49
  label: "user_shell",
50
50
  description:
51
- "Run a command in the user's live shell (visible in terminal). " +
52
- "Use for cd, export, source, or commands the user wants to see. " +
51
+ "Run a command with lasting effects in the user's live shell (cd, export, " +
52
+ "install packages, start servers) or show output the user wants to see. " +
53
53
  "Output is shown directly to the user. Set return_output=true only " +
54
54
  "if you need to inspect the result.",
55
- promptSnippet: "Execute commands in the user's live terminal (PTY). Use in HELP mode.",
55
+ promptSnippet: "Execute commands in the user's live terminal (PTY).",
56
56
  promptGuidelines: [
57
- "You are running inside agent-sh, a terminal wrapper with two interaction modes.",
58
- "EXECUTE mode (triggered by '>'): Use your standard tools (bash, file ops). Do NOT use user_shell.",
59
- "HELP mode (triggered by '?'): Run the command via user_shell. Do not explain or confirm just run it.",
60
- "Each prompt includes a per-query mode instruction follow it.",
61
- "user_shell executes in the user's actual shell (their aliases, env vars, cwd). Use bash for background work.",
57
+ "You are running inside agent-sh, a terminal wrapper.",
58
+ "Use your standard tools (bash, file ops) for investigation output goes to you, not the user.",
59
+ "Use user_shell to run commands in the user's live shell when they ask to see output or need lasting effects (cd, install, start servers).",
60
+ "Default to standard tools. Use user_shell when the user is the intended audience for the output or the command has real effects.",
62
61
  ],
63
62
  parameters: schema,
64
63
 
@@ -203,7 +202,7 @@ export default function activate(ctx: ExtensionContext): void {
203
202
  const listeners: Array<{ event: string; fn: Function }> = [];
204
203
 
205
204
  const wireListeners = () => {
206
- const onSubmit = async ({ query, modeInstruction, modeLabel }: any) => {
205
+ const onSubmit = async ({ query }: any) => {
207
206
  if (!session) {
208
207
  bus.emit("agent:error", {
209
208
  message: booting ? "pi is still starting up..." : "pi session not initialized",
@@ -212,12 +211,11 @@ export default function activate(ctx: ExtensionContext): void {
212
211
  return;
213
212
  }
214
213
 
215
- const prompt = modeInstruction ? `${modeInstruction}\n${query}` : query;
216
- bus.emit("agent:query", { query, modeLabel });
214
+ bus.emit("agent:query", { query });
217
215
  bus.emit("agent:processing-start", {});
218
216
 
219
217
  try {
220
- await session.prompt(prompt);
218
+ await session.prompt(query);
221
219
  } catch (err) {
222
220
  bus.emit("agent:error", {
223
221
  message: err instanceof Error ? err.message : String(err),
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Secret guard extension.
3
+ *
4
+ * Redacts sensitive patterns (API keys, tokens, passwords) from tool output
5
+ * — both the streamed terminal display and the content sent back to the LLM.
6
+ *
7
+ * Usage:
8
+ * agent-sh -e ./examples/extensions/secret-guard.ts
9
+ *
10
+ * # Or install permanently:
11
+ * cp examples/extensions/secret-guard.ts ~/.agent-sh/extensions/
12
+ *
13
+ * Configuration (~/.agent-sh/settings.json):
14
+ * {
15
+ * "secret-guard": {
16
+ * "extraPatterns": ["CUSTOM_\\w+=\\S+"],
17
+ * "redactText": "***REDACTED***"
18
+ * }
19
+ * }
20
+ */
21
+ import type { ExtensionContext } from "agent-sh/types";
22
+
23
+ // Common secret patterns — each matches key=value or key: value formats
24
+ const DEFAULT_PATTERNS = [
25
+ // API keys and tokens (generic)
26
+ /(?:api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|secret[_-]?key|private[_-]?key)\s*[=:]\s*\S+/gi,
27
+ // AWS
28
+ /(?:AKIA|ASIA)[A-Z0-9]{16}/g,
29
+ /(?:aws_secret_access_key|aws_session_token)\s*[=:]\s*\S+/gi,
30
+ // Bearer tokens
31
+ /Bearer\s+[A-Za-z0-9\-._~+/]+=*/g,
32
+ // GitHub tokens
33
+ /gh[pousr]_[A-Za-z0-9_]{36,}/g,
34
+ // Anthropic / OpenAI keys
35
+ /sk-(?:ant-)?[A-Za-z0-9\-_]{10,}/g,
36
+ // Generic long hex/base64 secrets (env var assignment)
37
+ /(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY)\s*[=:]\s*\S+/gi,
38
+ // Connection strings with passwords
39
+ /[a-z+]+:\/\/[^:]+:[^@\s]+@/gi,
40
+ ];
41
+
42
+ export default function activate(ctx: ExtensionContext) {
43
+ const { bus } = ctx;
44
+ const config = ctx.getExtensionSettings("secret-guard", {
45
+ extraPatterns: [] as string[],
46
+ redactText: "***REDACTED***",
47
+ });
48
+
49
+ const patterns = [
50
+ ...DEFAULT_PATTERNS,
51
+ ...config.extraPatterns.map((p: string) => new RegExp(p, "gi")),
52
+ ];
53
+
54
+ function redact(text: string): string {
55
+ let result = text;
56
+ for (const pattern of patterns) {
57
+ // Reset lastIndex for stateful regex (global flag)
58
+ pattern.lastIndex = 0;
59
+ result = result.replace(pattern, config.redactText);
60
+ }
61
+ return result;
62
+ }
63
+
64
+ // Redact the dynamic context (shell history, cwd, etc.) before it's sent
65
+ // to the LLM. This is the chokepoint — everything the model sees passes
66
+ // through dynamic-context:build.
67
+ ctx.advise("dynamic-context:build", (next) => {
68
+ return redact(next());
69
+ });
70
+
71
+ // Advise tool:execute to wrap both streaming output and final result.
72
+ // Chunks from child processes arrive at arbitrary byte boundaries, so a
73
+ // secret like "sk-ant-abc123" could be split across two chunks. We
74
+ // line-buffer: accumulate until we see '\n', redact complete lines, flush.
75
+ ctx.advise("tool:execute", async (next, toolCtx) => {
76
+ const origOnChunk = toolCtx.onChunk;
77
+ if (origOnChunk) {
78
+ let buf = "";
79
+ toolCtx.onChunk = (chunk: string) => {
80
+ buf += chunk;
81
+ const lastNl = buf.lastIndexOf("\n");
82
+ if (lastNl !== -1) {
83
+ // Flush all complete lines, redacted
84
+ origOnChunk(redact(buf.slice(0, lastNl + 1)));
85
+ buf = buf.slice(lastNl + 1);
86
+ }
87
+ };
88
+
89
+ const result = await next(toolCtx);
90
+
91
+ // Flush any remaining partial line
92
+ if (buf) origOnChunk(redact(buf));
93
+
94
+ return { ...result, content: redact(result.content) };
95
+ }
96
+
97
+ const result = await next(toolCtx);
98
+ return { ...result, content: redact(result.content) };
99
+ });
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",