Compare commits

..
37 changed files with 1000 additions and 281 deletions

No files matched your search

+3 -1
View File
@@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
@@ -19,14 +20,15 @@ Docs: https://docs.clawd.bot
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
- Model picker: list the full catalog when no model allowlist is configured.
- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07.
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
- Zalouser: parse zca JSON output when logs or ANSI codes are present. (#1379) Thanks @ptn1411.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
## 2026.1.20
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps
self.commands = commands
self.permissions = permissions
self.pathenv = pathenv
self.role = role
self.scopes = scopes
self.device = device
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
case caps
case commands
case permissions
case pathenv = "pathEnv"
case role
case scopes
case device
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps
self.commands = commands
self.permissions = permissions
self.pathenv = pathenv
self.role = role
self.scopes = scopes
self.device = device
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
case caps
case commands
case permissions
case pathenv = "pathEnv"
case role
case scopes
case device
+10
View File
@@ -87,6 +87,7 @@ If a prompt is required but no UI is reachable, fallback decides:
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
editing in the macOS app. Patterns are **case-insensitive glob matches**.
Patterns should resolve to **binary paths** (basename-only entries are ignored).
Examples:
- `~/Projects/**/bin/bird`
@@ -104,6 +105,15 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
gateway for the skill bin list. Disable this if you want strict manual allowlists.
## Safe bins (stdin-only)
`tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`)
that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject
positional file args and path-like tokens, so they can only operate on the incoming stream.
Shell chaining and redirections are not auto-allowed in allowlist mode.
Default safe bins: `jq`, `grep`, `cut`, `sort`, `uniq`, `head`, `tail`, `tr`, `wc`.
## Control UI editing
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
+10 -1
View File
@@ -43,6 +43,7 @@ Notes:
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries.
Example:
```json5
@@ -64,7 +65,8 @@ Example:
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
if the exec call already sets `env.PATH`.
if the exec call already sets `env.PATH`. Node PATH overrides are accepted only when they prepend
the node host PATH (no replacement).
Per-agent node binding (use the agent list index in config):
@@ -90,6 +92,13 @@ Example:
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
## Allowlist + safe bins
Allowlist enforcement matches **resolved binary paths only** (no basename matches). When
`security=allowlist`, shell commands are auto-allowed only if every pipeline segment is
allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in
allowlist mode.
## Examples
Foreground:
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseJsonOutput } from "./zca.js";
describe("parseJsonOutput", () => {
it("parses plain JSON output", () => {
expect(parseJsonOutput<{ ok: boolean }>('{"ok":true}')).toEqual({ ok: true });
});
it("parses JSON wrapped in ANSI codes", () => {
const output = "\u001B[32m{\"ok\":true}\u001B[0m";
expect(parseJsonOutput<{ ok: boolean }>(output)).toEqual({ ok: true });
});
it("parses JSON after log prefix lines", () => {
const output = ["INFO starting up", "{\"items\":[1,2]}"].join("\n");
expect(parseJsonOutput<{ items: number[] }>(output)).toEqual({ items: [1, 2] });
});
it("skips invalid JSON blocks and returns the next payload", () => {
const output = ["INFO", "{bad}", "{\"ok\":true}"].join("\n");
expect(parseJsonOutput<{ ok: boolean }>(output)).toEqual({ ok: true });
});
it("returns null when no JSON payload is found", () => {
expect(parseJsonOutput("INFO no payload")).toBeNull();
});
});
+26 -1
View File
@@ -114,11 +114,36 @@ export function runZcaInteractive(
});
}
function stripAnsi(str: string): string {
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
}
export function parseJsonOutput<T>(stdout: string): T | null {
try {
return JSON.parse(stdout) as T;
} catch {
return null;
const cleaned = stripAnsi(stdout);
try {
return JSON.parse(cleaned) as T;
} catch {
// zca may prefix output with INFO/log lines, try to find JSON
const lines = cleaned.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith("{") || line.startsWith("[")) {
// Try parsing from this line to the end
const jsonCandidate = lines.slice(i).join("\n").trim();
try {
return JSON.parse(jsonCandidate) as T;
} catch {
continue;
}
}
}
return null;
}
}
}
+50 -24
View File
@@ -8,12 +8,15 @@ import {
type ExecAsk,
type ExecHost,
type ExecSecurity,
type ExecAllowlistEntry,
addAllowlistEntry,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist,
maxAsk,
minSecurity,
resolveSafeBins,
recordAllowlistUse,
resolveCommandResolution,
resolveExecApprovals,
} from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
@@ -94,6 +97,7 @@ export type ExecToolDefaults = {
ask?: ExecAsk;
node?: string;
pathPrepend?: string[];
safeBins?: string[];
agentId?: string;
backgroundMs?: number;
timeoutSec?: number;
@@ -298,6 +302,7 @@ export function createExecTool(
? defaults.timeoutSec
: 1800;
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
const safeBins = resolveSafeBins(defaults?.safeBins);
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
@@ -593,13 +598,27 @@ export function createExecTool(
if (hostSecurity === "deny") {
throw new Error("exec denied: host=gateway security=deny");
}
const resolution = resolveCommandResolution(params.command, workdir, env);
const allowlistMatch =
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
const allowlistMatches: ExecAllowlistEntry[] = [];
let allowlistSatisfied = false;
if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) {
allowlistSatisfied = analysis.segments.every((segment) => {
const match = matchAllowlist(approvals.allowlist, segment.resolution);
if (match) allowlistMatches.push(match);
const safe = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins,
cwd: workdir,
});
return Boolean(match || safe);
});
}
const requiresAsk =
hostAsk === "always" ||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
(hostAsk === "on-miss" &&
hostSecurity === "allowlist" &&
(!analysis.ok || !allowlistSatisfied));
let approvedByAsk = false;
if (requiresAsk) {
@@ -613,7 +632,7 @@ export function createExecTool(
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: resolution?.resolvedPath ?? null,
resolvedPath: analysis.segments[0]?.resolution?.resolvedPath ?? null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: 120_000,
},
@@ -630,7 +649,7 @@ export function createExecTool(
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!allowlistMatch) {
if (!analysis.ok || !allowlistSatisfied) {
throw new Error("exec denied: approval required (approval UI not available)");
}
approvedByAsk = true;
@@ -644,30 +663,37 @@ export function createExecTool(
if (decision === "allow-always") {
approvedByAsk = true;
if (hostSecurity === "allowlist") {
const pattern =
resolution?.resolvedPath ??
resolution?.rawExecutable ??
params.command.split(/\s+/).shift() ??
"";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
}
}
}
}
}
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
if (
hostSecurity === "allowlist" &&
(!analysis.ok || !allowlistSatisfied) &&
!approvedByAsk
) {
throw new Error("exec denied: allowlist miss");
}
if (allowlistMatch) {
recordAllowlistUse(
approvals.file,
defaults?.agentId,
allowlistMatch,
params.command,
resolution?.resolvedPath,
);
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) continue;
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
defaults?.agentId,
match,
params.command,
analysis.segments[0]?.resolution?.resolvedPath,
);
}
}
}
+6 -6
View File
@@ -10,10 +10,11 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe("");
});
it("does not extract /models with argument", () => {
it("extracts /models with argument", () => {
const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(false);
expect(result.cleaned).toBe("/models gpt-5");
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt-5");
expect(result.cleaned).toBe("");
});
it("extracts /model with provider/model format", () => {
@@ -113,11 +114,10 @@ describe("extractModelDirective", () => {
});
describe("edge cases", () => {
it("extracts models with multiple path segments", () => {
it("preserves spacing when /model is followed by a path segment", () => {
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt-5/tmp/hello");
expect(result.cleaned).toBe("thats not");
expect(result.cleaned).toBe("thats not /hello");
});
it("handles alias with special regex characters", () => {
+1 -1
View File
@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false };
const modelMatch = body.match(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
);
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
@@ -60,13 +60,13 @@ describe("directive behavior", () => {
vi.restoreAllMocks();
});
it("lists allowlisted models on /models <provider>", async () => {
it("lists allowlisted models on /model list", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -84,29 +84,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Models (anthropic)");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("anthropic/claude-opus-4-5");
const openaiRes = await getReplyFromConfig(
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
expect(openaiText).toContain("Models (openai)");
expect(openaiText).toContain("openai/gpt-4.1-mini");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -117,7 +97,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -135,29 +115,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Models (anthropic)");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("anthropic/claude-opus-4-5");
const openaiRes = await getReplyFromConfig(
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
expect(openaiText).toContain("Models (openai)");
expect(openaiText).toContain("openai/gpt-4.1-mini");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -172,7 +132,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -190,29 +150,10 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Models (xai)");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(text).toContain("xai/grok-4");
const minimaxRes = await getReplyFromConfig(
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["openai/gpt-4.1-mini"],
},
imageModel: { primary: "minimax/MiniMax-M2.1" },
workspace: path.join(home, "clawd"),
},
},
session: { store: storePath },
},
);
const minimaxText = Array.isArray(minimaxRes) ? minimaxRes[0]?.text : minimaxRes?.text;
expect(minimaxText).toContain("Models (minimax)");
expect(minimaxText).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -232,7 +173,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -261,7 +202,8 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Models (minimax)");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -289,7 +231,6 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved.");
expect(text).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -62,13 +62,13 @@ describe("directive behavior", () => {
vi.restoreAllMocks();
});
it("selects exact alias matches even when ambiguous", async () => {
it("prefers alias matches when fuzzy selection is ambiguous", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{ Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -65,7 +65,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
await getReplyFromConfig(
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
@@ -94,10 +94,10 @@ describe("directive behavior", () => {
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -106,7 +106,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
await getReplyFromConfig(
{
Body: "/model kimi-k2-0905-preview",
From: "+1222",
@@ -140,10 +140,10 @@ describe("directive behavior", () => {
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -152,7 +152,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
await getReplyFromConfig(
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
@@ -181,10 +181,10 @@ describe("directive behavior", () => {
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -187,7 +187,7 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("shows a model summary on /model", async () => {
it("lists allowlisted models on /model", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
@@ -211,9 +211,10 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Browse: /models");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -95,7 +95,7 @@ afterEach(() => {
});
describe("trigger handling", () => {
it("shows a /model summary with browse hints", async () => {
it("shows a /model summary and points to /models", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
@@ -116,17 +116,19 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
expect(normalized).toContain("Browse: /models");
expect(normalized).toContain("Switch: /model <provider/model>");
expect(normalized).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image");
});
});
it("lists providers on /models", async () => {
it("moves /model list to /models", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
{
Body: "/models",
Body: "/model list",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -141,12 +143,9 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Providers:");
expect(normalized).toContain("anthropic");
expect(normalized).toContain("openrouter");
expect(normalized).toContain("openai");
expect(normalized).toContain("openai-codex");
expect(normalized).toContain("minimax");
expect(normalized).toContain("Model listing moved.");
expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)");
expect(normalized).toContain("Switch: /model <provider/model>");
});
});
it("selects the exact provider/model pair for openrouter", async () => {
@@ -200,16 +199,17 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Numeric model selection is not supported in chat.",
);
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Numeric model selection is not supported in chat.");
expect(normalized).toContain("Browse: /models or /models <provider>");
expect(normalized).toContain("Switch: /model <provider/model>");
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBeUndefined();
expect(store[sessionKey]?.modelOverride).toBeUndefined();
});
});
it("selects exact provider/model combo via /model <provider/model>", async () => {
it("resets to the default model via /model <provider/model>", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
@@ -231,7 +231,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Model reset to default (anthropic/claude-opus-4-5).",
"Model reset to default (anthropic/claude-opus-4-5)",
);
const store = loadSessionStore(cfg.session.store);
+33 -41
View File
@@ -80,47 +80,14 @@ describe("/models command", () => {
expect(result.reply?.text).toContain("All: /models anthropic all");
});
it("includes configured providers and defaults", async () => {
const configuredCfg = {
commands: { text: true },
agents: {
defaults: {
model: {
primary: "synthetic/synth-1",
fallbacks: ["synthetic/synth-2"],
},
imageModel: {
primary: "synthetic/synth-image",
fallbacks: ["synthetic/synth-image-2"],
},
},
},
models: {
providers: {
synthetic: {
baseUrl: "https://example.com",
models: [
{
id: "synth-3",
name: "Synth 3",
},
],
},
},
},
} as unknown as ClawdbotConfig;
const providersResult = await handleCommands(buildParams("/models", configuredCfg));
expect(providersResult.shouldContinue).toBe(false);
expect(providersResult.reply?.text).toContain("synthetic");
const modelsResult = await handleCommands(buildParams("/models synthetic", configuredCfg));
expect(modelsResult.shouldContinue).toBe(false);
expect(modelsResult.reply?.text).toContain("synthetic/synth-1");
expect(modelsResult.reply?.text).toContain("synthetic/synth-2");
expect(modelsResult.reply?.text).toContain("synthetic/synth-3");
expect(modelsResult.reply?.text).toContain("synthetic/synth-image");
expect(modelsResult.reply?.text).toContain("synthetic/synth-image-2");
it("ignores page argument when all flag is present", async () => {
const params = buildParams("/models anthropic 3 all", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (anthropic)");
expect(result.reply?.text).toContain("page 1/1");
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
expect(result.reply?.text).not.toContain("Page out of range");
});
it("errors on out-of-range pages", async () => {
@@ -138,4 +105,29 @@ describe("/models command", () => {
expect(result.reply?.text).toContain("Unknown provider");
expect(result.reply?.text).toContain("Available providers");
});
it("lists configured models outside the curated catalog", async () => {
const customCfg = {
commands: { text: true },
agents: {
defaults: {
model: {
primary: "localai/ultra-chat",
fallbacks: ["anthropic/claude-opus-4-5"],
},
imageModel: "visionpro/studio-v1",
},
},
} as unknown as ClawdbotConfig;
const providerList = await handleCommands(buildParams("/models", customCfg));
expect(providerList.reply?.text).toContain("localai");
expect(providerList.reply?.text).toContain("visionpro");
const result = await handleCommands(buildParams("/models localai", customCfg));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (localai)");
expect(result.reply?.text).toContain("localai/ultra-chat");
expect(result.reply?.text).not.toContain("Unknown provider");
});
});
+43 -43
View File
@@ -17,15 +17,6 @@ function formatProviderLine(params: { provider: string; count: number }): string
return `- ${params.provider} (${params.count})`;
}
function addModelRef(map: Map<string, Set<string>>, provider: string, model: string): void {
const normalizedProvider = normalizeProviderId(provider);
const normalizedModel = String(model ?? "").trim();
if (!normalizedProvider || !normalizedModel) return;
const set = map.get(normalizedProvider) ?? new Set<string>();
set.add(normalizedModel);
map.set(normalizedProvider, set);
}
function parseModelsArgs(raw: string): {
provider?: string;
page: number;
@@ -100,57 +91,66 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
defaultModel: resolvedDefault.model,
});
const byProvider = new Map<string, Set<string>>();
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: resolvedDefault.provider,
});
const addRaw = (raw?: string) => {
const value = String(raw ?? "").trim();
if (!value) return;
const byProvider = new Map<string, Set<string>>();
const add = (p: string, m: string) => {
const key = normalizeProviderId(p);
const set = byProvider.get(key) ?? new Set<string>();
set.add(m);
byProvider.set(key, set);
};
const addRawModelRef = (raw?: string) => {
const trimmed = raw?.trim();
if (!trimmed) return;
const resolved = resolveModelRefFromString({
raw: value,
raw: trimmed,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
if (!resolved) return;
addModelRef(byProvider, resolved.ref.provider, resolved.ref.model);
add(resolved.ref.provider, resolved.ref.model);
};
const addModelConfigEntries = () => {
const modelConfig = params.cfg.agents?.defaults?.model;
if (typeof modelConfig === "string") {
addRawModelRef(modelConfig);
} else if (modelConfig && typeof modelConfig === "object") {
addRawModelRef(modelConfig.primary);
for (const fallback of modelConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
const imageConfig = params.cfg.agents?.defaults?.imageModel;
if (typeof imageConfig === "string") {
addRawModelRef(imageConfig);
} else if (imageConfig && typeof imageConfig === "object") {
addRawModelRef(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
};
for (const entry of allowed.allowedCatalog) {
addModelRef(byProvider, entry.provider, entry.id);
}
addModelRef(byProvider, resolvedDefault.provider, resolvedDefault.model);
const modelConfig = params.cfg.agents?.defaults?.model;
const modelFallbacks =
modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : [];
for (const fallback of modelFallbacks) {
addRaw(String(fallback ?? ""));
}
const imageConfig = params.cfg.agents?.defaults?.imageModel;
if (imageConfig) {
if (typeof imageConfig === "string") {
addRaw(imageConfig);
} else {
addRaw(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRaw(String(fallback ?? ""));
}
}
add(entry.provider, entry.id);
}
// Include config-only allowlist keys that aren't in the curated catalog.
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
addRaw(String(raw ?? ""));
addRawModelRef(raw);
}
for (const [provider, providerConfig] of Object.entries(params.cfg.models?.providers ?? {})) {
for (const modelDef of providerConfig?.models ?? []) {
addModelRef(byProvider, provider, modelDef?.id ?? "");
}
}
// Ensure configured defaults/fallbacks/image models show up even when the
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
add(resolvedDefault.provider, resolvedDefault.model);
addModelConfigEntries();
const providers = [...byProvider.keys()].sort();
+1 -1
View File
@@ -54,7 +54,7 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
if (Math.abs(aLen - bLen) > maxDistance) return null;
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
const prev = Array.from({ length: bLen + 1 }, (_, index) => index);
const prev = Array.from({ length: bLen + 1 }, (_, idx) => idx);
const curr = Array.from({ length: bLen + 1 }, () => 0);
for (let i = 1; i <= aLen; i++) {
+3
View File
@@ -1,4 +1,5 @@
import type { startGatewayServer } from "../../gateway/server.js";
import { acquireGatewayLock } from "../../infra/gateway-lock.js";
import {
consumeGatewaySigusr1RestartAuthorization,
isGatewaySigusr1RestartExternallyAllowed,
@@ -14,6 +15,7 @@ export async function runGatewayLoop(params: {
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
runtime: typeof defaultRuntime;
}) {
const lock = await acquireGatewayLock();
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
let shuttingDown = false;
let restartResolver: (() => void) | null = null;
@@ -96,6 +98,7 @@ export async function runGatewayLoop(params: {
});
}
} finally {
await lock?.release();
cleanupSignals();
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ const callGateway = vi.fn(async (opts: { method?: string }) => {
},
};
}
if (opts.method === "exec.approvals.get") {
if (opts.method === "exec.approvals.node.get") {
return {
path: "/tmp/exec-approvals.json",
exists: true,
+6 -1
View File
@@ -38,6 +38,7 @@ type ExecDefaults = {
ask?: ExecAsk;
node?: string;
pathPrepend?: string[];
safeBins?: string[];
};
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
@@ -95,6 +96,7 @@ function resolveExecDefaults(
ask: globalExec.ask,
node: globalExec.node,
pathPrepend: globalExec.pathPrepend,
safeBins: globalExec.safeBins,
}
: undefined;
}
@@ -104,6 +106,7 @@ function resolveExecDefaults(
ask: agentExec?.ask ?? globalExec?.ask,
node: agentExec?.node ?? globalExec?.node,
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
};
}
@@ -230,7 +233,9 @@ export function registerNodesInvokeCommands(nodes: Command) {
const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const approvalsSnapshot = (await callGatewayCli("exec.approvals.get", opts, {})) as {
const approvalsSnapshot = (await callGatewayCli("exec.approvals.node.get", opts, {
nodeId,
})) as {
file?: unknown;
} | null;
const approvalsFile =
@@ -220,12 +220,18 @@ describe("legacy config detection", () => {
});
it("keeps gateway.bind tailnet", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const { migrateLegacyConfig, validateConfigObject } = await import("./config.js");
const res = migrateLegacyConfig({
gateway: { bind: "tailnet" as const },
});
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
expect(res.config?.gateway?.bind).toBe("tailnet");
expect(res.config).toBeNull();
const validated = validateConfigObject({ gateway: { bind: "tailnet" as const } });
expect(validated.ok).toBe(true);
if (validated.ok) {
expect(validated.config.gateway?.bind).toBe("tailnet");
}
});
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
+4 -2
View File
@@ -202,10 +202,12 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
let mutated = false;
const nextDefaults = { ...defaults };
const contextPruning = defaults.contextPruning ?? {};
const heartbeat = defaults.heartbeat ?? {};
if (defaults.contextPruning?.mode === undefined) {
nextDefaults.contextPruning = {
...defaults.contextPruning,
...contextPruning,
mode: "cache-ttl",
ttl: defaults.contextPruning?.ttl ?? "1h",
};
@@ -214,7 +216,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
if (defaults.heartbeat?.every === undefined) {
nextDefaults.heartbeat = {
...defaults.heartbeat,
...heartbeat,
every: authMode === "oauth" ? "1h" : "30m",
};
mutated = true;
+1 -5
View File
@@ -7,11 +7,7 @@ export function migrateLegacyConfig(raw: unknown): {
changes: string[];
} {
const { next, changes } = applyLegacyMigrations(raw);
if (!next) {
const validated = validateConfigObjectWithPlugins(raw);
if (!validated.ok) return { config: null, changes: [] };
return { config: validated.config, changes: [] };
}
if (!next) return { config: null, changes: [] };
const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) {
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
+3
View File
@@ -166,6 +166,7 @@ const FIELD_LABELS: Record<string, string> = {
"tools.exec.ask": "Exec Ask",
"tools.exec.node": "Exec Node Binding",
"tools.exec.pathPrepend": "Exec PATH Prepend",
"tools.exec.safeBins": "Exec Safe Bins",
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
@@ -367,6 +368,8 @@ const FIELD_HELP: Record<string, string> = {
"tools.exec.notifyOnExit":
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
"tools.exec.safeBins":
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
"tools.message.allowCrossContextSend":
"Legacy override: allow cross-context sends across all providers.",
"tools.message.crossContext.allowWithinProvider":
+2
View File
@@ -131,6 +131,8 @@ export type ExecToolConfig = {
node?: string;
/** Directories to prepend to PATH when running exec (gateway/sandbox). */
pathPrepend?: string[];
/** Safe stdin-only binaries that can run without allowlist entries. */
safeBins?: string[];
/** Default time (ms) before an exec command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing exec commands. */
+2
View File
@@ -257,6 +257,7 @@ export const AgentToolsSchema = z
ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(),
pathPrepend: z.array(z.string()).optional(),
safeBins: z.array(z.string()).optional(),
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
@@ -485,6 +486,7 @@ export const ToolsSchema = z
ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(),
pathPrepend: z.array(z.string()).optional(),
safeBins: z.array(z.string()).optional(),
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
+8 -1
View File
@@ -2,7 +2,14 @@ export const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
export const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
export const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
export const HANDSHAKE_TIMEOUT_MS = 10_000;
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
export const getHandshakeTimeoutMs = () => {
if (process.env.VITEST && process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS) {
const parsed = Number(process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return DEFAULT_HANDSHAKE_TIMEOUT_MS;
};
export const TICK_INTERVAL_MS = 30_000;
export const HEALTH_REFRESH_INTERVAL_MS = 60_000;
export const DEDUPE_TTL_MS = 5 * 60_000;
+16 -5
View File
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { HANDSHAKE_TIMEOUT_MS } from "./server-constants.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
import {
connectReq,
getFreePort,
@@ -28,10 +28,21 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
vi.useRealTimers();
const { server, ws } = await startServerWithClient();
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
expect(closed).toBe(true);
await server.close();
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
try {
const { server, ws } = await startServerWithClient();
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000);
expect(closed).toBe(true);
await server.close();
} finally {
if (prevHandshakeTimeout === undefined) {
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
} else {
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
}
}
});
test("connect (req) handshake returns hello-ok payload", async () => {
+3 -2
View File
@@ -8,7 +8,7 @@ import { isWebchatClient } from "../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../auth.js";
import { isLoopbackAddress } from "../net.js";
import { HANDSHAKE_TIMEOUT_MS } from "../server-constants.js";
import { getHandshakeTimeoutMs } from "../server-constants.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
import { formatError } from "../server-utils.js";
import { logWs } from "../ws-log.js";
@@ -210,6 +210,7 @@ export function attachGatewayWsConnectionHandler(params: {
close();
});
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const handshakeTimer = setTimeout(() => {
if (!client) {
handshakeState = "failed";
@@ -219,7 +220,7 @@ export function attachGatewayWsConnectionHandler(params: {
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
close();
}
}, HANDSHAKE_TIMEOUT_MS);
}, handshakeTimeoutMs);
attachGatewayWsMessageHandler({
socket,
+80 -4
View File
@@ -5,9 +5,13 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
analyzeArgvCommand,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist,
maxAsk,
minSecurity,
normalizeSafeBins,
resolveCommandResolution,
resolveExecApprovals,
type ExecAllowlistEntry,
@@ -18,7 +22,7 @@ function makeTempDir() {
}
describe("exec approvals allowlist matching", () => {
it("matches by executable name (case-insensitive)", () => {
it("ignores basename-only patterns", () => {
const resolution = {
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
@@ -26,7 +30,7 @@ describe("exec approvals allowlist matching", () => {
};
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
const match = matchAllowlist(entries, resolution);
expect(match?.pattern).toBe("RG");
expect(match).toBeNull();
});
it("matches by resolved path with **", () => {
@@ -51,7 +55,7 @@ describe("exec approvals allowlist matching", () => {
expect(match).toBeNull();
});
it("falls back to raw executable when no resolved path", () => {
it("requires a resolved path", () => {
const resolution = {
rawExecutable: "bin/rg",
resolvedPath: undefined,
@@ -59,7 +63,7 @@ describe("exec approvals allowlist matching", () => {
};
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
const match = matchAllowlist(entries, resolution);
expect(match?.pattern).toBe("bin/rg");
expect(match).toBeNull();
});
});
@@ -70,6 +74,7 @@ describe("exec approvals command resolution", () => {
fs.mkdirSync(binDir, { recursive: true });
const exe = path.join(binDir, "rg");
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
expect(res?.resolvedPath).toBe(exe);
expect(res?.executableName).toBe("rg");
@@ -81,6 +86,7 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "scripts", "run.sh");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
fs.chmodSync(script, 0o755);
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
@@ -91,11 +97,81 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "bin", "tool");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
fs.chmodSync(script, 0o755);
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
});
describe("exec approvals shell parsing", () => {
it("parses simple pipelines", () => {
const res = analyzeShellCommand({ command: "echo ok | jq .foo" });
expect(res.ok).toBe(true);
expect(res.segments.map((seg) => seg.argv[0])).toEqual(["echo", "jq"]);
});
it("rejects chained commands", () => {
const res = analyzeShellCommand({ command: "ls && rm -rf /" });
expect(res.ok).toBe(false);
});
it("parses argv commands", () => {
const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] });
expect(res.ok).toBe(true);
expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]);
});
});
describe("exec approvals safe bins", () => {
it("allows safe bins with non-path args", () => {
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const exe = path.join(binDir, "jq");
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const res = analyzeShellCommand({
command: "jq .foo",
cwd: dir,
env: { PATH: binDir },
});
expect(res.ok).toBe(true);
const segment = res.segments[0];
const ok = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins: normalizeSafeBins(["jq"]),
cwd: dir,
});
expect(ok).toBe(true);
});
it("blocks safe bins with file args", () => {
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const exe = path.join(binDir, "jq");
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const file = path.join(dir, "secret.json");
fs.writeFileSync(file, "{}");
const res = analyzeShellCommand({
command: "jq .foo secret.json",
cwd: dir,
env: { PATH: binDir },
});
expect(res.ok).toBe(true);
const segment = res.segments[0];
const ok = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins: normalizeSafeBins(["jq"]),
cwd: dir,
});
expect(ok).toBe(false);
});
});
describe("exec approvals policy helpers", () => {
it("minSecurity returns the more restrictive value", () => {
expect(minSecurity("deny", "full")).toBe("deny");
+310 -13
View File
@@ -60,6 +60,7 @@ const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny";
const DEFAULT_AUTO_ALLOW_SKILLS = false;
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"];
function hashExecApprovalsRaw(raw: string | null): string {
return crypto
@@ -283,6 +284,19 @@ type CommandResolution = {
executableName: string;
};
function isExecutableFile(filePath: string): boolean {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
if (process.platform !== "win32") {
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
} catch {
return false;
}
}
function parseFirstToken(command: string): string | null {
const trimmed = command.trim();
if (!trimmed) return null;
@@ -299,15 +313,26 @@ function parseFirstToken(command: string): string | null {
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
if (expanded.includes("/") || expanded.includes("\\")) {
if (path.isAbsolute(expanded)) return expanded;
if (path.isAbsolute(expanded)) {
return isExecutableFile(expanded) ? expanded : undefined;
}
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
return path.resolve(base, expanded);
const candidate = path.resolve(base, expanded);
return isExecutableFile(candidate) ? candidate : undefined;
}
const envPath = env?.PATH ?? process.env.PATH ?? "";
const entries = envPath.split(path.delimiter).filter(Boolean);
const extensions =
process.platform === "win32"
? (env?.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((ext) => ext.toLowerCase())
: [""];
for (const entry of entries) {
const candidate = path.join(entry, expanded);
if (fs.existsSync(candidate)) return candidate;
for (const ext of extensions) {
const candidate = path.join(entry, expanded + ext);
if (isExecutableFile(candidate)) return candidate;
}
}
return undefined;
}
@@ -324,6 +349,18 @@ export function resolveCommandResolution(
return { rawExecutable, resolvedPath, executableName };
}
export function resolveCommandResolutionFromArgv(
argv: string[],
cwd?: string,
env?: NodeJS.ProcessEnv,
): CommandResolution | null {
const rawExecutable = argv[0]?.trim();
if (!rawExecutable) return null;
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return { rawExecutable, resolvedPath, executableName };
}
function normalizeMatchTarget(value: string): string {
return value.replace(/\\\\/g, "/").toLowerCase();
}
@@ -370,24 +407,284 @@ export function matchAllowlist(
entries: ExecAllowlistEntry[],
resolution: CommandResolution | null,
): ExecAllowlistEntry | null {
if (!entries.length || !resolution) return null;
const rawExecutable = resolution.rawExecutable;
if (!entries.length || !resolution?.resolvedPath) return null;
const resolvedPath = resolution.resolvedPath;
const executableName = resolution.executableName;
for (const entry of entries) {
const pattern = entry.pattern?.trim();
if (!pattern) continue;
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
if (hasPath) {
const target = resolvedPath ?? rawExecutable;
if (target && matchesPattern(pattern, target)) return entry;
continue;
}
if (executableName && matchesPattern(pattern, executableName)) return entry;
if (!hasPath) continue;
if (matchesPattern(pattern, resolvedPath)) return entry;
}
return null;
}
export type ExecCommandSegment = {
raw: string;
argv: string[];
resolution: CommandResolution | null;
};
export type ExecCommandAnalysis = {
ok: boolean;
reason?: string;
segments: ExecCommandSegment[];
};
const DISALLOWED_TOKENS = new Set([";", "&", ">", "<", "`", "\n", "\r", "(", ")"]);
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
const segments: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushSegment = () => {
const trimmed = buf.trim();
if (!trimmed) {
return false;
}
segments.push(trimmed);
buf = "";
return true;
};
for (let i = 0; i < command.length; i += 1) {
const ch = command[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
escaped = true;
buf += ch;
continue;
}
if (inSingle) {
if (ch === "'") inSingle = false;
buf += ch;
continue;
}
if (inDouble) {
if (ch === '"') inDouble = false;
buf += ch;
continue;
}
if (ch === "'") {
inSingle = true;
buf += ch;
continue;
}
if (ch === '"') {
inDouble = true;
buf += ch;
continue;
}
if (ch === "|" && command[i + 1] === "|") {
return { ok: false, reason: "unsupported shell token: ||", segments: [] };
}
if (ch === "|" && command[i + 1] === "&") {
return { ok: false, reason: "unsupported shell token: |&", segments: [] };
}
if (ch === "|") {
if (!pushSegment()) {
return { ok: false, reason: "empty pipeline segment", segments: [] };
}
continue;
}
if (DISALLOWED_TOKENS.has(ch)) {
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
}
if (ch === "$" && command[i + 1] === "(") {
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return { ok: false, reason: "unterminated shell quote/escape", segments: [] };
}
if (!pushSegment()) {
return { ok: false, reason: "empty command", segments: [] };
}
return { ok: true, segments };
}
function tokenizeShellSegment(segment: string): string[] | null {
const tokens: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushToken = () => {
if (buf.length > 0) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < segment.length; i += 1) {
const ch = segment[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
escaped = true;
continue;
}
if (inSingle) {
if (ch === "'") {
inSingle = false;
} else {
buf += ch;
}
continue;
}
if (inDouble) {
if (ch === '"') {
inDouble = false;
} else {
buf += ch;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
if (/\s/.test(ch)) {
pushToken();
continue;
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return null;
}
pushToken();
return tokens;
}
export function analyzeShellCommand(params: {
command: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
const split = splitShellPipeline(params.command);
if (!split.ok) {
return { ok: false, reason: split.reason, segments: [] };
}
const segments: ExecCommandSegment[] = [];
for (const raw of split.segments) {
const argv = tokenizeShellSegment(raw);
if (!argv || argv.length === 0) {
return { ok: false, reason: "unable to parse shell segment", segments: [] };
}
segments.push({
raw,
argv,
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
});
}
return { ok: true, segments };
}
export function analyzeArgvCommand(params: {
argv: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
const argv = params.argv.filter((entry) => entry.trim().length > 0);
if (argv.length === 0) {
return { ok: false, reason: "empty argv", segments: [] };
}
return {
ok: true,
segments: [
{
raw: argv.join(" "),
argv,
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
},
],
};
}
function isPathLikeToken(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed === "-") return false;
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) return true;
if (trimmed.startsWith("/")) return true;
return /^[A-Za-z]:[\\/]/.test(trimmed);
}
function defaultFileExists(filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
export function normalizeSafeBins(entries?: string[]): Set<string> {
if (!Array.isArray(entries)) return new Set();
const normalized = entries
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry.length > 0);
return new Set(normalized);
}
export function resolveSafeBins(entries?: string[] | null): Set<string> {
if (entries === undefined) return normalizeSafeBins(DEFAULT_SAFE_BINS);
return normalizeSafeBins(entries ?? []);
}
export function isSafeBinUsage(params: {
argv: string[];
resolution: CommandResolution | null;
safeBins: Set<string>;
cwd?: string;
fileExists?: (filePath: string) => boolean;
}): boolean {
if (params.safeBins.size === 0) return false;
const resolution = params.resolution;
const execName = resolution?.executableName?.toLowerCase();
if (!execName || !params.safeBins.has(execName)) return false;
if (!resolution?.resolvedPath) return false;
const cwd = params.cwd ?? process.cwd();
const exists = params.fileExists ?? defaultFileExists;
const argv = params.argv.slice(1);
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token) continue;
if (token === "-") continue;
if (token.startsWith("-")) {
const eqIndex = token.indexOf("=");
if (eqIndex > 0) {
const value = token.slice(eqIndex + 1);
if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) {
return false;
}
}
continue;
}
if (isPathLikeToken(token)) return false;
if (exists(path.resolve(cwd, token))) return false;
}
return true;
}
export function recordAllowlistUse(
approvals: ExecApprovalsFile,
agentId: string | undefined,
+55
View File
@@ -0,0 +1,55 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
async function makeEnv() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-lock-"));
const configPath = path.join(dir, "clawdbot.json");
await fs.writeFile(configPath, "{}", "utf8");
return {
env: {
...process.env,
CLAWDBOT_STATE_DIR: dir,
CLAWDBOT_CONFIG_PATH: configPath,
},
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
describe("gateway lock", () => {
it("blocks concurrent acquisition until release", async () => {
const { env, cleanup } = await makeEnv();
const lock = await acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 200,
pollIntervalMs: 20,
});
expect(lock).not.toBeNull();
await expect(
acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 200,
pollIntervalMs: 20,
}),
).rejects.toBeInstanceOf(GatewayLockError);
await lock?.release();
const lock2 = await acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 200,
pollIntervalMs: 20,
});
await lock2?.release();
await cleanup();
});
});
+144
View File
@@ -1,3 +1,33 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
const DEFAULT_TIMEOUT_MS = 5000;
const DEFAULT_POLL_INTERVAL_MS = 100;
const DEFAULT_STALE_MS = 30_000;
type LockPayload = {
pid: number;
createdAt: string;
configPath: string;
};
export type GatewayLockHandle = {
lockPath: string;
configPath: string;
release: () => Promise<void>;
};
export type GatewayLockOptions = {
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
pollIntervalMs?: number;
staleMs?: number;
allowInTests?: boolean;
};
export class GatewayLockError extends Error {
constructor(
message: string,
@@ -7,3 +37,117 @@ export class GatewayLockError extends Error {
this.name = "GatewayLockError";
}
}
function isAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
try {
const raw = await fs.readFile(lockPath, "utf8");
const parsed = JSON.parse(raw) as Partial<LockPayload>;
if (typeof parsed.pid !== "number") return null;
if (typeof parsed.createdAt !== "string") return null;
if (typeof parsed.configPath !== "string") return null;
return {
pid: parsed.pid,
createdAt: parsed.createdAt,
configPath: parsed.configPath,
};
} catch {
return null;
}
}
function resolveGatewayLockPath(env: NodeJS.ProcessEnv) {
const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir);
const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);
const lockPath = path.join(stateDir, `gateway.${hash}.lock`);
return { lockPath, configPath };
}
export async function acquireGatewayLock(
opts: GatewayLockOptions = {},
): Promise<GatewayLockHandle | null> {
const env = opts.env ?? process.env;
const allowInTests = opts.allowInTests === true;
if (
env.CLAWDBOT_ALLOW_MULTI_GATEWAY === "1" ||
(!allowInTests && (env.VITEST || env.NODE_ENV === "test"))
) {
return null;
}
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
const { lockPath, configPath } = resolveGatewayLockPath(env);
await fs.mkdir(path.dirname(lockPath), { recursive: true });
const startedAt = Date.now();
let lastPayload: LockPayload | null = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const handle = await fs.open(lockPath, "wx");
const payload: LockPayload = {
pid: process.pid,
createdAt: new Date().toISOString(),
configPath,
};
await handle.writeFile(JSON.stringify(payload), "utf8");
return {
lockPath,
configPath,
release: async () => {
await handle.close().catch(() => undefined);
await fs.rm(lockPath, { force: true });
},
};
} catch (err) {
const code = (err as { code?: unknown }).code;
if (code !== "EEXIST") {
throw new GatewayLockError(`failed to acquire gateway lock at ${lockPath}`, err);
}
lastPayload = await readLockPayload(lockPath);
const ownerPid = lastPayload?.pid;
const ownerAlive = ownerPid ? isAlive(ownerPid) : false;
if (!ownerAlive && ownerPid) {
await fs.rm(lockPath, { force: true });
continue;
}
if (!ownerAlive) {
let stale = false;
if (lastPayload?.createdAt) {
const createdAt = Date.parse(lastPayload.createdAt);
stale = Number.isFinite(createdAt) ? Date.now() - createdAt > staleMs : false;
}
if (!stale) {
try {
const st = await fs.stat(lockPath);
stale = Date.now() - st.mtimeMs > staleMs;
} catch {
stale = true;
}
}
if (stale) {
await fs.rm(lockPath, { force: true });
continue;
}
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
}
const owner = lastPayload?.pid ? ` (pid ${lastPayload.pid})` : "";
throw new GatewayLockError(`gateway already running${owner}; lock timeout after ${timeoutMs}ms`);
}
+3 -3
View File
@@ -89,7 +89,7 @@ function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
}
}
function parseActiveHoursTime(raw: string | undefined, opts: { allow24: boolean }): number | null {
function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null {
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
const [hourStr, minuteStr] = raw.split(":");
const hour = Number(hourStr);
@@ -131,8 +131,8 @@ function isWithinActiveHours(
const active = heartbeat?.activeHours;
if (!active) return true;
const startMin = parseActiveHoursTime(active.start, { allow24: false });
const endMin = parseActiveHoursTime(active.end, { allow24: true });
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
if (startMin === null || endMin === null) return true;
if (startMin === endMin) return true;
+15 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import process from "node:process";
import type { GatewayLockHandle } from "../infra/gateway-lock.js";
declare const __CLAWDBOT_VERSION__: string;
@@ -45,6 +46,7 @@ async function main() {
{ startGatewayServer },
{ setGatewayWsLogStyle },
{ setVerbose },
{ acquireGatewayLock, GatewayLockError },
{ consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed },
{ defaultRuntime },
{ enableConsoleCapture, setConsoleTimestampPrefix },
@@ -53,10 +55,11 @@ async function main() {
import("../gateway/server.js"),
import("../gateway/ws-logging.js"),
import("../globals.js"),
import("../infra/gateway-lock.js"),
import("../infra/restart.js"),
import("../runtime.js"),
import("../logging.js"),
]);
] as const);
enableConsoleCapture();
setConsoleTimestampPrefix(true);
@@ -103,6 +106,7 @@ async function main() {
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
let lock: GatewayLockHandle | null = null;
let shuttingDown = false;
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
let restartResolver: (() => void) | null = null;
@@ -177,6 +181,15 @@ async function main() {
process.on("SIGUSR1", onSigusr1);
try {
try {
lock = await acquireGatewayLock();
} catch (err) {
if (err instanceof GatewayLockError) {
defaultRuntime.error(`Gateway start blocked: ${err.message}`);
process.exit(1);
}
throw err;
}
// eslint-disable-next-line no-constant-condition
while (true) {
try {
@@ -191,6 +204,7 @@ async function main() {
});
}
} finally {
await (lock as GatewayLockHandle | null)?.release();
cleanupSignals();
}
}
+66 -13
View File
@@ -4,12 +4,16 @@ import fs from "node:fs";
import path from "node:path";
import {
type ExecAllowlistEntry,
addAllowlistEntry,
analyzeArgvCommand,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist,
normalizeExecApprovals,
recordAllowlistUse,
resolveCommandResolution,
resolveExecApprovals,
resolveSafeBins,
ensureExecApprovals,
readExecApprovalsSnapshot,
resolveExecApprovalsSocketPath,
@@ -25,6 +29,7 @@ import {
import { getMachineDisplayName } from "../infra/machine-name.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadConfig } from "../config/config.js";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import { VERSION } from "../version.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -110,7 +115,6 @@ const execHostFallbackAllowed =
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
const blockedEnvKeys = new Set([
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
@@ -156,10 +160,24 @@ function sanitizeEnv(
): Record<string, string> | undefined {
if (!overrides) return undefined;
const merged = { ...process.env } as Record<string, string>;
const basePath = process.env.PATH ?? DEFAULT_NODE_PATH;
for (const [rawKey, value] of Object.entries(overrides)) {
const key = rawKey.trim();
if (!key) continue;
const upper = key.toUpperCase();
if (upper === "PATH") {
const trimmed = value.trim();
if (!trimmed) continue;
if (!basePath || trimmed === basePath) {
merged[key] = trimmed;
continue;
}
const suffix = `${path.delimiter}${basePath}`;
if (trimmed.endsWith(suffix)) {
merged[key] = trimmed;
}
continue;
}
if (blockedEnvKeys.has(upper)) continue;
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
merged[key] = value;
@@ -567,12 +585,32 @@ async function handleInvoke(
const sessionKey = params.sessionKey?.trim() || "node";
const runId = crypto.randomUUID();
const env = sanitizeEnv(params.env ?? undefined);
const resolution = resolveCommandResolution(cmdText, params.cwd ?? undefined, env);
const allowlistMatch =
security === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const analysis = rawCommand
? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
: analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
const cfg = loadConfig();
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
const allowlistMatches: ExecAllowlistEntry[] = [];
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
const allowlistSatisfied =
security === "allowlist" && analysis.ok && analysis.segments.length > 0
? analysis.segments.every((segment) => {
const match = matchAllowlist(approvals.allowlist, segment.resolution);
if (match) allowlistMatches.push(match);
const safe = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins,
cwd: params.cwd ?? undefined,
});
const skillAllow =
autoAllowSkills && segment.resolution?.executableName
? bins.has(segment.resolution.executableName)
: false;
return Boolean(match || safe || skillAllow);
})
: false;
const useMacAppExec = process.platform === "darwin";
if (useMacAppExec) {
@@ -678,7 +716,7 @@ async function handleInvoke(
const requiresAsk =
ask === "always" ||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
(ask === "on-miss" && security === "allowlist" && (!analysis.ok || !allowlistSatisfied));
const approvalDecision =
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
@@ -704,11 +742,15 @@ async function handleInvoke(
return;
}
if (approvalDecision === "allow-always" && security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
if (analysis.ok) {
for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
}
}
}
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) {
if (security === "allowlist" && (!analysis.ok || !allowlistSatisfied) && !approvedByAsk) {
await sendNodeEvent(
client,
"exec.denied",
@@ -727,8 +769,19 @@ async function handleInvoke(
return;
}
if (allowlistMatch) {
recordAllowlistUse(approvals.file, agentId, allowlistMatch, cmdText, resolution?.resolvedPath);
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (!match?.pattern || seen.has(match.pattern)) continue;
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
match,
cmdText,
analysis.segments[0]?.resolution?.resolvedPath,
);
}
}
if (params.needsScreenRecording === true) {