@hyperdrive.bot/paseo-server 0.3.39 → 0.3.41
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/README.md +3 -3
- package/dist/server/server/agent/agent-manager.js +15 -0
- package/dist/server/server/agent/agent-projections.js +3 -0
- package/dist/server/server/agent/agent-sdk-types.d.ts +23 -0
- package/dist/server/server/agent/agent-storage.d.ts +2 -1
- package/dist/server/server/agent/agent-storage.js +4 -0
- package/dist/server/server/agent/mcp-shared.js +5 -2
- package/dist/server/server/agent/providers/claude/agent.d.ts +34 -0
- package/dist/server/server/agent/providers/claude/agent.js +74 -0
- package/dist/server/server/agent/providers/claude/pty-session-launcher.d.ts +7 -0
- package/dist/server/server/agent/providers/claude/pty-session-launcher.js +6 -0
- package/dist/server/server/agent/providers/claude/tool-allowlist-guard.d.ts +41 -0
- package/dist/server/server/agent/providers/claude/tool-allowlist-guard.js +93 -0
- package/dist/server/server/agent/providers/claude/tool-allowlist.d.ts +68 -0
- package/dist/server/server/agent/providers/claude/tool-allowlist.js +133 -0
- package/dist/server/server/agent/providers/claude/transport/pty-query.d.ts +33 -1
- package/dist/server/server/agent/providers/claude/transport/pty-query.js +110 -5
- package/dist/server/server/agent/providers/claude/transport/pty.d.ts +32 -0
- package/dist/server/server/agent/providers/claude/transport/pty.js +69 -5
- package/dist/server/server/agent/providers/claude/transport/sdk.d.ts +2 -0
- package/dist/server/server/agent/providers/claude/transport/sdk.js +4 -0
- package/dist/server/server/agent/providers/claude/transport/types.d.ts +8 -0
- package/dist/server/server/agent/tools/paseo-tools.d.ts +19 -0
- package/dist/server/server/agent/tools/paseo-tools.js +213 -38
- package/dist/server/server/agent/tools/read-only-surface.d.ts +1 -0
- package/dist/server/server/agent/tools/read-only-surface.js +1 -0
- package/dist/server/server/persistence-hooks.js +2 -0
- package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.d.ts +13 -1
- package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.js +27 -3
- package/dist/server/server/session.js +6 -2
- package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js → index-cb251ddad56c08c3021036af3a43fc0f.js} +5 -5
- package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js.map.br → index-cb251ddad56c08c3021036af3a43fc0f.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js.map.gz → index-cb251ddad56c08c3021036af3a43fc0f.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-46b675f5daddb88c514f0eee26695f71.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-46b675f5daddb88c514f0eee26695f71.js.gz +0 -0
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ npm run dev
|
|
|
24
24
|
- **Vite Dev Server** (port 5173) - Hot-reload React UI in development
|
|
25
25
|
- **WebSocket** (`/ws`) - Real-time bidirectional communication
|
|
26
26
|
- **Agent** - STT → LLM → TTS pipeline with terminal control
|
|
27
|
-
- **Daemon** -
|
|
27
|
+
- **Daemon** - node-pty terminal management (in-process)
|
|
28
28
|
|
|
29
29
|
## Development
|
|
30
30
|
|
|
@@ -57,7 +57,7 @@ npm start
|
|
|
57
57
|
|
|
58
58
|
**⏳ In Progress** (Phase 3):
|
|
59
59
|
|
|
60
|
-
- Terminal control (
|
|
60
|
+
- Terminal control (node-pty integration)
|
|
61
61
|
|
|
62
62
|
**📋 Planned** (Phases 4-9):
|
|
63
63
|
|
|
@@ -92,7 +92,7 @@ PASEO_HOME=~/.paseo-blue PASEO_LISTEN=127.0.0.1:7777 npm run dev
|
|
|
92
92
|
|
|
93
93
|
- **Server**: Express, TypeScript, ws (WebSocket)
|
|
94
94
|
- **Client**: React 18, Vite, TypeScript
|
|
95
|
-
- **Terminal**:
|
|
95
|
+
- **Terminal**: node-pty (in-process PTY) + @xterm/headless (screen model)
|
|
96
96
|
- **AI**: OpenAI (LLM + TTS), Deepgram (STT)
|
|
97
97
|
|
|
98
98
|
## Testing
|
|
@@ -59,6 +59,20 @@ function buildStoredAgentConfig(record) {
|
|
|
59
59
|
return stripInternalPaseoMcpServer(config);
|
|
60
60
|
}
|
|
61
61
|
export { AGENT_LIFECYCLE_STATUSES };
|
|
62
|
+
/**
|
|
63
|
+
* Reject a create call that asks for a tool allowlist the chosen provider cannot
|
|
64
|
+
* enforce. Silently dropping it would hand the caller a guarantee that does not
|
|
65
|
+
* exist, so the failure is loud and names the provider.
|
|
66
|
+
*/
|
|
67
|
+
function requireToolAllowlistSupport(client, config) {
|
|
68
|
+
if (!config.allowedTools || config.allowedTools.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
if (client.supportsToolAllowlist === true)
|
|
71
|
+
return;
|
|
72
|
+
throw new Error(`Provider '${config.provider}' cannot enforce a tool allowlist (allowedTools). ` +
|
|
73
|
+
"Only the claude provider (and providers derived from it) supports runtime tool " +
|
|
74
|
+
"enforcement. Re-run without --allowed-tools, or use a claude-based provider.");
|
|
75
|
+
}
|
|
62
76
|
function resolveInitialAttention(input) {
|
|
63
77
|
if (input == null || !input.requiresAttention) {
|
|
64
78
|
return { requiresAttention: false };
|
|
@@ -541,6 +555,7 @@ export class AgentManager {
|
|
|
541
555
|
const client = await this.requireAvailableClient({
|
|
542
556
|
provider: storedConfig.provider,
|
|
543
557
|
});
|
|
558
|
+
requireToolAllowlistSupport(client, storedConfig);
|
|
544
559
|
const launchContext = await this.buildLaunchContext(resolvedAgentId, client, options?.env);
|
|
545
560
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
|
546
561
|
const createOptions = this.buildCreateSessionOptions(options);
|
|
@@ -263,6 +263,9 @@ function buildSerializableConfig(config) {
|
|
|
263
263
|
if (config.mcpServers) {
|
|
264
264
|
serializable.mcpServers = config.mcpServers;
|
|
265
265
|
}
|
|
266
|
+
if (config.allowedTools?.length) {
|
|
267
|
+
serializable.allowedTools = [...config.allowedTools];
|
|
268
|
+
}
|
|
266
269
|
return Object.keys(serializable).length ? serializable : null;
|
|
267
270
|
}
|
|
268
271
|
function sanitizePendingPermissions(pending) {
|
|
@@ -521,6 +521,17 @@ export interface AgentSessionConfig {
|
|
|
521
521
|
claude?: Partial<ClaudeAgentOptions>;
|
|
522
522
|
};
|
|
523
523
|
mcpServers?: Record<string, McpServerConfig>;
|
|
524
|
+
/**
|
|
525
|
+
* Per-run tool allowlist in Claude Code permission-rule syntax, e.g.
|
|
526
|
+
* ["Read", "Bash(git:*)", "mcp__playwright__browser_navigate"].
|
|
527
|
+
*
|
|
528
|
+
* When present, a tool absent from the list is blocked BEFORE it executes and
|
|
529
|
+
* the model is told why. Undefined or empty means no allowlist. Enforced only
|
|
530
|
+
* by clients that set `AgentClient.supportsToolAllowlist`; a create call that
|
|
531
|
+
* asks any other provider for an allowlist is rejected rather than run
|
|
532
|
+
* unenforced.
|
|
533
|
+
*/
|
|
534
|
+
allowedTools?: string[];
|
|
524
535
|
/**
|
|
525
536
|
* Internal agents are hidden from listings and don't trigger notifications.
|
|
526
537
|
* They are used for ephemeral system tasks like commit/PR generation.
|
|
@@ -673,6 +684,18 @@ export interface ProviderCatalog {
|
|
|
673
684
|
export interface AgentClient {
|
|
674
685
|
readonly provider: AgentProvider;
|
|
675
686
|
readonly capabilities: AgentCapabilityFlags;
|
|
687
|
+
/**
|
|
688
|
+
* True when this client can ENFORCE `AgentSessionConfig.allowedTools` at
|
|
689
|
+
* runtime (block a tool outside the list before it executes), not merely
|
|
690
|
+
* accept the field.
|
|
691
|
+
*
|
|
692
|
+
* Absent/false means the create call is rejected when an allowlist is set. A
|
|
693
|
+
* tool allowlist that is quietly dropped is worse than no allowlist: the
|
|
694
|
+
* caller believes a restriction is in force that is not, so this fails loudly
|
|
695
|
+
* instead. Derived providers (`pool` extends `claude`, and so on) reuse the
|
|
696
|
+
* base client class and therefore inherit the correct answer.
|
|
697
|
+
*/
|
|
698
|
+
readonly supportsToolAllowlist?: boolean;
|
|
676
699
|
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext, options?: AgentCreateSessionOptions): Promise<AgentSession>;
|
|
677
700
|
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
|
678
701
|
/**
|
|
@@ -62,6 +62,7 @@ declare const STORED_AGENT_SCHEMA: z.ZodObject<{
|
|
|
62
62
|
extra: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodAny>>>;
|
|
63
63
|
systemPrompt: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
64
64
|
mcpServers: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodAny>>>;
|
|
65
|
+
allowedTools: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
|
|
65
66
|
}, z.core.$strip>>>;
|
|
66
67
|
runtimeInfo: z.ZodOptional<z.ZodObject<{
|
|
67
68
|
provider: z.ZodString;
|
|
@@ -112,7 +113,7 @@ declare const STORED_AGENT_SCHEMA: z.ZodObject<{
|
|
|
112
113
|
internal: z.ZodOptional<z.ZodBoolean>;
|
|
113
114
|
archivedAt: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
114
115
|
}, z.core.$strip>;
|
|
115
|
-
export type SerializableAgentConfig = Pick<AgentSessionConfig, "modeId" | "model" | "thinkingOptionId" | "featureValues" | "extra" | "systemPrompt" | "mcpServers">;
|
|
116
|
+
export type SerializableAgentConfig = Pick<AgentSessionConfig, "modeId" | "model" | "thinkingOptionId" | "featureValues" | "extra" | "systemPrompt" | "mcpServers" | "allowedTools">;
|
|
116
117
|
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
|
117
118
|
export declare function parseStoredAgentRecord(value: unknown): StoredAgentRecord;
|
|
118
119
|
export declare class AgentStorage {
|
|
@@ -13,6 +13,10 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
|
|
|
13
13
|
extra: z.record(z.string(), z.any()).nullable().optional(),
|
|
14
14
|
systemPrompt: z.string().nullable().optional(),
|
|
15
15
|
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
|
|
16
|
+
// Persisted so a daemon restart or session resume rebuilds the SAME
|
|
17
|
+
// restriction. An allowlist that silently evaporates on resume is worse than
|
|
18
|
+
// none: the operator still believes the run is fenced.
|
|
19
|
+
allowedTools: z.array(z.string()).nullable().optional(),
|
|
16
20
|
})
|
|
17
21
|
.nullable()
|
|
18
22
|
.optional();
|
|
@@ -168,7 +168,7 @@ export function parseDurationString(input) {
|
|
|
168
168
|
}
|
|
169
169
|
let totalMs = 0;
|
|
170
170
|
let hasMatch = false;
|
|
171
|
-
const regex = /(\d+)([
|
|
171
|
+
const regex = /(\d+)([smhd])/g;
|
|
172
172
|
let match;
|
|
173
173
|
while ((match = regex.exec(trimmed)) !== null) {
|
|
174
174
|
hasMatch = true;
|
|
@@ -183,10 +183,13 @@ export function parseDurationString(input) {
|
|
|
183
183
|
case "h":
|
|
184
184
|
totalMs += value * 60 * 60 * 1000;
|
|
185
185
|
break;
|
|
186
|
+
case "d":
|
|
187
|
+
totalMs += value * 24 * 60 * 60 * 1000;
|
|
188
|
+
break;
|
|
186
189
|
}
|
|
187
190
|
}
|
|
188
191
|
if (!hasMatch) {
|
|
189
|
-
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
|
|
192
|
+
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m, 1d`);
|
|
190
193
|
}
|
|
191
194
|
return totalMs;
|
|
192
195
|
}
|
|
@@ -50,6 +50,7 @@ export declare function readEventIdentifiers(message: SDKMessage): EventIdentifi
|
|
|
50
50
|
export declare class ClaudeAgentClient implements AgentClient {
|
|
51
51
|
readonly provider: "claude";
|
|
52
52
|
readonly capabilities: AgentCapabilityFlags;
|
|
53
|
+
readonly supportsToolAllowlist = true;
|
|
53
54
|
private readonly defaults?;
|
|
54
55
|
private readonly logger;
|
|
55
56
|
private readonly runtimeSettings?;
|
|
@@ -133,6 +134,13 @@ export declare class ClaudeAgentSession implements AgentSession {
|
|
|
133
134
|
private readonly emittedUserMessageIds;
|
|
134
135
|
private readonly rewindTurnAnchors;
|
|
135
136
|
private pendingFreshSessionId;
|
|
137
|
+
/**
|
|
138
|
+
* Parsed per-run tool allowlist. Empty array = no allowlist configured, which
|
|
139
|
+
* is the historical behaviour (everything the permission mode permits runs).
|
|
140
|
+
*/
|
|
141
|
+
private readonly toolAllowRules;
|
|
142
|
+
/** PTY-transport guard files for the current session; removed on teardown. */
|
|
143
|
+
private toolAllowGuard;
|
|
136
144
|
private cumulativeImageBase64Bytes;
|
|
137
145
|
private cumulativeImageWarnEmitted;
|
|
138
146
|
private recentStderr;
|
|
@@ -240,6 +248,32 @@ export declare class ClaudeAgentSession implements AgentSession {
|
|
|
240
248
|
private buildAppendedSystemPrompt;
|
|
241
249
|
private buildSdkEnv;
|
|
242
250
|
private buildOptions;
|
|
251
|
+
/**
|
|
252
|
+
* Install the per-run tool allowlist as a `PreToolUse` hook on the SDK options.
|
|
253
|
+
*
|
|
254
|
+
* Deliberately NOT `options.allowedTools`: that field is a permission ALLOW rule
|
|
255
|
+
* (pre-approve, do not prompt), and this session also sets
|
|
256
|
+
* allowDangerouslySkipPermissions, under which permission rules are moot. A
|
|
257
|
+
* PreToolUse hook is the only gate that still fires; the SDK says so outright
|
|
258
|
+
* ("PreToolUse hook denies bypass canUseTool"). Applied last so an
|
|
259
|
+
* `extra.claude.hooks` override cannot displace it.
|
|
260
|
+
*/
|
|
261
|
+
private applyToolAllowlistHook;
|
|
262
|
+
/**
|
|
263
|
+
* PTY-transport half of allowlist enforcement: (re)generate the hook script and
|
|
264
|
+
* the `--settings` file that registers it.
|
|
265
|
+
*
|
|
266
|
+
* Returns a spreadable fragment (`{}` when no allowlist is configured) so the
|
|
267
|
+
* caller stays branch-free. Regenerated per spawn because a session can restart
|
|
268
|
+
* (mode swap, model swap, rewind) and the artifacts are keyed to the session id.
|
|
269
|
+
*/
|
|
270
|
+
private refreshToolAllowGuard;
|
|
271
|
+
/**
|
|
272
|
+
* SDK-transport half of allowlist enforcement. The PTY transport enforces the
|
|
273
|
+
* same rules through a generated hook script (see tool-allowlist-guard.ts);
|
|
274
|
+
* both call `isToolAllowed`, so the two transports cannot diverge.
|
|
275
|
+
*/
|
|
276
|
+
private enforceToolAllowlistHook;
|
|
243
277
|
private buildSettingsOptions;
|
|
244
278
|
private resolveFastModeSetting;
|
|
245
279
|
private normalizeMcpServers;
|
|
@@ -24,7 +24,10 @@ import { normalizeProviderReplayTimestamp } from "../../provider-history-timesta
|
|
|
24
24
|
import { composeSystemPromptParts } from "../../system-prompt.js";
|
|
25
25
|
import { SdkTransport } from "./transport/sdk.js";
|
|
26
26
|
import { PtyTransport } from "./transport/pty.js";
|
|
27
|
+
import { resolvePaseoHome } from "../../../paseo-home.js";
|
|
27
28
|
import { createPtySession } from "./pty-session-launcher.js";
|
|
29
|
+
import { formatDenialMessage, isToolAllowed, parseToolAllowlist, } from "./tool-allowlist.js";
|
|
30
|
+
import { removeGuardArtifacts, writeGuardArtifacts, } from "./tool-allowlist-guard.js";
|
|
28
31
|
import { trackTransportSpawned } from "../../../monitoring/telemetry.js";
|
|
29
32
|
import { isProviderImageMarkdown, materializeProviderImage, renderProviderImageOutputAsAssistantMarkdown, } from "../provider-image-output.js";
|
|
30
33
|
import { getAgentStreamEventTurnId, } from "../../agent-sdk-types.js";
|
|
@@ -1111,6 +1114,9 @@ export class ClaudeAgentClient {
|
|
|
1111
1114
|
constructor(options) {
|
|
1112
1115
|
this.provider = "claude";
|
|
1113
1116
|
this.capabilities = CLAUDE_CAPABILITIES;
|
|
1117
|
+
// Enforced through a PreToolUse hook on both transports (SDK: options.hooks;
|
|
1118
|
+
// PTY: a generated hook script registered via `claude --settings`).
|
|
1119
|
+
this.supportsToolAllowlist = true;
|
|
1114
1120
|
this.defaults = options.defaults;
|
|
1115
1121
|
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
|
1116
1122
|
this.runtimeSettings = options.runtimeSettings;
|
|
@@ -1575,11 +1581,34 @@ export class ClaudeAgentSession {
|
|
|
1575
1581
|
this.emittedUserMessageIds = new Set();
|
|
1576
1582
|
this.rewindTurnAnchors = [];
|
|
1577
1583
|
this.pendingFreshSessionId = null;
|
|
1584
|
+
/** PTY-transport guard files for the current session; removed on teardown. */
|
|
1585
|
+
this.toolAllowGuard = null;
|
|
1578
1586
|
this.cumulativeImageBase64Bytes = 0;
|
|
1579
1587
|
this.cumulativeImageWarnEmitted = false;
|
|
1580
1588
|
this.recentStderr = "";
|
|
1581
1589
|
this.closed = false;
|
|
1582
1590
|
this.hookEventHandlers = new Map();
|
|
1591
|
+
/**
|
|
1592
|
+
* SDK-transport half of allowlist enforcement. The PTY transport enforces the
|
|
1593
|
+
* same rules through a generated hook script (see tool-allowlist-guard.ts);
|
|
1594
|
+
* both call `isToolAllowed`, so the two transports cannot diverge.
|
|
1595
|
+
*/
|
|
1596
|
+
this.enforceToolAllowlistHook = async (input) => {
|
|
1597
|
+
if (input.hook_event_name !== "PreToolUse")
|
|
1598
|
+
return {};
|
|
1599
|
+
const toolName = input.tool_name;
|
|
1600
|
+
if (isToolAllowed(toolName, input.tool_input, this.toolAllowRules))
|
|
1601
|
+
return {};
|
|
1602
|
+
const reason = formatDenialMessage(toolName, this.toolAllowRules);
|
|
1603
|
+
this.logger.warn({ toolName }, "tool call blocked by run tool allowlist");
|
|
1604
|
+
return {
|
|
1605
|
+
hookSpecificOutput: {
|
|
1606
|
+
hookEventName: "PreToolUse",
|
|
1607
|
+
permissionDecision: "deny",
|
|
1608
|
+
permissionDecisionReason: reason,
|
|
1609
|
+
},
|
|
1610
|
+
};
|
|
1611
|
+
};
|
|
1583
1612
|
this.handlePermissionRequest = async (toolName, input, options) => {
|
|
1584
1613
|
const requestId = `permission-${randomUUID()}`;
|
|
1585
1614
|
const kind = resolvePermissionKind(toolName, input);
|
|
@@ -1661,6 +1690,10 @@ export class ClaudeAgentSession {
|
|
|
1661
1690
|
this.queryFactory = options.queryFactory;
|
|
1662
1691
|
this.resolveBinary = options.resolveBinary;
|
|
1663
1692
|
this.contextUsage = new ClaudeContextUsageState(findClaudeModel(this.config.model)?.contextWindowMaxTokens);
|
|
1693
|
+
this.toolAllowRules = parseToolAllowlist(this.config.allowedTools ?? []);
|
|
1694
|
+
if (this.toolAllowRules.length > 0) {
|
|
1695
|
+
this.logger.info({ allowedTools: this.config.allowedTools }, "tool allowlist active: tools outside the list will be blocked before execution");
|
|
1696
|
+
}
|
|
1664
1697
|
const handle = options.handle;
|
|
1665
1698
|
if (handle) {
|
|
1666
1699
|
if (!handle.sessionId) {
|
|
@@ -2099,6 +2132,8 @@ export class ClaudeAgentSession {
|
|
|
2099
2132
|
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
|
|
2100
2133
|
}, "provider.claude.session_close.start");
|
|
2101
2134
|
this.closed = true;
|
|
2135
|
+
removeGuardArtifacts(this.toolAllowGuard);
|
|
2136
|
+
this.toolAllowGuard = null;
|
|
2102
2137
|
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
|
2103
2138
|
this.cancelCurrentTurn?.();
|
|
2104
2139
|
this.subscribers.clear();
|
|
@@ -2559,6 +2594,7 @@ export class ClaudeAgentSession {
|
|
|
2559
2594
|
resume: Boolean(resumeId),
|
|
2560
2595
|
model: this.config.model,
|
|
2561
2596
|
permissionMode: this.currentMode,
|
|
2597
|
+
...this.refreshToolAllowGuard(ptySessionId),
|
|
2562
2598
|
appendSystemPrompt: this.buildAppendedSystemPrompt(),
|
|
2563
2599
|
input: input.iterable,
|
|
2564
2600
|
runtimeSettings: this.runtimeSettings,
|
|
@@ -2740,8 +2776,46 @@ export class ClaudeAgentSession {
|
|
|
2740
2776
|
...this.runtimeSettings.disallowedTools,
|
|
2741
2777
|
];
|
|
2742
2778
|
}
|
|
2779
|
+
this.applyToolAllowlistHook(base);
|
|
2743
2780
|
return base;
|
|
2744
2781
|
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Install the per-run tool allowlist as a `PreToolUse` hook on the SDK options.
|
|
2784
|
+
*
|
|
2785
|
+
* Deliberately NOT `options.allowedTools`: that field is a permission ALLOW rule
|
|
2786
|
+
* (pre-approve, do not prompt), and this session also sets
|
|
2787
|
+
* allowDangerouslySkipPermissions, under which permission rules are moot. A
|
|
2788
|
+
* PreToolUse hook is the only gate that still fires; the SDK says so outright
|
|
2789
|
+
* ("PreToolUse hook denies bypass canUseTool"). Applied last so an
|
|
2790
|
+
* `extra.claude.hooks` override cannot displace it.
|
|
2791
|
+
*/
|
|
2792
|
+
applyToolAllowlistHook(base) {
|
|
2793
|
+
if (this.toolAllowRules.length === 0)
|
|
2794
|
+
return;
|
|
2795
|
+
const existing = base.hooks?.PreToolUse ?? [];
|
|
2796
|
+
base.hooks = {
|
|
2797
|
+
...base.hooks,
|
|
2798
|
+
PreToolUse: [...existing, { hooks: [this.enforceToolAllowlistHook] }],
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
/**
|
|
2802
|
+
* PTY-transport half of allowlist enforcement: (re)generate the hook script and
|
|
2803
|
+
* the `--settings` file that registers it.
|
|
2804
|
+
*
|
|
2805
|
+
* Returns a spreadable fragment (`{}` when no allowlist is configured) so the
|
|
2806
|
+
* caller stays branch-free. Regenerated per spawn because a session can restart
|
|
2807
|
+
* (mode swap, model swap, rewind) and the artifacts are keyed to the session id.
|
|
2808
|
+
*/
|
|
2809
|
+
refreshToolAllowGuard(ptySessionId) {
|
|
2810
|
+
removeGuardArtifacts(this.toolAllowGuard);
|
|
2811
|
+
this.toolAllowGuard = writeGuardArtifacts({
|
|
2812
|
+
rules: this.toolAllowRules,
|
|
2813
|
+
sessionId: ptySessionId,
|
|
2814
|
+
baseDir: resolvePaseoHome(),
|
|
2815
|
+
});
|
|
2816
|
+
const guard = this.toolAllowGuard;
|
|
2817
|
+
return guard ? { settingsPath: guard.settingsPath } : {};
|
|
2818
|
+
}
|
|
2745
2819
|
buildSettingsOptions(extraClaudeOptions, input) {
|
|
2746
2820
|
const fastMode = this.resolveFastModeSetting();
|
|
2747
2821
|
if (fastMode === null && !input.ultracode) {
|
|
@@ -11,6 +11,13 @@ export interface CreatePtySessionOptions {
|
|
|
11
11
|
resume: boolean;
|
|
12
12
|
model?: string;
|
|
13
13
|
permissionMode: string;
|
|
14
|
+
/**
|
|
15
|
+
* Extra settings file passed as `claude --settings <path>`. Used to install the
|
|
16
|
+
* per-run tool-allowlist PreToolUse hook (see tool-allowlist-guard.ts). `--settings`
|
|
17
|
+
* LAYERS onto the user/project/local settings claude already reads, so this adds the
|
|
18
|
+
* guard without displacing anything the user configured.
|
|
19
|
+
*/
|
|
20
|
+
settingsPath?: string;
|
|
14
21
|
appendSystemPrompt?: string;
|
|
15
22
|
/** The shared input channel fed by startTurn() — PtyQuery types these into the PTY. */
|
|
16
23
|
input: AsyncIterable<SDKUserMessage>;
|
|
@@ -70,6 +70,12 @@ function buildInteractiveFlags(opts) {
|
|
|
70
70
|
if (opts.permissionMode)
|
|
71
71
|
flags.push("--permission-mode", opts.permissionMode);
|
|
72
72
|
flags.push("--dangerously-skip-permissions");
|
|
73
|
+
// Order matters only for readability; claude merges --settings over its own sources.
|
|
74
|
+
// The tool allowlist rides here BECAUSE of the line above: --dangerously-skip-permissions
|
|
75
|
+
// neutralizes every permission rule, and a PreToolUse hook is the one gate it does not
|
|
76
|
+
// skip. Verified on claude 2.1.239.
|
|
77
|
+
if (opts.settingsPath)
|
|
78
|
+
flags.push("--settings", opts.settingsPath);
|
|
73
79
|
// Inject the same MCP servers the SDK path passes (incl. the paseo MCP) so the PTY agent
|
|
74
80
|
// keeps its mcp__paseo__* tools (create_agent, terminals, schedules…). claude accepts
|
|
75
81
|
// --mcp-config as an inline JSON string. Without this, orchestrated agents (the pool /
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ToolAllowRule } from "./tool-allowlist.js";
|
|
2
|
+
/**
|
|
3
|
+
* Materializes the PTY-transport half of tool-allowlist enforcement: a
|
|
4
|
+
* `PreToolUse` hook script plus the `--settings` file that registers it.
|
|
5
|
+
*
|
|
6
|
+
* The SDK transport enforces the same allowlist in-process (an `options.hooks`
|
|
7
|
+
* callback). The PTY transport spawns a real `claude` terminal, so its gate has
|
|
8
|
+
* to be a command on disk.
|
|
9
|
+
*
|
|
10
|
+
* SINGLE SOURCE OF TRUTH: the guard script is generated by serializing the very
|
|
11
|
+
* functions the in-process path calls (`Function.prototype.toString()`), so the
|
|
12
|
+
* two transports cannot drift into enforcing different rules. `tool-allowlist-guard.test.ts`
|
|
13
|
+
* executes the generated script over the same fixture table that exercises
|
|
14
|
+
* `isToolAllowed()` to keep that guarantee honest. Neither the tsc build nor tsx
|
|
15
|
+
* minifies, so the emitted bodies are valid, type-free JS in dev and in dist.
|
|
16
|
+
*/
|
|
17
|
+
export interface GuardArtifacts {
|
|
18
|
+
/** Directory holding both generated files. Delete it on session teardown. */
|
|
19
|
+
readonly dir: string;
|
|
20
|
+
/** Path passed to `claude --settings`. */
|
|
21
|
+
readonly settingsPath: string;
|
|
22
|
+
/** Path of the generated hook script. */
|
|
23
|
+
readonly guardPath: string;
|
|
24
|
+
}
|
|
25
|
+
/** The standalone `PreToolUse` hook script, with the rules baked in. */
|
|
26
|
+
export declare function buildGuardScript(rules: readonly ToolAllowRule[]): string;
|
|
27
|
+
/** The `--settings` payload that registers the guard for every tool call. */
|
|
28
|
+
export declare function buildGuardSettings(nodeBinary: string, guardPath: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Write the guard script + settings file for a session. Returns null when the
|
|
31
|
+
* allowlist is empty, i.e. no allowlist was configured and nothing is enforced.
|
|
32
|
+
*/
|
|
33
|
+
export declare function writeGuardArtifacts(options: {
|
|
34
|
+
readonly rules: readonly ToolAllowRule[];
|
|
35
|
+
readonly sessionId: string;
|
|
36
|
+
readonly baseDir: string;
|
|
37
|
+
readonly nodeBinary?: string;
|
|
38
|
+
}): GuardArtifacts | null;
|
|
39
|
+
/** Best-effort teardown for `writeGuardArtifacts`. */
|
|
40
|
+
export declare function removeGuardArtifacts(artifacts: GuardArtifacts | null | undefined): void;
|
|
41
|
+
//# sourceMappingURL=tool-allowlist-guard.d.ts.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { SPECIFIER_SOURCES, escapeLiteral, isToolAllowed, readSpecifierValue, specifierMatches, } from "./tool-allowlist.js";
|
|
4
|
+
/** Quote a path for a POSIX/`sh -c` hook command line. */
|
|
5
|
+
function shellQuote(value) {
|
|
6
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
}
|
|
8
|
+
/** The standalone `PreToolUse` hook script, with the rules baked in. */
|
|
9
|
+
export function buildGuardScript(rules) {
|
|
10
|
+
return `#!/usr/bin/env node
|
|
11
|
+
// GENERATED by paseo (tool-allowlist-guard.ts). Do not edit; regenerated per session.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const RULES = ${JSON.stringify(rules)};
|
|
15
|
+
const SPECIFIER_SOURCES = ${JSON.stringify(SPECIFIER_SOURCES)};
|
|
16
|
+
|
|
17
|
+
const escapeLiteral = ${escapeLiteral.toString()};
|
|
18
|
+
const specifierMatches = ${specifierMatches.toString()};
|
|
19
|
+
const readSpecifierValue = ${readSpecifierValue.toString()};
|
|
20
|
+
const isToolAllowed = ${isToolAllowed.toString()};
|
|
21
|
+
|
|
22
|
+
let raw = "";
|
|
23
|
+
try {
|
|
24
|
+
raw = readFileSync(0, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
raw = "";
|
|
27
|
+
}
|
|
28
|
+
let event = {};
|
|
29
|
+
try {
|
|
30
|
+
event = JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
event = {};
|
|
33
|
+
}
|
|
34
|
+
const toolName = typeof event.tool_name === "string" ? event.tool_name : "";
|
|
35
|
+
const toolInput = event.tool_input ?? {};
|
|
36
|
+
|
|
37
|
+
// Fail closed: an unreadable event is not a licence to run an unknown tool.
|
|
38
|
+
if (!toolName || !isToolAllowed(toolName, toolInput, RULES)) {
|
|
39
|
+
const listed = RULES.map((r) => (r.specifier === undefined ? r.tool : r.tool + "(" + r.specifier + ")")).join(", ");
|
|
40
|
+
process.stderr.write(
|
|
41
|
+
'Tool "' + (toolName || "<unknown>") + '" is blocked by this run\\'s tool allowlist and was not executed. ' +
|
|
42
|
+
"Allowed: " + listed + ". Do not retry this tool; achieve the goal with an allowed tool or report that you cannot.\\n",
|
|
43
|
+
);
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
process.exit(0);
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
/** The `--settings` payload that registers the guard for every tool call. */
|
|
50
|
+
export function buildGuardSettings(nodeBinary, guardPath) {
|
|
51
|
+
return `${JSON.stringify({
|
|
52
|
+
hooks: {
|
|
53
|
+
PreToolUse: [
|
|
54
|
+
{
|
|
55
|
+
matcher: "*",
|
|
56
|
+
hooks: [
|
|
57
|
+
{
|
|
58
|
+
type: "command",
|
|
59
|
+
command: `${shellQuote(nodeBinary)} ${shellQuote(guardPath)}`,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
}, null, 2)}\n`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Write the guard script + settings file for a session. Returns null when the
|
|
69
|
+
* allowlist is empty, i.e. no allowlist was configured and nothing is enforced.
|
|
70
|
+
*/
|
|
71
|
+
export function writeGuardArtifacts(options) {
|
|
72
|
+
if (options.rules.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
const dir = path.join(options.baseDir, `tool-allowlist-${options.sessionId}`);
|
|
75
|
+
mkdirSync(dir, { recursive: true });
|
|
76
|
+
const guardPath = path.join(dir, "guard.mjs");
|
|
77
|
+
const settingsPath = path.join(dir, "settings.json");
|
|
78
|
+
writeFileSync(guardPath, buildGuardScript(options.rules), { mode: 0o700 });
|
|
79
|
+
writeFileSync(settingsPath, buildGuardSettings(options.nodeBinary ?? process.execPath, guardPath));
|
|
80
|
+
return { dir, settingsPath, guardPath };
|
|
81
|
+
}
|
|
82
|
+
/** Best-effort teardown for `writeGuardArtifacts`. */
|
|
83
|
+
export function removeGuardArtifacts(artifacts) {
|
|
84
|
+
if (!artifacts)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
rmSync(artifacts.dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// teardown is best effort — a leftover temp dir must never fail a session
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=tool-allowlist-guard.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run tool allowlist enforcement for the claude provider.
|
|
3
|
+
*
|
|
4
|
+
* WHY A HOOK AND NOT `--allowedTools`
|
|
5
|
+
* -----------------------------------
|
|
6
|
+
* `--allowedTools` (CLI) and `options.allowedTools` (SDK) are *permission allow
|
|
7
|
+
* rules*: they pre-approve a tool so it does not prompt. They do not restrict
|
|
8
|
+
* anything. Both of paseo's claude transports also run the child with
|
|
9
|
+
* `--dangerously-skip-permissions` / `allowDangerouslySkipPermissions`, under
|
|
10
|
+
* which every permission rule is moot, so passing an allowlist there would be
|
|
11
|
+
* decorative: accepted, parsed, and silently unenforcing.
|
|
12
|
+
*
|
|
13
|
+
* A `PreToolUse` hook is the one gate that still fires in that state. The Agent
|
|
14
|
+
* SDK says so explicitly (sdk.d.ts, PermissionDeniedHookInput): "PreToolUse hook
|
|
15
|
+
* denies bypass canUseTool". Verified against claude 2.1.239 with
|
|
16
|
+
* `--permission-mode acceptEdits --dangerously-skip-permissions`: a hook exiting
|
|
17
|
+
* 2 blocked a Bash call while an allowlisted Read call went through.
|
|
18
|
+
*
|
|
19
|
+
* FAIL CLOSED
|
|
20
|
+
* -----------
|
|
21
|
+
* When an allowlist is configured, anything this module cannot positively match
|
|
22
|
+
* is DENIED. A matcher that is too strict fails loudly (the model is told which
|
|
23
|
+
* rule set rejected it, and the run keeps going), while a matcher that is too
|
|
24
|
+
* lax fails silently and hands back a guarantee that is not real. Strictness is
|
|
25
|
+
* the safe direction, so unknown rule shapes and unknown specifier sources deny.
|
|
26
|
+
*
|
|
27
|
+
* Note this is *stricter* than `claude-pool launch --allowedTools`, where tools
|
|
28
|
+
* that never request permission (Read, Grep, TodoWrite, ...) were unaffected by
|
|
29
|
+
* the allowlist. Here the allowlist means exactly what it says: a tool absent
|
|
30
|
+
* from it cannot run at all.
|
|
31
|
+
*/
|
|
32
|
+
export interface ToolAllowRule {
|
|
33
|
+
/** Tool name, e.g. "Bash", "Read", "mcp__playwright-personal__browser_navigate". */
|
|
34
|
+
readonly tool: string;
|
|
35
|
+
/** Optional specifier from `Tool(specifier)` form, e.g. "git:*". */
|
|
36
|
+
readonly specifier?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Tools whose specifier (the `Tool(spec)` argument) we know how to read off the input. */
|
|
39
|
+
export declare const SPECIFIER_SOURCES: Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* Parse CSV-or-array allowlist entries into rules. Entries are the same strings
|
|
42
|
+
* Claude Code permission rules use: `Bash`, `Bash(git:*)`, `Read`,
|
|
43
|
+
* `mcp__server__tool`. Blank entries are dropped.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseToolAllowlist(entries: readonly string[]): ToolAllowRule[];
|
|
46
|
+
/** Escape a literal for use inside a RegExp, leaving `*` to be expanded by the caller. */
|
|
47
|
+
export declare function escapeLiteral(value: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Match a Claude-Code-style specifier pattern against a value.
|
|
50
|
+
*
|
|
51
|
+
* Supported shapes (anything else denies):
|
|
52
|
+
* - `*` any value
|
|
53
|
+
* - `prefix:*` value starts with `prefix` (Claude Code's command-prefix form)
|
|
54
|
+
* - `glob` `*` wildcards, everything else literal, anchored both ends
|
|
55
|
+
*/
|
|
56
|
+
export declare function specifierMatches(pattern: string, value: string): boolean;
|
|
57
|
+
/** Read the specifier value a `Tool(spec)` rule compares against, or null when unknown. */
|
|
58
|
+
export declare function readSpecifierValue(toolName: string, input: unknown): string | null;
|
|
59
|
+
/**
|
|
60
|
+
* True when `toolName` (with `input`) is permitted by `rules`.
|
|
61
|
+
*
|
|
62
|
+
* An EMPTY rule list means "no allowlist configured" and allows everything; the
|
|
63
|
+
* caller is responsible for not installing the guard at all in that case.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isToolAllowed(toolName: string, input: unknown, rules: readonly ToolAllowRule[]): boolean;
|
|
66
|
+
/** The message handed back to the model when a call is blocked. */
|
|
67
|
+
export declare function formatDenialMessage(toolName: string, rules: readonly ToolAllowRule[]): string;
|
|
68
|
+
//# sourceMappingURL=tool-allowlist.d.ts.map
|