@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.
- package/dist/chat/extensions.d.ts +14 -0
- package/dist/chat/extensions.js +39 -3
- package/dist/core/agentSpec.d.ts +33 -0
- package/dist/core/claudeAiConnectors.d.ts +35 -0
- package/dist/core/claudeAiConnectors.js +76 -0
- package/dist/core/extensions.d.ts +7 -0
- package/dist/core/externalExtensions.d.ts +30 -0
- package/dist/core/externalExtensions.js +37 -0
- package/dist/core/hooks/guidedGate.d.ts +57 -0
- package/dist/core/hooks/guidedGate.js +97 -0
- package/dist/core/hooks/remoteGate.d.ts +22 -0
- package/dist/core/hooks/remoteGate.js +35 -0
- package/dist/core/hooks/transcriptLogger.d.ts +2 -0
- package/dist/core/hooks/transcriptLogger.js +1 -1
- package/dist/core/messages/de.js +10 -1
- package/dist/core/messages/en.d.ts +22 -0
- package/dist/core/messages/en.js +10 -1
- package/dist/core/messages/es.js +10 -1
- package/dist/core/messages/fr.js +10 -1
- package/dist/core/readOnlyHints.d.ts +12 -6
- package/dist/core/readOnlyHints.js +14 -8
- package/dist/core/runner.js +24 -0
- package/dist/core/session.d.ts +5 -0
- package/dist/core/session.js +88 -6
- package/dist/core/sessionFacts.d.ts +6 -0
- package/dist/core/sessionFacts.js +1 -0
- package/dist/core/tools/humanApproval.d.ts +4 -2
- package/dist/core/tools/humanApproval.js +3 -1
- package/dist/extensions/awareness/tools.js +6 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/tui/extensionCommand.d.ts +1 -1
- package/dist/tui/extensionCommand.js +14 -6
- package/package.json +1 -1
- 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
|
package/dist/chat/extensions.js
CHANGED
|
@@ -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
|
|
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)
|
package/dist/core/agentSpec.d.ts
CHANGED
|
@@ -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) {
|