@falkenslab/agent-kit 0.25.0 → 0.27.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 (35) hide show
  1. package/dist/chat/extensions.d.ts +14 -0
  2. package/dist/chat/extensions.js +39 -3
  3. package/dist/core/agentSpec.d.ts +33 -0
  4. package/dist/core/claudeAiConnectors.d.ts +35 -0
  5. package/dist/core/claudeAiConnectors.js +76 -0
  6. package/dist/core/extensions.d.ts +7 -0
  7. package/dist/core/externalExtensions.d.ts +30 -0
  8. package/dist/core/externalExtensions.js +37 -0
  9. package/dist/core/hooks/guidedGate.d.ts +57 -0
  10. package/dist/core/hooks/guidedGate.js +97 -0
  11. package/dist/core/hooks/remoteGate.d.ts +22 -0
  12. package/dist/core/hooks/remoteGate.js +35 -0
  13. package/dist/core/hooks/transcriptLogger.d.ts +2 -0
  14. package/dist/core/hooks/transcriptLogger.js +1 -1
  15. package/dist/core/messages/de.js +10 -1
  16. package/dist/core/messages/en.d.ts +22 -0
  17. package/dist/core/messages/en.js +10 -1
  18. package/dist/core/messages/es.js +10 -1
  19. package/dist/core/messages/fr.js +10 -1
  20. package/dist/core/readOnlyHints.d.ts +12 -6
  21. package/dist/core/readOnlyHints.js +14 -8
  22. package/dist/core/runner.js +24 -0
  23. package/dist/core/session.d.ts +5 -0
  24. package/dist/core/session.js +88 -6
  25. package/dist/core/sessionFacts.d.ts +6 -0
  26. package/dist/core/sessionFacts.js +1 -0
  27. package/dist/core/tools/humanApproval.d.ts +4 -2
  28. package/dist/core/tools/humanApproval.js +3 -1
  29. package/dist/extensions/awareness/tools.js +6 -0
  30. package/dist/index.d.ts +3 -2
  31. package/dist/index.js +2 -1
  32. package/dist/tui/extensionCommand.d.ts +1 -1
  33. package/dist/tui/extensionCommand.js +14 -6
  34. package/package.json +1 -1
  35. package/schemas/agent-kit-metadata.schema.json +5 -0
@@ -39,6 +39,20 @@ export type ExtensionAction = {
39
39
  export declare function describeEntry(entry: CatalogEntry): string;
40
40
  /** An installed extension's options, one line each: its value, its default, or whether a sensitive one is set (never its value). */
41
41
  export declare function describeOptions(extension: InstalledExtension): Promise<string[]>;
42
+ /** What guided mode keeps about an extension's tools (#74, #76): whether they ask, and the answers kept. None for one without tools. */
43
+ export declare function describeConfirm(extension: InstalledExtension): string[];
44
+ /**
45
+ * `confirm=on|off|reset` among `config`'s pairs (#74, #76): `off` lets its tools run without asking
46
+ * in guided mode, `on` asks again, `reset` forgets the answers kept. Returns the other pairs and
47
+ * the extension as it is now, or why it can't.
48
+ */
49
+ export declare function applyConfirm(extension: InstalledExtension, pairs: readonly string[]): Promise<{
50
+ rest: string[];
51
+ extension: InstalledExtension;
52
+ changed: boolean;
53
+ } | {
54
+ error: string;
55
+ }>;
42
56
  /**
43
57
  * `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
44
58
  * and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { extensionOptionStates, setExtensionEnabled, setExtensionOptions } from "../core/externalExtensions.js";
2
+ import { extensionOptionStates, readLock, setExtensionConfirm, setExtensionEnabled, setExtensionOptions } from "../core/externalExtensions.js";
3
3
  import { t } from "../core/messages/index.js";
4
4
  import { extensionCatalog } from "../core/extensionCatalog.js";
5
5
  /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
@@ -54,6 +54,31 @@ export async function describeOptions(extension) {
54
54
  return ` ${key} = ${shown} (${option.title}${option.required ? `, ${t().optionRequired}` : ""})`;
55
55
  });
56
56
  }
57
+ /** What guided mode keeps about an extension's tools (#74, #76): whether they ask, and the answers kept. None for one without tools. */
58
+ export function describeConfirm(extension) {
59
+ if (!Object.keys(extension.manifest?.servers ?? {}).length)
60
+ return [];
61
+ const confirm = extension.lock.confirm;
62
+ if (confirm?.off)
63
+ return [t().extensionConfirmOff(extension.name)];
64
+ const kept = Object.entries(confirm?.tools ?? {});
65
+ return [t().extensionConfirmOn(extension.name), ...(kept.length ? kept.map(([tool, answer]) => t().confirmKept(tool, answer)) : [t().confirmNoneKept])];
66
+ }
67
+ /**
68
+ * `confirm=on|off|reset` among `config`'s pairs (#74, #76): `off` lets its tools run without asking
69
+ * in guided mode, `on` asks again, `reset` forgets the answers kept. Returns the other pairs and
70
+ * the extension as it is now, or why it can't.
71
+ */
72
+ export async function applyConfirm(extension, pairs) {
73
+ const rest = pairs.filter((pair) => !pair.startsWith("confirm="));
74
+ const values = pairs.filter((pair) => pair.startsWith("confirm=")).map((pair) => pair.slice("confirm=".length).toLowerCase());
75
+ if (values.some((value) => !["on", "off", "reset"].includes(value)))
76
+ return { error: "confirm takes on, off or reset" };
77
+ for (const value of values)
78
+ await setExtensionConfirm(extension.scopeDir, extension.name, value === "reset" ? { reset: true } : { off: value === "off" });
79
+ const lock = values.length ? (await readLock(extension.scopeDir))[extension.name] : undefined;
80
+ return { rest, extension: lock ? { ...extension, lock } : extension, changed: values.length > 0 };
81
+ }
57
82
  /**
58
83
  * `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
59
84
  * and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
@@ -96,10 +121,16 @@ export async function chatExtensionsCommand(line, status, canReopen) {
96
121
  return { lines: [t().extensionNotInstalled(name)] };
97
122
  if (!extra.length) {
98
123
  const options = await describeOptions(installed);
99
- return { lines: options.length ? [t().extensionOptions(name), ...options] : [t().extensionNoOptions(name)] };
124
+ return { lines: [...(options.length ? [t().extensionOptions(name), ...options] : [t().extensionNoOptions(name)]), ...describeConfirm(installed)] };
100
125
  }
126
+ // Guided mode's answers: the gate reads them on each call, so nothing restarts (#74, #76).
127
+ const confirm = await applyConfirm(installed, extra);
128
+ if ("error" in confirm)
129
+ return { lines: [`(${confirm.error})`] };
130
+ if (!confirm.rest.length)
131
+ return { lines: describeConfirm(confirm.extension) };
101
132
  const typed = {};
102
- for (const pair of extra) {
133
+ for (const pair of confirm.rest) {
103
134
  const at = pair.indexOf("=");
104
135
  const key = at > 0 ? pair.slice(0, at) : pair;
105
136
  if (installed.manifest?.userConfig[key]?.sensitive)
@@ -120,6 +151,11 @@ export async function chatExtensionsCommand(line, status, canReopen) {
120
151
  const lines = [status?.active.length ? t().extensionsRunning(status.active.join(", ")) : t().extensionsNone];
121
152
  if (status)
122
153
  lines.push(...describeCapabilities(status), ...(await needingSignIn(status)).map((extension) => t().extensionNeedsSignIn(extension)));
154
+ // The claude.ai connectors the agent lets in, apart from installed extensions (#70).
155
+ if (status?.connectors?.allowed.length)
156
+ lines.push(t().connectorsAllowed(status.connectors.allowed.map((name) => name.replace(/^claude\.ai /, "")).join(", ")));
157
+ if (status?.connectors?.missing.length)
158
+ lines.push(t().connectorsMissing(status.connectors.missing.join(", ")));
123
159
  for (const { name: off, reason } of status?.inactive ?? [])
124
160
  lines.push(t().extensionOff(off, reason));
125
161
  if (status?.installed.length)
@@ -44,6 +44,22 @@ export interface BaseSessionConfig {
44
44
  * catalog and `extension search` show only those with one of them. Without it, every one.
45
45
  */
46
46
  catalogTopics?: string[];
47
+ /**
48
+ * The claude.ai connectors of the person's account this agent lets in (#70), by the name
49
+ * claude.ai shows them (`["Canva", "Notion"]`, any case): those already signed in on claude.ai,
50
+ * nothing to install. None by default: the kit keeps them out, since they're the account's
51
+ * integrations, not the agent's. When the session opens, the kit asks the account which it has
52
+ * (a few seconds) and lets in only these; one asked for that isn't there is said in `/extensions`.
53
+ */
54
+ claudeAiConnectors?: string[];
55
+ /**
56
+ * What a call to a remote MCP server does (#70): an installed extension's `http`/`sse` server, one
57
+ * of the agent's own, or a claude.ai connector. What the model sends one leaves the computer, so
58
+ * an agent handling sensitive data (students') may keep it from them. `"allow"` (the default)
59
+ * lets it go on; `"ask"` asks the person before each call, saying where it goes (blocked when no
60
+ * one can be asked, as in autonomous mode); `"deny"` blocks it.
61
+ */
62
+ remoteServers?: "allow" | "ask" | "deny";
47
63
  /**
48
64
  * Directories where the agent reads, searches and writes with the file tools (Write/Edit,
49
65
  * scoped — see hooks/fileScopeGate.ts), e.g. a folder of its own notes. Setting one gives the
@@ -170,6 +186,11 @@ export interface AgentSpec<TConfig extends BaseSessionConfig> {
170
186
  approved: string;
171
187
  rejected: string;
172
188
  };
189
+ /**
190
+ * Guided mode's gate for installed extensions' tools that change things (#74, #76, see
191
+ * hooks/guidedGate.ts): one that isn't known to only read waits for the person's yes. On by default.
192
+ */
193
+ extensionGate?: ExtensionGateSpec;
173
194
  /**
174
195
  * Text for the generic manual-intervention checkpoint (see tools/manualLogin.ts), AND
175
196
  * this domain's opt-in signal for offering that checkpoint at all: `buildSessionOptions()`
@@ -193,6 +214,18 @@ export interface AgentSpec<TConfig extends BaseSessionConfig> {
193
214
  */
194
215
  planMode?: PlanModeSpec<TConfig>;
195
216
  }
217
+ /** The agent's word on guided mode's gate for extensions' tools (`AgentSpec.extensionGate`). */
218
+ export interface ExtensionGateSpec {
219
+ /** `false` turns the gate off: extensions' tools run without asking, as the model decides. */
220
+ enabled?: boolean;
221
+ /**
222
+ * Tools that always ask, even inside a batch the person approved, and never offer "always", by
223
+ * extension and short tool name (`{ canva: ["create-design"] }`): added to its manifest's `confirmTools`.
224
+ */
225
+ confirmTools?: Record<string, string[]>;
226
+ /** In autonomous mode, where no one can be asked: `"deny"` (the default) blocks those tools, `"allow"` runs them. */
227
+ autonomous?: "deny" | "allow";
228
+ }
196
229
  /** Who an agent is, as told to its model (`AgentSpec.identity`). */
197
230
  export interface AgentIdentity {
198
231
  /** The agent's name, e.g. "padawan". */
@@ -0,0 +1,35 @@
1
+ import { type Options } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * The claude.ai connectors an agent lets in (#70): the person's account's integrations (Canva,
4
+ * Notion…), which the kit keeps out by default (`disableClaudeAiConnectors`, ADR-018). The SDK
5
+ * lets them in all or none, and lets one out only by its name (`deniedMcpServers`); an allowlist
6
+ * would have to list every other server of the session exactly (confirmed empirically, CLI
7
+ * 2.1.300). So the kit asks the account which it has, when the session opens, and denies by name
8
+ * the ones the agent didn't choose; a gate blocks a call to any other (one added since).
9
+ *
10
+ * `toggleMcpServer()` is never used for them: it saves the switch in the person's own Claude Code
11
+ * config for the folder (`~/.claude.json`, `disabledMcpServers`), not only in the session
12
+ * (confirmed empirically, CLI 2.1.300).
13
+ */
14
+ /** The name the SDK gives a claude.ai connector's server: `claude.ai <Name>`. */
15
+ export declare const CONNECTOR_PREFIX = "claude.ai ";
16
+ /** The prefix of a server's tools as the model names them: `claude.ai Claude Docs` → `mcp__claude_ai_Claude_Docs__`. */
17
+ export declare function serverToolPrefix(server: string): string;
18
+ /** Whether a tool is a claude.ai connector's. */
19
+ export declare function isConnectorTool(tool: string): boolean;
20
+ /** What the agent chose, set against what the account has: the servers to let in, to deny, and the names it asked for that aren't there. */
21
+ export interface ConnectorChoice {
22
+ allowed: string[];
23
+ denied: string[];
24
+ missing: string[];
25
+ }
26
+ /** Chooses among the account's connectors (`found`, as the SDK names them) the ones the agent wants, by the name claude.ai shows (any case). */
27
+ export declare function chooseConnectors(found: readonly string[], wanted: readonly string[]): ConnectorChoice;
28
+ /**
29
+ * The account's claude.ai connectors, by the names the SDK gives them: a session that sends no
30
+ * message, opened in `cwd` with connectors on, its servers' status read and closed. A few seconds;
31
+ * an empty list when it can't tell (no login, no network), so nothing is let in.
32
+ */
33
+ export declare function discoverConnectors(cwd: string, options?: Pick<Options, "pathToClaudeCodeExecutable" | "env"> & {
34
+ timeoutMs?: number;
35
+ }): Promise<string[]>;
@@ -0,0 +1,76 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * The claude.ai connectors an agent lets in (#70): the person's account's integrations (Canva,
4
+ * Notion…), which the kit keeps out by default (`disableClaudeAiConnectors`, ADR-018). The SDK
5
+ * lets them in all or none, and lets one out only by its name (`deniedMcpServers`); an allowlist
6
+ * would have to list every other server of the session exactly (confirmed empirically, CLI
7
+ * 2.1.300). So the kit asks the account which it has, when the session opens, and denies by name
8
+ * the ones the agent didn't choose; a gate blocks a call to any other (one added since).
9
+ *
10
+ * `toggleMcpServer()` is never used for them: it saves the switch in the person's own Claude Code
11
+ * config for the folder (`~/.claude.json`, `disabledMcpServers`), not only in the session
12
+ * (confirmed empirically, CLI 2.1.300).
13
+ */
14
+ /** The name the SDK gives a claude.ai connector's server: `claude.ai <Name>`. */
15
+ export const CONNECTOR_PREFIX = "claude.ai ";
16
+ /** The prefix of a server's tools as the model names them: `claude.ai Claude Docs` → `mcp__claude_ai_Claude_Docs__`. */
17
+ export function serverToolPrefix(server) {
18
+ return `mcp__${server.replace(/[^A-Za-z0-9_-]/g, "_")}__`;
19
+ }
20
+ /** Whether a tool is a claude.ai connector's. */
21
+ export function isConnectorTool(tool) {
22
+ // `claude.ai <Name>` becomes `mcp__claude_ai_<Name>__`: the prefix up to the name.
23
+ return tool.startsWith("mcp__claude_ai_");
24
+ }
25
+ /** Chooses among the account's connectors (`found`, as the SDK names them) the ones the agent wants, by the name claude.ai shows (any case). */
26
+ export function chooseConnectors(found, wanted) {
27
+ const label = (server) => (server.startsWith(CONNECTOR_PREFIX) ? server.slice(CONNECTOR_PREFIX.length) : server).toLowerCase();
28
+ const asked = new Set(wanted.map(label));
29
+ const allowed = found.filter((server) => asked.has(label(server)));
30
+ const present = new Set(allowed.map(label));
31
+ return { allowed, denied: found.filter((server) => !asked.has(label(server))), missing: wanted.filter((name) => !present.has(label(name))) };
32
+ }
33
+ /**
34
+ * The account's claude.ai connectors, by the names the SDK gives them: a session that sends no
35
+ * message, opened in `cwd` with connectors on, its servers' status read and closed. A few seconds;
36
+ * an empty list when it can't tell (no login, no network), so nothing is let in.
37
+ */
38
+ export async function discoverConnectors(cwd, options = {}) {
39
+ let release = () => { };
40
+ const done = new Promise((resolve) => (release = resolve));
41
+ // A conversation that never sends a message, ended when the look is over: the session only starts its servers.
42
+ const prompt = { [Symbol.asyncIterator]: () => ({ next: () => done.then(() => ({ done: true, value: undefined })) }) };
43
+ const session = query({
44
+ prompt,
45
+ options: {
46
+ cwd,
47
+ settingSources: ["project"],
48
+ tools: [],
49
+ settings: { autoMemoryEnabled: false, disableClaudeAiConnectors: false },
50
+ ...(options.pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable } : {}),
51
+ ...(options.env ? { env: options.env } : {}),
52
+ },
53
+ });
54
+ void (async () => {
55
+ for await (const message of session)
56
+ void message;
57
+ })().catch(() => undefined);
58
+ try {
59
+ const connectors = async () => (await session.mcpServerStatus()).filter((server) => server.scope === "claudeai" || server.name.startsWith(CONNECTOR_PREFIX));
60
+ // Connectors come from the account after the session starts: wait until none is pending.
61
+ const deadline = Date.now() + (options.timeoutMs ?? 10_000);
62
+ let found = await connectors().catch(() => []);
63
+ while (Date.now() < deadline && (!found.length || found.some((server) => server.status === "pending"))) {
64
+ await new Promise((resolve) => setTimeout(resolve, 400));
65
+ found = await connectors().catch(() => []);
66
+ }
67
+ return found.map((server) => server.name);
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ finally {
73
+ release();
74
+ session.close();
75
+ }
76
+ }
@@ -64,6 +64,13 @@ export interface ExtensionContribution {
64
64
  * no `readOnlyTools`. A list in the manifest wins: its servers aren't here.
65
65
  */
66
66
  readOnlyHintServers?: string[];
67
+ /**
68
+ * Its MCP tools that always ask the person in guided mode, even inside an approved batch, by full
69
+ * name (#76): an installed extension's manifest's `confirmTools`.
70
+ */
71
+ confirmTools?: string[];
72
+ /** Its MCP servers whose tools that always ask guided mode learns from their `destructiveHint`: an installed extension whose manifest lists no `confirmTools`. */
73
+ confirmHintServers?: string[];
67
74
  /**
68
75
  * Its MCP servers that are remote (#61), by session name: the SDK fixes their URL and headers
69
76
  * when the session opens, so a changed option takes opening it again, and they may need the
@@ -101,6 +101,12 @@ export interface ExternalManifest {
101
101
  * list is the whole answer, even empty.
102
102
  */
103
103
  readOnlyTools?: string[];
104
+ /**
105
+ * Its tools that always ask the person in guided mode (short names), even inside a batch they
106
+ * approved, and never offer "always" (#76): what creates, edits or publishes in their account.
107
+ * Without the key, its servers' `destructiveHint` says which.
108
+ */
109
+ confirmTools?: string[];
104
110
  /** Its tools' chat labels, by short name (of any of its servers) and language (English when the kit's isn't there). */
105
111
  labels: Record<string, Partial<Record<Language, ExternalToolLabel>>>;
106
112
  /** What it says about itself in a session, for the agent to tell the person (`SessionFacts`). */
@@ -140,6 +146,8 @@ export interface LockEntry {
140
146
  options?: Record<string, OptionValue>;
141
147
  /** The npm packages its servers run with npx (#54), by `<package>@<version>`: in the agent's package cache, checked when it loads. */
142
148
  packages?: Record<string, CachedPackage>;
149
+ /** What the person said about its tools in guided mode (#74, #76): kept answers, or the gate off for it. */
150
+ confirm?: ExtensionConfirm;
143
151
  enabled: boolean;
144
152
  /** When it was installed, ISO 8601 in UTC. */
145
153
  installed: string;
@@ -264,6 +272,28 @@ export declare function removeExtension(scopeDir: string, name: string, options?
264
272
  }): Promise<boolean>;
265
273
  /** The size in bytes of the files under `dir` (0 when it doesn't exist). */
266
274
  export declare function folderSize(dir: string): Promise<number>;
275
+ /** A kept answer to guided mode's question about one of an extension's tools (#76). */
276
+ export type ToolConfirmation = "always" | "never";
277
+ /** What the person said about an extension's tools in guided mode (#74, #76), in its lock. */
278
+ export interface ExtensionConfirm {
279
+ /** Its tools run without asking: the person trusts it. */
280
+ off?: boolean;
281
+ /** Their kept answers, by the tool's short name. */
282
+ tools?: Record<string, ToolConfirmation>;
283
+ }
284
+ /**
285
+ * Changes what's kept about an installed extension's tools in guided mode: `off` turns the gate
286
+ * off (or back on) for it; `tool` keeps an answer for one tool (`undefined` asks again);
287
+ * `reset` forgets every kept answer. False when it isn't installed there.
288
+ */
289
+ export declare function setExtensionConfirm(scopeDir: string, name: string, change: {
290
+ off?: boolean;
291
+ tool?: {
292
+ name: string;
293
+ answer: ToolConfirmation | undefined;
294
+ };
295
+ reset?: boolean;
296
+ }): Promise<boolean>;
267
297
  /** Enables or disables an installed extension in a scope; false when it isn't there. */
268
298
  export declare function setExtensionEnabled(scopeDir: string, name: string, enabled: boolean): Promise<boolean>;
269
299
  /**
@@ -140,6 +140,7 @@ export async function readExternalManifest(dir) {
140
140
  requirements: parseRequirements(kit.requirements),
141
141
  servers: await pluginServers(dir, raw),
142
142
  ...(Array.isArray(kit.readOnlyTools) ? { readOnlyTools: kit.readOnlyTools.filter((tool) => typeof tool === "string") } : {}),
143
+ ...(Array.isArray(kit.confirmTools) ? { confirmTools: kit.confirmTools.filter((tool) => typeof tool === "string") } : {}),
143
144
  labels: kit.labels ?? {},
144
145
  ...(kit.help ? { help: kit.help } : {}),
145
146
  userConfig: parseUserConfig(raw.userConfig),
@@ -424,6 +425,10 @@ function externalExtension(installed, manifest, cache, unavailable = []) {
424
425
  ...(manifest.readOnlyTools
425
426
  ? { readOnlyTools: Object.keys(servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)) }
426
427
  : { readOnlyTools: [], readOnlyHintServers: Object.keys(servers) }),
428
+ // The same for what always asks in guided mode (#76): the list, or the servers' destructiveHint.
429
+ ...(manifest.confirmTools
430
+ ? { confirmTools: Object.keys(servers).flatMap((server) => manifest.confirmTools.map((tool) => `mcp__${server}__${tool}`)) }
431
+ : { confirmHintServers: Object.keys(servers) }),
427
432
  toolLabels: manifestToolLabels(manifest, Object.keys(servers)),
428
433
  ...(remote.length ? { remoteServers: remote } : {}),
429
434
  ...(manifest.help ? { helpLines: [manifest.help] } : {}),
@@ -568,6 +573,9 @@ export async function addExtension(source, scopeDir, options = {}) {
568
573
  const kept = Object.fromEntries(Object.entries(lock[manifest.name]?.options ?? {}).filter(([key]) => manifest.userConfig[key] && !manifest.userConfig[key].sensitive));
569
574
  if (Object.keys(kept).length)
570
575
  locked.options = kept;
576
+ // And what the person said about its tools in guided mode (#76): theirs, not the version's.
577
+ if (lock[manifest.name]?.confirm)
578
+ locked.confirm = lock[manifest.name].confirm;
571
579
  await rm(target, { recursive: true, force: true });
572
580
  await mkdir(scopeDir, { recursive: true });
573
581
  const origin = options.origin ?? (isGitSource(source) ? source : from);
@@ -636,6 +644,35 @@ export async function folderSize(dir) {
636
644
  }
637
645
  return size;
638
646
  }
647
+ /**
648
+ * Changes what's kept about an installed extension's tools in guided mode: `off` turns the gate
649
+ * off (or back on) for it; `tool` keeps an answer for one tool (`undefined` asks again);
650
+ * `reset` forgets every kept answer. False when it isn't installed there.
651
+ */
652
+ export async function setExtensionConfirm(scopeDir, name, change) {
653
+ const lock = await readLock(scopeDir);
654
+ const entry = lock[name];
655
+ if (!entry)
656
+ return false;
657
+ const confirm = { ...entry.confirm, tools: change.reset ? {} : { ...entry.confirm?.tools } };
658
+ if (change.off !== undefined)
659
+ confirm.off = change.off;
660
+ if (change.tool) {
661
+ if (change.tool.answer)
662
+ confirm.tools[change.tool.name] = change.tool.answer;
663
+ else
664
+ delete confirm.tools[change.tool.name];
665
+ }
666
+ if (!confirm.off)
667
+ delete confirm.off;
668
+ if (!Object.keys(confirm.tools).length)
669
+ delete confirm.tools;
670
+ lock[name] = { ...entry, ...(Object.keys(confirm).length ? { confirm } : {}) };
671
+ if (!Object.keys(confirm).length)
672
+ delete lock[name].confirm;
673
+ await writeLock(scopeDir, lock);
674
+ return true;
675
+ }
639
676
  /** Enables or disables an installed extension in a scope; false when it isn't there. */
640
677
  export async function setExtensionEnabled(scopeDir, name, enabled) {
641
678
  const lock = await readLock(scopeDir);
@@ -0,0 +1,57 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { Mode } from "../agentSpec.js";
3
+ import type { ToolLabels } from "../toolLabels.js";
4
+ /** An installed extension whose tools the guided gate watches. */
5
+ export interface GuardedExtension {
6
+ name: string;
7
+ /** Its scope's folder: its lock keeps what the person said about its tools. */
8
+ scopeDir: string;
9
+ /** Its MCP servers, by session name. */
10
+ servers: readonly string[];
11
+ /** Its tools that always ask (full names): its manifest's `confirmTools` and the agent's own list. */
12
+ confirmTools: readonly string[];
13
+ }
14
+ /** One answer the gate gave, for the transcript: what was asked, and what the person (or the mode) said. */
15
+ export interface GuidedDecision {
16
+ extension: string;
17
+ tool: string;
18
+ answer: "yes" | "session" | "always" | "no" | "never" | "kept-never" | "autonomous-deny";
19
+ }
20
+ /** The guided gate: its `PreToolUse` hook, and what ends or starts an approved batch. */
21
+ export interface GuidedGate {
22
+ preToolUse: HookCallback;
23
+ /** A `UserPromptSubmit` hook: the person's next message ends an approved batch. */
24
+ userPromptSubmit: HookCallback;
25
+ /** `request_human_approval`'s answer: approved, the calls that follow don't ask until the person's next message. */
26
+ approvalAnswered(approved: boolean): void;
27
+ }
28
+ /**
29
+ * Guided mode's gate for installed extensions' tools that change things (#74, #76): ADR-025 treats
30
+ * every extension tool as publishing unless it's known to only read, and in guided mode the person
31
+ * says yes before one runs, whatever the model did first. The step gate covers interactive mode
32
+ * (it asks before everything) and the plan gate plan mode (it denies), so this one decides only in
33
+ * guided and autonomous mode, where no one can be asked and `autonomous` says what happens.
34
+ *
35
+ * Read-only tools (the manifest's `readOnlyTools`, else the server's `readOnlyHint`) never ask.
36
+ * The rest ask through the interaction port, with answers the person can make last: this call,
37
+ * this session, always (kept in the extension's lock), no, never (kept too). Right after
38
+ * `request_human_approval` is approved, calls go on until the person's next message (a batch),
39
+ * except those that always ask: the agent's or the manifest's `confirmTools`, else the server's
40
+ * `destructiveHint`, which aren't offered "always" either. An extension whose lock says `off`
41
+ * runs without asking. Approving gives no decision of its own, so the other gates still judge.
42
+ */
43
+ export declare function createGuidedGate(options: {
44
+ runDir: string;
45
+ mode: () => Mode;
46
+ extensions: readonly GuardedExtension[];
47
+ /** Whether a tool only reads: the extensions' `readOnlyTools`, else their servers' hints. */
48
+ isReadOnly(tool: string): Promise<boolean>;
49
+ /** Whether a tool's server marks it `destructiveHint`, for those whose manifest lists no `confirmTools`. */
50
+ isDestructive(tool: string): Promise<boolean>;
51
+ /** In autonomous mode, where no one can be asked: denied (the default) or run. */
52
+ autonomous?: "deny" | "allow";
53
+ /** The tools' labels in the session's language, to say what's about to happen. */
54
+ labels?: () => ToolLabels;
55
+ /** Each answer, for the transcript. */
56
+ record?(decision: GuidedDecision): Promise<void>;
57
+ }): GuidedGate;
@@ -0,0 +1,97 @@
1
+ import { askForChoice } from "./humanInput.js";
2
+ import { readLock, setExtensionConfirm } from "../externalExtensions.js";
3
+ import { t } from "../messages/index.js";
4
+ /** The kit's approval tool: asking it ends a batch, and its yes starts one. */
5
+ const APPROVAL_TOOL = "mcp__approvals__request_human_approval";
6
+ /**
7
+ * Guided mode's gate for installed extensions' tools that change things (#74, #76): ADR-025 treats
8
+ * every extension tool as publishing unless it's known to only read, and in guided mode the person
9
+ * says yes before one runs, whatever the model did first. The step gate covers interactive mode
10
+ * (it asks before everything) and the plan gate plan mode (it denies), so this one decides only in
11
+ * guided and autonomous mode, where no one can be asked and `autonomous` says what happens.
12
+ *
13
+ * Read-only tools (the manifest's `readOnlyTools`, else the server's `readOnlyHint`) never ask.
14
+ * The rest ask through the interaction port, with answers the person can make last: this call,
15
+ * this session, always (kept in the extension's lock), no, never (kept too). Right after
16
+ * `request_human_approval` is approved, calls go on until the person's next message (a batch),
17
+ * except those that always ask: the agent's or the manifest's `confirmTools`, else the server's
18
+ * `destructiveHint`, which aren't offered "always" either. An extension whose lock says `off`
19
+ * runs without asking. Approving gives no decision of its own, so the other gates still judge.
20
+ */
21
+ export function createGuidedGate(options) {
22
+ const thisSession = new Set();
23
+ let batch = false;
24
+ const deny = (input, reason) => ({ hookSpecificOutput: { hookEventName: input.hook_event_name, permissionDecision: "deny", permissionDecisionReason: reason } });
25
+ const preToolUse = async (input) => {
26
+ const pre = input;
27
+ const full = pre.tool_name;
28
+ // A new approval request ends the batch the last one started, whatever its answer.
29
+ if (full === APPROVAL_TOOL)
30
+ batch = false;
31
+ const mode = options.mode();
32
+ if (mode !== "guided" && mode !== "autonomous")
33
+ return {};
34
+ const server = options.extensions.flatMap((extension) => extension.servers.map((name) => ({ extension, prefix: `mcp__${name}__` }))).find(({ prefix }) => full.startsWith(prefix));
35
+ if (!server)
36
+ return {};
37
+ const { extension } = server;
38
+ const tool = full.slice(server.prefix.length);
39
+ if (await options.isReadOnly(full))
40
+ return {};
41
+ const confirm = (await readLock(extension.scopeDir).catch(() => ({})))[extension.name]?.confirm;
42
+ if (confirm?.off)
43
+ return {};
44
+ const record = async (answer) => await options.record?.({ extension: extension.name, tool: full, answer }).catch(() => { });
45
+ const kept = confirm?.tools?.[tool];
46
+ if (kept === "never") {
47
+ await record("kept-never");
48
+ return deny(pre, `The person never lets ${extension.name} run ${tool} (they said so before). Don't call it again: do the task another way, or tell them it needs it.`);
49
+ }
50
+ const alwaysAsks = extension.confirmTools.includes(full) || (await options.isDestructive(full));
51
+ if (kept === "always" && !alwaysAsks)
52
+ return {};
53
+ if (thisSession.has(full))
54
+ return {};
55
+ if (mode === "autonomous") {
56
+ if (options.autonomous === "allow")
57
+ return {};
58
+ await record("autonomous-deny");
59
+ return deny(pre, `Autonomous mode: no one can approve ${extension.name}'s ${tool}, which changes things, so it isn't run. Do what you can without it, and say it needs a person to approve it.`);
60
+ }
61
+ if (batch && !alwaysAsks)
62
+ return {};
63
+ const answers = t().confirmAnswers;
64
+ const choices = [answers.yes, answers.session, ...(alwaysAsks ? [] : [answers.always]), answers.no, answers.never];
65
+ const label = options.labels?.()[full]?.label((pre.tool_input ?? {}));
66
+ const answer = await askForChoice(options.runDir, {
67
+ title: t().guidedTitle(extension.name),
68
+ lines: [...(label ? [label] : []), t().toolLine(full), t().parametersLine(JSON.stringify(pre.tool_input, null, 2))],
69
+ }, choices);
70
+ const picked = answer.chosen[0];
71
+ if (picked === answers.yes || picked === answers.session || picked === answers.always) {
72
+ if (picked === answers.session)
73
+ thisSession.add(full);
74
+ if (picked === answers.always)
75
+ await setExtensionConfirm(extension.scopeDir, extension.name, { tool: { name: tool, answer: "always" } });
76
+ await record(picked === answers.yes ? "yes" : picked === answers.session ? "session" : "always");
77
+ return {};
78
+ }
79
+ if (picked === answers.never) {
80
+ await setExtensionConfirm(extension.scopeDir, extension.name, { tool: { name: tool, answer: "never" } });
81
+ await record("never");
82
+ return deny(pre, `The person never lets ${extension.name} run ${tool}. Don't call it again: do the task another way, or tell them it needs it.`);
83
+ }
84
+ await record("no");
85
+ return deny(pre, `The person didn't approve this call${answer.other ? `. They said: ${answer.other}` : ""}. Don't retry it without asking them first.`);
86
+ };
87
+ return {
88
+ preToolUse,
89
+ userPromptSubmit: async () => {
90
+ batch = false;
91
+ return {};
92
+ },
93
+ approvalAnswered(approved) {
94
+ batch = approved;
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,22 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ /** What a call to a remote MCP server does (#70): goes on, asks the person first, or is blocked. */
3
+ export type RemoteServersPolicy = "allow" | "ask" | "deny";
4
+ /**
5
+ * PreToolUse hook for remote MCP servers (#70): what the model sends one leaves the computer.
6
+ * - A claude.ai connector's tool the agent didn't choose is always blocked: a connector the
7
+ * account gained after the session opened, which the session's denylist doesn't name.
8
+ * - A remote server's tool (an installed extension's, the agent's own, a chosen connector) goes
9
+ * on with `allow`; with `ask`, the person approves each call, told where it goes (blocked when
10
+ * no one can be asked); with `deny`, it's blocked.
11
+ * Approving gives no decision of its own, so the other gates (plan mode, the file scope) still judge.
12
+ */
13
+ export declare function createRemoteGate(options: {
14
+ runDir: string;
15
+ policy: RemoteServersPolicy;
16
+ /** Where a tool's call goes (a host, a connector), or nothing when it isn't remote. */
17
+ destination: (tool: string) => string | undefined;
18
+ /** The chosen connectors' tool prefixes (`serverToolPrefix()`). */
19
+ allowedConnectors: readonly string[];
20
+ /** Whether a person can be asked now (not autonomous). */
21
+ canAsk: () => boolean;
22
+ }): HookCallback;
@@ -0,0 +1,35 @@
1
+ import { askForDecision } from "./humanInput.js";
2
+ import { isConnectorTool } from "../claudeAiConnectors.js";
3
+ import { t } from "../messages/index.js";
4
+ /**
5
+ * PreToolUse hook for remote MCP servers (#70): what the model sends one leaves the computer.
6
+ * - A claude.ai connector's tool the agent didn't choose is always blocked: a connector the
7
+ * account gained after the session opened, which the session's denylist doesn't name.
8
+ * - A remote server's tool (an installed extension's, the agent's own, a chosen connector) goes
9
+ * on with `allow`; with `ask`, the person approves each call, told where it goes (blocked when
10
+ * no one can be asked); with `deny`, it's blocked.
11
+ * Approving gives no decision of its own, so the other gates (plan mode, the file scope) still judge.
12
+ */
13
+ export function createRemoteGate(options) {
14
+ const deny = (input, reason) => ({ hookSpecificOutput: { hookEventName: input.hook_event_name, permissionDecision: "deny", permissionDecisionReason: reason } });
15
+ return async (input) => {
16
+ const pre = input;
17
+ const tool = pre.tool_name;
18
+ if (isConnectorTool(tool) && !options.allowedConnectors.some((prefix) => tool.startsWith(prefix))) {
19
+ return deny(pre, "This agent doesn't use that claude.ai connector: it isn't one of the connectors it lets in. Don't call it again.");
20
+ }
21
+ const destination = options.destination(tool);
22
+ if (!destination || options.policy === "allow")
23
+ return {};
24
+ if (options.policy === "deny" || !options.canAsk()) {
25
+ return deny(pre, `This agent sends nothing to remote servers here: calling ${destination} isn't allowed. Do the task without it, or tell the person it needs that service.`);
26
+ }
27
+ const answer = await askForDecision(options.runDir, {
28
+ title: t().remoteCallTitle(destination),
29
+ lines: [t().toolLine(tool), t().parametersLine(JSON.stringify(pre.tool_input, null, 2))],
30
+ });
31
+ if (answer === "" || answer === "y" || answer === "yes")
32
+ return {};
33
+ return deny(pre, `The person didn't let this call go to ${destination}.`);
34
+ };
35
+ }
@@ -3,6 +3,8 @@ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
3
3
  export interface TranscriptLogger {
4
4
  preToolUse: HookCallback;
5
5
  postToolUse: HookCallback;
6
+ /** Another event the kit keeps in it (a gate's answer, #74): one line, with its time. */
7
+ note(event: string, entry: Record<string, unknown>): Promise<void>;
6
8
  }
7
9
  /**
8
10
  * Logs every tool call (request + result) as one JSON line, redacting `secrets` (e.g. a
@@ -39,7 +39,7 @@ export function createTranscriptLogger(transcriptPath, secrets = []) {
39
39
  });
40
40
  return {};
41
41
  };
42
- return { preToolUse, postToolUse };
42
+ return { preToolUse, postToolUse, note: async (event, entry) => await write({ ts: new Date().toISOString(), event, ...entry }) };
43
43
  }
44
44
  /** Avoids bloating the transcript with large payloads (browser-tool screenshots/base64). */
45
45
  export function summarizeToolResponse(response) {