hovclaw 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -97,7 +97,27 @@ const DEFAULT_FILE_CONFIG = {
97
97
  "tail",
98
98
  "wc"
99
99
  ],
100
- tools: { bashEnabled: false }
100
+ tools: {
101
+ bashEnabled: false,
102
+ exec: {
103
+ enabled: false,
104
+ security: "allowlist",
105
+ ask: "on-miss",
106
+ approvalTimeoutMs: 12e4,
107
+ allowlist: [],
108
+ safeBins: [...[
109
+ "jq",
110
+ "grep",
111
+ "cut",
112
+ "sort",
113
+ "uniq",
114
+ "head",
115
+ "tail",
116
+ "tr",
117
+ "wc"
118
+ ]]
119
+ }
120
+ }
101
121
  },
102
122
  channels: {
103
123
  discord: {
@@ -202,6 +222,22 @@ const commandsConfigSchema = z.object({
202
222
  useAccessGroups: z.boolean(),
203
223
  allowFrom: commandAllowFromSchema
204
224
  });
225
+ const runtimeExecConfigSchema = z.object({
226
+ enabled: z.boolean(),
227
+ security: z.enum([
228
+ "deny",
229
+ "allowlist",
230
+ "full"
231
+ ]),
232
+ ask: z.enum([
233
+ "off",
234
+ "on-miss",
235
+ "always"
236
+ ]),
237
+ approvalTimeoutMs: z.number().int().positive(),
238
+ allowlist: z.array(z.string().min(1)),
239
+ safeBins: z.array(z.string().min(1))
240
+ });
205
241
  const telegramTopicConfigSchema = z.object({
206
242
  enabled: z.boolean().optional(),
207
243
  requireMention: z.boolean().optional(),
@@ -364,7 +400,10 @@ const fileConfigSchema = z.object({
364
400
  allowedReadRoots: z.array(z.string().min(1)),
365
401
  allowedWriteRoots: z.array(z.string().min(1)),
366
402
  allowedCommandPrefixes: z.array(z.string().min(1)).min(1),
367
- tools: z.object({ bashEnabled: z.boolean() })
403
+ tools: z.object({
404
+ bashEnabled: z.boolean(),
405
+ exec: runtimeExecConfigSchema
406
+ })
368
407
  }),
369
408
  channels: z.object({
370
409
  discord: z.object({
@@ -441,7 +480,21 @@ const partialFileConfigSchema = z.object({
441
480
  bindings: fileConfigSchema.shape.bindings.optional(),
442
481
  models: fileConfigSchema.shape.models.partial().optional(),
443
482
  commands: commandsConfigSchema.partial().optional(),
444
- runtime: fileConfigSchema.shape.runtime.partial().optional(),
483
+ runtime: z.object({
484
+ mode: z.enum(["local", "container"]).optional(),
485
+ containerImage: z.string().min(1).optional(),
486
+ idleTimeoutMs: z.number().int().positive().optional(),
487
+ networkMode: z.string().min(1).optional(),
488
+ timeoutMs: z.number().int().positive().optional(),
489
+ maxOutputBytes: z.number().int().positive().optional(),
490
+ allowedReadRoots: z.array(z.string().min(1)).optional(),
491
+ allowedWriteRoots: z.array(z.string().min(1)).optional(),
492
+ allowedCommandPrefixes: z.array(z.string().min(1)).min(1).optional(),
493
+ tools: z.object({
494
+ bashEnabled: z.boolean().optional(),
495
+ exec: runtimeExecConfigSchema.partial().optional()
496
+ }).optional()
497
+ }).optional(),
445
498
  channels: partialChannelsSchema,
446
499
  gateway: partialGatewayConfigSchema,
447
500
  scheduler: fileConfigSchema.shape.scheduler.partial().optional()
@@ -712,7 +765,17 @@ function mergeWithDefaults(partial) {
712
765
  allowedReadRoots: partial.runtime?.allowedReadRoots ?? DEFAULT_FILE_CONFIG.runtime.allowedReadRoots,
713
766
  allowedWriteRoots: partial.runtime?.allowedWriteRoots ?? DEFAULT_FILE_CONFIG.runtime.allowedWriteRoots,
714
767
  allowedCommandPrefixes: partial.runtime?.allowedCommandPrefixes ?? DEFAULT_FILE_CONFIG.runtime.allowedCommandPrefixes,
715
- tools: { bashEnabled: partial.runtime?.tools?.bashEnabled ?? DEFAULT_FILE_CONFIG.runtime.tools.bashEnabled }
768
+ tools: {
769
+ bashEnabled: partial.runtime?.tools?.bashEnabled ?? DEFAULT_FILE_CONFIG.runtime.tools.bashEnabled,
770
+ exec: {
771
+ enabled: partial.runtime?.tools?.exec?.enabled ?? partial.runtime?.tools?.bashEnabled ?? DEFAULT_FILE_CONFIG.runtime.tools.exec.enabled,
772
+ security: partial.runtime?.tools?.exec?.security ?? DEFAULT_FILE_CONFIG.runtime.tools.exec.security,
773
+ ask: partial.runtime?.tools?.exec?.ask ?? DEFAULT_FILE_CONFIG.runtime.tools.exec.ask,
774
+ approvalTimeoutMs: partial.runtime?.tools?.exec?.approvalTimeoutMs ?? DEFAULT_FILE_CONFIG.runtime.tools.exec.approvalTimeoutMs,
775
+ allowlist: partial.runtime?.tools?.exec?.allowlist ?? DEFAULT_FILE_CONFIG.runtime.tools.exec.allowlist,
776
+ safeBins: partial.runtime?.tools?.exec?.safeBins ?? DEFAULT_FILE_CONFIG.runtime.tools.exec.safeBins
777
+ }
778
+ }
716
779
  },
717
780
  channels: {
718
781
  discord: {
@@ -824,7 +887,17 @@ function applyEnvOverrides(base, env) {
824
887
  allowedReadRoots: splitCsv(env.ALLOWED_READ_ROOTS, base.runtime.allowedReadRoots),
825
888
  allowedWriteRoots: splitCsv(env.ALLOWED_WRITE_ROOTS, base.runtime.allowedWriteRoots),
826
889
  allowedCommandPrefixes: splitCsv(env.ALLOWED_COMMAND_PREFIXES, base.runtime.allowedCommandPrefixes),
827
- tools: { bashEnabled: toBool(env.RUNTIME_BASH_ENABLED, base.runtime.tools.bashEnabled) }
890
+ tools: {
891
+ bashEnabled: toBool(env.RUNTIME_BASH_ENABLED, base.runtime.tools.bashEnabled),
892
+ exec: {
893
+ enabled: toBool(env.RUNTIME_EXEC_ENABLED, toBool(env.RUNTIME_BASH_ENABLED, base.runtime.tools.exec.enabled)),
894
+ security: env.RUNTIME_EXEC_SECURITY === "deny" || env.RUNTIME_EXEC_SECURITY === "allowlist" || env.RUNTIME_EXEC_SECURITY === "full" ? env.RUNTIME_EXEC_SECURITY : base.runtime.tools.exec.security,
895
+ ask: env.RUNTIME_EXEC_ASK === "off" || env.RUNTIME_EXEC_ASK === "on-miss" || env.RUNTIME_EXEC_ASK === "always" ? env.RUNTIME_EXEC_ASK : base.runtime.tools.exec.ask,
896
+ approvalTimeoutMs: toPositiveInt(env.RUNTIME_EXEC_APPROVAL_TIMEOUT_MS, base.runtime.tools.exec.approvalTimeoutMs),
897
+ allowlist: splitCsv(env.RUNTIME_EXEC_ALLOWLIST, base.runtime.tools.exec.allowlist),
898
+ safeBins: splitCsv(env.RUNTIME_EXEC_SAFE_BINS, base.runtime.tools.exec.safeBins)
899
+ }
900
+ }
828
901
  },
829
902
  channels: {
830
903
  discord: {
@@ -929,7 +1002,17 @@ function loadConfig(env = process.env) {
929
1002
  allowedReadRoots: normalizeRoots(merged.runtime.allowedReadRoots),
930
1003
  allowedWriteRoots: normalizeRoots(merged.runtime.allowedWriteRoots),
931
1004
  allowedCommandPrefixes: merged.runtime.allowedCommandPrefixes,
932
- tools: { bashEnabled: merged.runtime.tools.bashEnabled }
1005
+ tools: {
1006
+ bashEnabled: merged.runtime.tools.bashEnabled,
1007
+ exec: {
1008
+ enabled: merged.runtime.tools.exec.enabled || merged.runtime.tools.bashEnabled,
1009
+ security: merged.runtime.tools.exec.security,
1010
+ ask: merged.runtime.tools.exec.ask,
1011
+ approvalTimeoutMs: merged.runtime.tools.exec.approvalTimeoutMs,
1012
+ allowlist: [...merged.runtime.tools.exec.allowlist],
1013
+ safeBins: [...merged.runtime.tools.exec.safeBins]
1014
+ }
1015
+ }
933
1016
  },
934
1017
  channels: {
935
1018
  discord: {
@@ -1027,6 +1110,12 @@ function legacyEnvKeys() {
1027
1110
  "ALLOWED_WRITE_ROOTS",
1028
1111
  "ALLOWED_COMMAND_PREFIXES",
1029
1112
  "RUNTIME_BASH_ENABLED",
1113
+ "RUNTIME_EXEC_ENABLED",
1114
+ "RUNTIME_EXEC_SECURITY",
1115
+ "RUNTIME_EXEC_ASK",
1116
+ "RUNTIME_EXEC_APPROVAL_TIMEOUT_MS",
1117
+ "RUNTIME_EXEC_ALLOWLIST",
1118
+ "RUNTIME_EXEC_SAFE_BINS",
1030
1119
  "TOOL_TIMEOUT_MS",
1031
1120
  "TOOL_MAX_OUTPUT_BYTES",
1032
1121
  "ENABLE_DISCORD",
@@ -2050,6 +2139,37 @@ var PiAgentManager = class {
2050
2139
  }
2051
2140
  };
2052
2141
 
2142
+ //#endregion
2143
+ //#region src/channels/command-auth.ts
2144
+ function normalizeAllowFromEntry(value) {
2145
+ return String(value).trim().toLowerCase();
2146
+ }
2147
+ function senderCandidates(msg) {
2148
+ const candidates = /* @__PURE__ */ new Set();
2149
+ const userId = msg.userId.trim();
2150
+ if (userId) candidates.add(userId.toLowerCase());
2151
+ const displayName = msg.displayName.trim();
2152
+ if (displayName.startsWith("@")) candidates.add(displayName.toLowerCase());
2153
+ return Array.from(candidates);
2154
+ }
2155
+ function resolveCommandsAllowFrom(config, channel) {
2156
+ const entries = config.commands.allowFrom;
2157
+ if (Object.keys(entries).length === 0) return null;
2158
+ const providerSpecific = entries[channel];
2159
+ if (Array.isArray(providerSpecific)) return providerSpecific;
2160
+ const global = entries["*"];
2161
+ if (Array.isArray(global)) return global;
2162
+ return [];
2163
+ }
2164
+ function isCommandAuthorized(config, msg) {
2165
+ const allowFrom = resolveCommandsAllowFrom(config, msg.channel);
2166
+ if (allowFrom === null) return true;
2167
+ const normalizedAllow = new Set(allowFrom.map((entry) => normalizeAllowFromEntry(entry)));
2168
+ if (normalizedAllow.has("*")) return true;
2169
+ for (const candidate of senderCandidates(msg)) if (normalizedAllow.has(candidate)) return true;
2170
+ return false;
2171
+ }
2172
+
2053
2173
  //#endregion
2054
2174
  //#region src/channels/discord.ts
2055
2175
  const DISCORD_CHUNK_LIMIT = 1900;
@@ -3084,99 +3204,6 @@ async function requestDaemonRestartFromCurrentProcess(env = process.env) {
3084
3204
  };
3085
3205
  }
3086
3206
 
3087
- //#endregion
3088
- //#region src/compat/openclaw-mirror.ts
3089
- function resolveOpenClawHome(env = process.env) {
3090
- const override = env.OPENCLAW_STATE_DIR?.trim();
3091
- if (override) return path.resolve(override.startsWith("~") ? path.join(os.homedir(), override.slice(1)) : override);
3092
- return path.join(os.homedir(), ".openclaw");
3093
- }
3094
- function resolveOpenClawConfigPath(openclawHome) {
3095
- return path.join(openclawHome, "openclaw.json");
3096
- }
3097
- function resolveOpenClawSharedSkillsPath(openclawHome) {
3098
- return path.join(openclawHome, "skills");
3099
- }
3100
- function buildMirrorConfig(config) {
3101
- const fallbackWorkspace = config.agents.defaults.workspace || path.join(config.hovclawHome, "workspace");
3102
- const agentList = config.agents.list.length > 0 ? config.agents.list : [{
3103
- id: "main",
3104
- name: "Main",
3105
- workspace: fallbackWorkspace,
3106
- default: true
3107
- }];
3108
- const extraDirs = /* @__PURE__ */ new Set();
3109
- extraDirs.add(config.skillsDir);
3110
- for (const agent of agentList) {
3111
- const workspace = (agent.workspace || fallbackWorkspace).trim();
3112
- if (!workspace) continue;
3113
- extraDirs.add(path.join(workspace, "skills"));
3114
- }
3115
- return {
3116
- agent: { workspace: fallbackWorkspace },
3117
- agents: {
3118
- defaults: { workspace: fallbackWorkspace },
3119
- list: agentList
3120
- },
3121
- skills: { load: { extraDirs: Array.from(extraDirs) } }
3122
- };
3123
- }
3124
- function ensureDir(dirPath) {
3125
- fs.mkdirSync(dirPath, {
3126
- recursive: true,
3127
- mode: 448
3128
- });
3129
- }
3130
- function syncSkillsDir(sharedSkillsPath, sourceSkillsPath) {
3131
- if (!fs.existsSync(sourceSkillsPath)) {
3132
- ensureDir(sharedSkillsPath);
3133
- return false;
3134
- }
3135
- try {
3136
- if (fs.lstatSync(sharedSkillsPath).isSymbolicLink()) {
3137
- const linkTarget = fs.readlinkSync(sharedSkillsPath);
3138
- if (path.resolve(path.dirname(sharedSkillsPath), linkTarget) === sourceSkillsPath) return true;
3139
- fs.unlinkSync(sharedSkillsPath);
3140
- }
3141
- } catch {}
3142
- if (!fs.existsSync(sharedSkillsPath)) try {
3143
- fs.symlinkSync(sourceSkillsPath, sharedSkillsPath, "dir");
3144
- return true;
3145
- } catch {}
3146
- ensureDir(sharedSkillsPath);
3147
- for (const entry of fs.readdirSync(sourceSkillsPath, { withFileTypes: true })) {
3148
- const src = path.join(sourceSkillsPath, entry.name);
3149
- const dst = path.join(sharedSkillsPath, entry.name);
3150
- if (entry.isDirectory()) {
3151
- fs.cpSync(src, dst, {
3152
- recursive: true,
3153
- force: true
3154
- });
3155
- continue;
3156
- }
3157
- if (entry.isFile()) fs.copyFileSync(src, dst);
3158
- }
3159
- return false;
3160
- }
3161
- function writeOpenClawMirror(config) {
3162
- const openclawHome = resolveOpenClawHome();
3163
- const configPath = resolveOpenClawConfigPath(openclawHome);
3164
- const sharedSkillsPath = resolveOpenClawSharedSkillsPath(openclawHome);
3165
- ensureDir(openclawHome);
3166
- const mirrorConfig = buildMirrorConfig(config);
3167
- fs.writeFileSync(configPath, `${JSON.stringify(mirrorConfig, null, 2)}\n`, {
3168
- encoding: "utf8",
3169
- mode: 384
3170
- });
3171
- fs.chmodSync(configPath, 384);
3172
- return {
3173
- openclawHome,
3174
- configPath,
3175
- sharedSkillsPath,
3176
- linkedSharedSkills: syncSkillsDir(sharedSkillsPath, config.skillsDir)
3177
- };
3178
- }
3179
-
3180
3207
  //#endregion
3181
3208
  //#region src/models/catalog.ts
3182
3209
  function buildModelCatalog(aliases) {
@@ -3197,37 +3224,6 @@ function buildModelCatalog(aliases) {
3197
3224
  }).filter((entry) => Boolean(entry)).sort((a, b) => a.ref.localeCompare(b.ref));
3198
3225
  }
3199
3226
 
3200
- //#endregion
3201
- //#region src/channels/command-auth.ts
3202
- function normalizeAllowFromEntry(value) {
3203
- return String(value).trim().toLowerCase();
3204
- }
3205
- function senderCandidates(msg) {
3206
- const candidates = /* @__PURE__ */ new Set();
3207
- const userId = msg.userId.trim();
3208
- if (userId) candidates.add(userId.toLowerCase());
3209
- const displayName = msg.displayName.trim();
3210
- if (displayName.startsWith("@")) candidates.add(displayName.toLowerCase());
3211
- return Array.from(candidates);
3212
- }
3213
- function resolveCommandsAllowFrom(config, channel) {
3214
- const entries = config.commands.allowFrom;
3215
- if (Object.keys(entries).length === 0) return null;
3216
- const providerSpecific = entries[channel];
3217
- if (Array.isArray(providerSpecific)) return providerSpecific;
3218
- const global = entries["*"];
3219
- if (Array.isArray(global)) return global;
3220
- return [];
3221
- }
3222
- function isCommandAuthorized(config, msg) {
3223
- const allowFrom = resolveCommandsAllowFrom(config, msg.channel);
3224
- if (allowFrom === null) return true;
3225
- const normalizedAllow = new Set(allowFrom.map((entry) => normalizeAllowFromEntry(entry)));
3226
- if (normalizedAllow.has("*")) return true;
3227
- for (const candidate of senderCandidates(msg)) if (normalizedAllow.has(candidate)) return true;
3228
- return false;
3229
- }
3230
-
3231
3227
  //#endregion
3232
3228
  //#region src/channels/commands-registry.ts
3233
3229
  const TELEGRAM_COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/;
@@ -3338,6 +3334,11 @@ const BASE_COMMANDS = [
3338
3334
  nativeName: "bash",
3339
3335
  description: "Run a shell command through the assistant (if enabled)"
3340
3336
  },
3337
+ {
3338
+ key: "approve",
3339
+ nativeName: "approve",
3340
+ description: "Approve or deny pending exec request"
3341
+ },
3341
3342
  {
3342
3343
  key: "restart",
3343
3344
  nativeName: "restart",
@@ -3583,7 +3584,6 @@ function reloadRuntimeConfig() {
3583
3584
  function persistFileConfig(next) {
3584
3585
  saveConfigFile(next);
3585
3586
  reloadRuntimeConfig();
3586
- writeOpenClawMirror(config);
3587
3587
  }
3588
3588
  function parseConfigPath(raw) {
3589
3589
  const pathValue = raw?.trim();
@@ -4271,7 +4271,7 @@ function emptyState() {
4271
4271
  pending: {}
4272
4272
  };
4273
4273
  }
4274
- function nowIso$1() {
4274
+ function nowIso$2() {
4275
4275
  return (/* @__PURE__ */ new Date()).toISOString();
4276
4276
  }
4277
4277
  function randomCode() {
@@ -4312,7 +4312,7 @@ var TelegramPairingStore = class {
4312
4312
  ...pendingByAccount,
4313
4313
  [code]: {
4314
4314
  userId,
4315
- createdAt: nowIso$1()
4315
+ createdAt: nowIso$2()
4316
4316
  }
4317
4317
  };
4318
4318
  this.writeState(state);
@@ -4476,7 +4476,7 @@ function redactSensitiveData(value) {
4476
4476
 
4477
4477
  //#endregion
4478
4478
  //#region src/db.ts
4479
- function nowIso() {
4479
+ function nowIso$1() {
4480
4480
  return (/* @__PURE__ */ new Date()).toISOString();
4481
4481
  }
4482
4482
  var HovClawDb = class {
@@ -4604,7 +4604,7 @@ var HovClawDb = class {
4604
4604
  return Boolean(row?.name);
4605
4605
  }
4606
4606
  upsertSession(parts, sessionKey, model, options) {
4607
- const createdAt = nowIso();
4607
+ const createdAt = nowIso$1();
4608
4608
  this.db.prepare(`
4609
4609
  INSERT INTO sessions (
4610
4610
  session_key,
@@ -4699,7 +4699,7 @@ var HovClawDb = class {
4699
4699
  ON CONFLICT(account_id) DO UPDATE SET
4700
4700
  last_update_id = excluded.last_update_id,
4701
4701
  updated_at = excluded.updated_at
4702
- `).run(accountId, updateId, nowIso());
4702
+ `).run(accountId, updateId, nowIso$1());
4703
4703
  }
4704
4704
  hasTelegramDedupe(accountId, updateId) {
4705
4705
  return typeof this.db.prepare(`
@@ -4712,7 +4712,7 @@ var HovClawDb = class {
4712
4712
  this.db.prepare(`
4713
4713
  INSERT OR IGNORE INTO telegram_dedupe (account_id, update_id, updated_at)
4714
4714
  VALUES (?, ?, ?)
4715
- `).run(accountId, updateId, nowIso());
4715
+ `).run(accountId, updateId, nowIso$1());
4716
4716
  }
4717
4717
  pruneTelegramDedupe(olderThanIso) {
4718
4718
  return this.db.prepare(`
@@ -4721,7 +4721,7 @@ var HovClawDb = class {
4721
4721
  `).run(olderThanIso).changes;
4722
4722
  }
4723
4723
  appendMessage(sessionKey, role, content) {
4724
- this.db.prepare(`INSERT INTO messages (session_key, role, content, created_at) VALUES (?, ?, ?, ?)`).run(sessionKey, role, content, nowIso());
4724
+ this.db.prepare(`INSERT INTO messages (session_key, role, content, created_at) VALUES (?, ?, ?, ?)`).run(sessionKey, role, content, nowIso$1());
4725
4725
  }
4726
4726
  getMessages(sessionKey) {
4727
4727
  return this.db.prepare(`
@@ -4742,7 +4742,7 @@ var HovClawDb = class {
4742
4742
  ON CONFLICT(session_key) DO UPDATE SET
4743
4743
  state_json = excluded.state_json,
4744
4744
  updated_at = excluded.updated_at
4745
- `).run(sessionKey, stateJson, nowIso());
4745
+ `).run(sessionKey, stateJson, nowIso$1());
4746
4746
  }
4747
4747
  getAgentState(sessionKey) {
4748
4748
  return this.db.prepare(`SELECT state_json FROM agent_state WHERE session_key = ?`).get(sessionKey)?.state_json ?? null;
@@ -4763,7 +4763,7 @@ var HovClawDb = class {
4763
4763
  cost_usd,
4764
4764
  created_at
4765
4765
  ) VALUES (?, ?, ?, ?, ?, ?, ?)
4766
- `).run(record.sessionKey, record.provider, record.model, record.inputTokens, record.outputTokens, record.costUsd, record.createdAt ?? nowIso());
4766
+ `).run(record.sessionKey, record.provider, record.model, record.inputTokens, record.outputTokens, record.costUsd, record.createdAt ?? nowIso$1());
4767
4767
  }
4768
4768
  upsertScheduledJob(job) {
4769
4769
  this.db.prepare(`
@@ -4897,7 +4897,7 @@ var HovClawDb = class {
4897
4897
  this.db.prepare(`
4898
4898
  INSERT INTO audit_log (ts, session_key, actor, event_type, payload_json)
4899
4899
  VALUES (?, ?, ?, ?, ?)
4900
- `).run(record.ts ?? nowIso(), record.sessionKey ?? null, record.actor, record.eventType, JSON.stringify(sanitizedPayload));
4900
+ `).run(record.ts ?? nowIso$1(), record.sessionKey ?? null, record.actor, record.eventType, JSON.stringify(sanitizedPayload));
4901
4901
  }
4902
4902
  getAuditEvents(eventType) {
4903
4903
  const query = eventType ? `SELECT ts, session_key, actor, event_type, payload_json FROM audit_log WHERE event_type = ? ORDER BY id DESC` : `SELECT ts, session_key, actor, event_type, payload_json FROM audit_log ORDER BY id DESC`;
@@ -4911,6 +4911,585 @@ var HovClawDb = class {
4911
4911
  }
4912
4912
  };
4913
4913
 
4914
+ //#endregion
4915
+ //#region src/exec-approvals.ts
4916
+ const DEFAULT_FILE_VERSION = 1;
4917
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 12e4;
4918
+ const ONE_TIME_APPROVAL_TTL_MS = 5 * 6e4;
4919
+ const SHELL_DISALLOWED_PATTERN = /[;&|`$()<>]|[\r\n]/;
4920
+ const SHELL_QUOTE_OR_ESCAPE_PATTERN = /["'\\]/;
4921
+ function nowIso() {
4922
+ return (/* @__PURE__ */ new Date()).toISOString();
4923
+ }
4924
+ function normalizeExecSecurity(value) {
4925
+ if (value === "deny" || value === "allowlist" || value === "full") return value;
4926
+ return null;
4927
+ }
4928
+ function normalizeExecAsk(value) {
4929
+ if (value === "off" || value === "on-miss" || value === "always") return value;
4930
+ return null;
4931
+ }
4932
+ function normalizeAllowlist(entries) {
4933
+ if (!Array.isArray(entries)) return [];
4934
+ const out = [];
4935
+ const seen = /* @__PURE__ */ new Set();
4936
+ for (const entry of entries) {
4937
+ if (!entry || typeof entry !== "object") continue;
4938
+ const pattern = typeof entry.pattern === "string" ? entry.pattern.trim() : "";
4939
+ if (!pattern) continue;
4940
+ const lowered = pattern.toLowerCase();
4941
+ if (seen.has(lowered)) continue;
4942
+ seen.add(lowered);
4943
+ out.push({
4944
+ pattern,
4945
+ lastUsedAt: typeof entry.lastUsedAt === "string" ? entry.lastUsedAt : void 0,
4946
+ lastUsedCommand: typeof entry.lastUsedCommand === "string" ? entry.lastUsedCommand : void 0,
4947
+ lastResolvedPath: typeof entry.lastResolvedPath === "string" ? entry.lastResolvedPath : void 0
4948
+ });
4949
+ }
4950
+ return out;
4951
+ }
4952
+ function normalizeFile(value, defaults) {
4953
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {
4954
+ version: DEFAULT_FILE_VERSION,
4955
+ defaults: {
4956
+ security: defaults.security,
4957
+ ask: defaults.ask
4958
+ },
4959
+ agents: {}
4960
+ };
4961
+ const raw = value;
4962
+ const security = typeof raw.defaults?.security === "string" ? normalizeExecSecurity(raw.defaults.security) : null;
4963
+ const ask = typeof raw.defaults?.ask === "string" ? normalizeExecAsk(raw.defaults.ask) : null;
4964
+ const agents = {};
4965
+ if (raw.agents && typeof raw.agents === "object" && !Array.isArray(raw.agents)) for (const [agentId, agentValue] of Object.entries(raw.agents)) {
4966
+ if (!agentId.trim()) continue;
4967
+ agents[agentId] = { allowlist: agentValue && typeof agentValue === "object" && !Array.isArray(agentValue) ? normalizeAllowlist(agentValue.allowlist) : [] };
4968
+ }
4969
+ return {
4970
+ version: DEFAULT_FILE_VERSION,
4971
+ defaults: {
4972
+ security: security ?? defaults.security,
4973
+ ask: ask ?? defaults.ask
4974
+ },
4975
+ agents
4976
+ };
4977
+ }
4978
+ function normalizePathForMatch(value) {
4979
+ if (process.platform === "win32") return value.replace(/\\/g, "/").toLowerCase();
4980
+ return value;
4981
+ }
4982
+ function resolveExecutablePath(executable, cwd, env) {
4983
+ if (!executable.trim()) return;
4984
+ if (executable.includes("/") || executable.includes("\\")) {
4985
+ const absolute = path.isAbsolute(executable) ? path.resolve(executable) : path.resolve(cwd, executable);
4986
+ if (fs.existsSync(absolute)) return absolute;
4987
+ return;
4988
+ }
4989
+ const pathEntries = (env.PATH || process.env.PATH || "").split(path.delimiter).filter(Boolean);
4990
+ for (const entry of pathEntries) {
4991
+ const candidate = path.join(entry, executable);
4992
+ if (fs.existsSync(candidate)) return candidate;
4993
+ }
4994
+ }
4995
+ function analyzeCommand(command, cwd, env) {
4996
+ const trimmed = command.trim();
4997
+ if (!trimmed) return {
4998
+ ok: false,
4999
+ reason: "empty command",
5000
+ args: []
5001
+ };
5002
+ if (SHELL_DISALLOWED_PATTERN.test(trimmed)) return {
5003
+ ok: false,
5004
+ reason: "disallowed shell syntax",
5005
+ args: []
5006
+ };
5007
+ if (SHELL_QUOTE_OR_ESCAPE_PATTERN.test(trimmed)) return {
5008
+ ok: false,
5009
+ reason: "quoted/escaped shell syntax is not allowed",
5010
+ args: []
5011
+ };
5012
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
5013
+ if (tokens.length === 0) return {
5014
+ ok: false,
5015
+ reason: "empty command",
5016
+ args: []
5017
+ };
5018
+ const executableRaw = tokens[0];
5019
+ const resolvedPath = resolveExecutablePath(executableRaw, cwd, env);
5020
+ return {
5021
+ ok: true,
5022
+ executableRaw,
5023
+ executableName: resolvedPath ? path.basename(resolvedPath) : path.basename(executableRaw),
5024
+ resolvedPath,
5025
+ args: tokens.slice(1)
5026
+ };
5027
+ }
5028
+ function hasPathLikeArg(value) {
5029
+ if (!value || value === "-") return false;
5030
+ if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("~/") || /^[A-Za-z]:[\\/]/.test(value)) return true;
5031
+ return value.includes("/") || value.includes("\\");
5032
+ }
5033
+ function isSafeBinUsage(analysis, safeBins) {
5034
+ if (!analysis.ok || !analysis.executableName) return false;
5035
+ const executable = analysis.executableName.toLowerCase();
5036
+ if (!safeBins.has(executable)) return false;
5037
+ for (const arg of analysis.args) {
5038
+ if (arg.startsWith("-")) {
5039
+ const eqIndex = arg.indexOf("=");
5040
+ if (eqIndex > 0 && hasPathLikeArg(arg.slice(eqIndex + 1))) return false;
5041
+ continue;
5042
+ }
5043
+ if (hasPathLikeArg(arg)) return false;
5044
+ }
5045
+ return true;
5046
+ }
5047
+ function matchAllowlistPattern(pattern, executableName, resolvedPath) {
5048
+ const trimmed = pattern.trim();
5049
+ if (!trimmed) return false;
5050
+ const normalizedPattern = normalizePathForMatch(trimmed);
5051
+ const normalizedExecutable = executableName.toLowerCase();
5052
+ const normalizedResolved = resolvedPath ? normalizePathForMatch(resolvedPath) : "";
5053
+ if (!trimmed.includes("/") && !trimmed.includes("\\") && !trimmed.endsWith("*")) return normalizedPattern === normalizedExecutable;
5054
+ if (trimmed.endsWith("*")) {
5055
+ const prefix = normalizePathForMatch(trimmed.slice(0, -1));
5056
+ return normalizedResolved.length > 0 && normalizedResolved.startsWith(prefix) || normalizedExecutable.startsWith(prefix);
5057
+ }
5058
+ if (normalizedResolved.length > 0 && normalizedResolved === normalizedPattern) return true;
5059
+ return normalizedExecutable === normalizedPattern;
5060
+ }
5061
+ function makeCommandApprovalKey(agentId, command) {
5062
+ return crypto.createHash("sha256").update(`${agentId}::${command.trim()}`).digest("hex");
5063
+ }
5064
+ var ExecApprovalsManager = class {
5065
+ filePath;
5066
+ onRequested;
5067
+ onResolved;
5068
+ defaults;
5069
+ pending = /* @__PURE__ */ new Map();
5070
+ oneTimeApprovals = /* @__PURE__ */ new Map();
5071
+ constructor(options) {
5072
+ this.filePath = path.join(options.storeDir, "exec-approvals.json");
5073
+ this.defaults = options.defaults;
5074
+ this.onRequested = options.onRequested;
5075
+ this.onResolved = options.onResolved;
5076
+ }
5077
+ getConfigPath() {
5078
+ return this.filePath;
5079
+ }
5080
+ ensureDir() {
5081
+ fs.mkdirSync(path.dirname(this.filePath), {
5082
+ recursive: true,
5083
+ mode: 448
5084
+ });
5085
+ }
5086
+ readRaw() {
5087
+ if (!fs.existsSync(this.filePath)) return null;
5088
+ const raw = fs.readFileSync(this.filePath, "utf8");
5089
+ return JSON.parse(raw);
5090
+ }
5091
+ writeFile(value) {
5092
+ this.ensureDir();
5093
+ fs.writeFileSync(this.filePath, `${JSON.stringify(value, null, 2)}\n`, {
5094
+ encoding: "utf8",
5095
+ mode: 384
5096
+ });
5097
+ fs.chmodSync(this.filePath, 384);
5098
+ }
5099
+ getSnapshot() {
5100
+ try {
5101
+ return normalizeFile(this.readRaw(), this.defaults);
5102
+ } catch {
5103
+ return normalizeFile(null, this.defaults);
5104
+ }
5105
+ }
5106
+ setSnapshot(next) {
5107
+ const normalized = normalizeFile(next, this.defaults);
5108
+ this.writeFile(normalized);
5109
+ return normalized;
5110
+ }
5111
+ resolveDefaults(policy) {
5112
+ const snapshot = this.getSnapshot();
5113
+ return {
5114
+ security: snapshot.defaults.security ?? policy.security,
5115
+ ask: snapshot.defaults.ask ?? policy.ask
5116
+ };
5117
+ }
5118
+ getAgentAllowlist(agentId, policyAllowlist) {
5119
+ const byAgent = this.getSnapshot().agents[agentId]?.allowlist ?? [];
5120
+ const merged = [];
5121
+ const seen = /* @__PURE__ */ new Set();
5122
+ for (const entry of policyAllowlist) {
5123
+ const pattern = entry.trim();
5124
+ if (!pattern) continue;
5125
+ const key = pattern.toLowerCase();
5126
+ if (seen.has(key)) continue;
5127
+ seen.add(key);
5128
+ merged.push({ pattern });
5129
+ }
5130
+ for (const entry of byAgent) {
5131
+ const pattern = entry.pattern.trim();
5132
+ if (!pattern) continue;
5133
+ const key = pattern.toLowerCase();
5134
+ if (seen.has(key)) continue;
5135
+ seen.add(key);
5136
+ merged.push(entry);
5137
+ }
5138
+ return merged;
5139
+ }
5140
+ addAllowlistEntry(agentId, pattern) {
5141
+ const normalizedPattern = pattern.trim();
5142
+ if (!normalizedPattern) return;
5143
+ const snapshot = this.getSnapshot();
5144
+ const current = snapshot.agents[agentId]?.allowlist ?? [];
5145
+ if (current.some((entry) => entry.pattern.toLowerCase() === normalizedPattern.toLowerCase())) return;
5146
+ const nextAllowlist = [...current, {
5147
+ pattern: normalizedPattern,
5148
+ lastUsedAt: nowIso()
5149
+ }];
5150
+ snapshot.agents[agentId] = { allowlist: nextAllowlist };
5151
+ this.writeFile(snapshot);
5152
+ }
5153
+ recordAllowlistUse(agentId, pattern, command, resolvedPath) {
5154
+ const snapshot = this.getSnapshot();
5155
+ const current = snapshot.agents[agentId]?.allowlist ?? [];
5156
+ const lowered = pattern.toLowerCase();
5157
+ const nextAllowlist = current.map((entry) => {
5158
+ if (entry.pattern.toLowerCase() !== lowered) return entry;
5159
+ return {
5160
+ ...entry,
5161
+ lastUsedAt: nowIso(),
5162
+ lastUsedCommand: command,
5163
+ lastResolvedPath: resolvedPath
5164
+ };
5165
+ });
5166
+ snapshot.agents[agentId] = { allowlist: nextAllowlist };
5167
+ this.writeFile(snapshot);
5168
+ }
5169
+ request(params) {
5170
+ const now = Date.now();
5171
+ const timeoutMs = Math.max(5e3, Number.isFinite(params.timeoutMs) ? Math.floor(params.timeoutMs) : DEFAULT_APPROVAL_TIMEOUT_MS);
5172
+ const record = {
5173
+ id: crypto.randomUUID(),
5174
+ createdAtMs: now,
5175
+ expiresAtMs: now + timeoutMs,
5176
+ command: params.command,
5177
+ agentId: params.agentId,
5178
+ sessionKey: params.sessionKey,
5179
+ resolvedPath: params.resolvedPath,
5180
+ timeoutMs,
5181
+ target: params.target
5182
+ };
5183
+ this.pending.set(record.id, {
5184
+ record,
5185
+ onApproved: params.onApproved
5186
+ });
5187
+ this.onRequested?.(record);
5188
+ return record;
5189
+ }
5190
+ getPending(recordId) {
5191
+ const pending = this.pending.get(recordId);
5192
+ if (!pending) return null;
5193
+ if (pending.record.expiresAtMs <= Date.now()) {
5194
+ this.pending.delete(recordId);
5195
+ return null;
5196
+ }
5197
+ return pending.record;
5198
+ }
5199
+ resolve(recordId, decision, resolvedBy) {
5200
+ const pending = this.pending.get(recordId);
5201
+ if (!pending) return null;
5202
+ if (pending.record.expiresAtMs <= Date.now()) {
5203
+ this.pending.delete(recordId);
5204
+ return null;
5205
+ }
5206
+ this.pending.delete(recordId);
5207
+ const resolvedRecord = {
5208
+ ...pending.record,
5209
+ decision,
5210
+ resolvedBy,
5211
+ resolvedAtMs: Date.now()
5212
+ };
5213
+ if (decision === "allow-always" && resolvedRecord.resolvedPath) this.addAllowlistEntry(resolvedRecord.agentId, resolvedRecord.resolvedPath);
5214
+ if (decision === "allow-once") {
5215
+ const key = makeCommandApprovalKey(resolvedRecord.agentId, resolvedRecord.command);
5216
+ this.oneTimeApprovals.set(key, {
5217
+ key,
5218
+ expiresAtMs: Date.now() + ONE_TIME_APPROVAL_TTL_MS
5219
+ });
5220
+ }
5221
+ this.onResolved?.(resolvedRecord);
5222
+ if ((decision === "allow-once" || decision === "allow-always") && pending.onApproved) pending.onApproved(resolvedRecord).catch(() => {});
5223
+ return resolvedRecord;
5224
+ }
5225
+ consumeOneTimeApproval(agentId, command) {
5226
+ const key = makeCommandApprovalKey(agentId, command);
5227
+ const pending = this.oneTimeApprovals.get(key);
5228
+ if (!pending) return false;
5229
+ this.oneTimeApprovals.delete(key);
5230
+ if (pending.expiresAtMs <= Date.now()) return false;
5231
+ return true;
5232
+ }
5233
+ };
5234
+ function evaluateExecPolicy(params) {
5235
+ const cwd = params.cwd || process.cwd();
5236
+ const env = params.env || process.env;
5237
+ const analysis = analyzeCommand(params.command, cwd, env);
5238
+ const defaults = params.manager.resolveDefaults(params.policy);
5239
+ const security = defaults.security;
5240
+ const ask = defaults.ask;
5241
+ if (!analysis.ok) return {
5242
+ analysisOk: false,
5243
+ reason: analysis.reason,
5244
+ executableName: analysis.executableName,
5245
+ resolvedPath: analysis.resolvedPath,
5246
+ allowlistSatisfied: false,
5247
+ safeBinSatisfied: false,
5248
+ requiresApproval: false,
5249
+ denied: true,
5250
+ deniedReason: analysis.reason ? `exec denied: ${analysis.reason}` : "exec denied: invalid command"
5251
+ };
5252
+ if (security === "deny") return {
5253
+ analysisOk: analysis.ok,
5254
+ reason: analysis.reason,
5255
+ executableName: analysis.executableName,
5256
+ resolvedPath: analysis.resolvedPath,
5257
+ allowlistSatisfied: false,
5258
+ safeBinSatisfied: false,
5259
+ requiresApproval: false,
5260
+ denied: true,
5261
+ deniedReason: "exec denied: security=deny"
5262
+ };
5263
+ const mergedAllowlist = params.manager.getAgentAllowlist(params.agentId, params.policy.allowlist);
5264
+ const safeBins = new Set(params.policy.safeBins.map((entry) => entry.trim().toLowerCase()).filter(Boolean));
5265
+ const oneTimeApproved = params.manager.consumeOneTimeApproval(params.agentId, params.command);
5266
+ const safeBinSatisfied = isSafeBinUsage(analysis, safeBins);
5267
+ let allowlistSatisfied = false;
5268
+ let allowlistPattern;
5269
+ if (analysis.ok && analysis.executableName) if (oneTimeApproved) {
5270
+ allowlistSatisfied = true;
5271
+ allowlistPattern = "one-time-approval";
5272
+ } else {
5273
+ for (const entry of mergedAllowlist) if (matchAllowlistPattern(entry.pattern, analysis.executableName, analysis.resolvedPath)) {
5274
+ allowlistSatisfied = true;
5275
+ allowlistPattern = entry.pattern;
5276
+ break;
5277
+ }
5278
+ if (!allowlistSatisfied && safeBinSatisfied) {
5279
+ allowlistSatisfied = true;
5280
+ allowlistPattern = `safe-bin:${analysis.executableName.toLowerCase()}`;
5281
+ }
5282
+ }
5283
+ if (security === "full") {
5284
+ if (ask === "always") return {
5285
+ analysisOk: analysis.ok,
5286
+ reason: analysis.reason,
5287
+ executableName: analysis.executableName,
5288
+ resolvedPath: analysis.resolvedPath,
5289
+ allowlistSatisfied,
5290
+ allowlistPattern,
5291
+ safeBinSatisfied,
5292
+ requiresApproval: true,
5293
+ denied: false
5294
+ };
5295
+ return {
5296
+ analysisOk: analysis.ok,
5297
+ reason: analysis.reason,
5298
+ executableName: analysis.executableName,
5299
+ resolvedPath: analysis.resolvedPath,
5300
+ allowlistSatisfied,
5301
+ allowlistPattern,
5302
+ safeBinSatisfied,
5303
+ requiresApproval: false,
5304
+ denied: false
5305
+ };
5306
+ }
5307
+ if (allowlistSatisfied && ask !== "always") return {
5308
+ analysisOk: analysis.ok,
5309
+ reason: analysis.reason,
5310
+ executableName: analysis.executableName,
5311
+ resolvedPath: analysis.resolvedPath,
5312
+ allowlistSatisfied,
5313
+ allowlistPattern,
5314
+ safeBinSatisfied,
5315
+ requiresApproval: false,
5316
+ denied: false
5317
+ };
5318
+ if (ask === "off") return {
5319
+ analysisOk: analysis.ok,
5320
+ reason: analysis.reason,
5321
+ executableName: analysis.executableName,
5322
+ resolvedPath: analysis.resolvedPath,
5323
+ allowlistSatisfied,
5324
+ allowlistPattern,
5325
+ safeBinSatisfied,
5326
+ requiresApproval: false,
5327
+ denied: true,
5328
+ deniedReason: allowlistSatisfied ? "exec denied: ask=off" : "exec denied: allowlist miss"
5329
+ };
5330
+ return {
5331
+ analysisOk: analysis.ok,
5332
+ reason: analysis.reason,
5333
+ executableName: analysis.executableName,
5334
+ resolvedPath: analysis.resolvedPath,
5335
+ allowlistSatisfied,
5336
+ allowlistPattern,
5337
+ safeBinSatisfied,
5338
+ requiresApproval: true,
5339
+ denied: false
5340
+ };
5341
+ }
5342
+
5343
+ //#endregion
5344
+ //#region src/exec-chat.ts
5345
+ const APPROVE_USAGE = "Usage: /approve <id> allow-once|allow-always|deny";
5346
+ const BASH_USAGE = "Usage: /bash <command>";
5347
+ function normalizeDecision(value) {
5348
+ const normalized = value.trim().toLowerCase();
5349
+ if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") return normalized;
5350
+ return null;
5351
+ }
5352
+ function formatExecOutput$1(result) {
5353
+ const lines = [
5354
+ `exitCode: ${result.exitCode}`,
5355
+ result.timedOut ? "timedOut: true" : "timedOut: false",
5356
+ result.truncated ? "truncated: true" : "truncated: false",
5357
+ ""
5358
+ ];
5359
+ if (result.stdout) {
5360
+ lines.push("stdout:");
5361
+ lines.push(result.stdout);
5362
+ lines.push("");
5363
+ }
5364
+ if (result.stderr) {
5365
+ lines.push("stderr:");
5366
+ lines.push(result.stderr);
5367
+ }
5368
+ if (!result.stdout && !result.stderr) lines.push("No output.");
5369
+ return lines.join("\n").trim();
5370
+ }
5371
+ async function executeApprovedCommand(options) {
5372
+ const result = await options.runtime.exec(options.command, { timeoutMs: options.timeoutMs });
5373
+ await options.channel.sendMessage(options.target, [
5374
+ `Approval granted. Executed: ${options.command}`,
5375
+ "",
5376
+ formatExecOutput$1(result)
5377
+ ].join("\n"));
5378
+ }
5379
+ async function handleExecChatCommand(options) {
5380
+ const trimmed = options.msg.text.trim();
5381
+ if (!trimmed.startsWith("/")) return { handled: false };
5382
+ const parsed = trimmed.split(/\s+/);
5383
+ const command = (parsed[0] || "").toLowerCase();
5384
+ if (command === "/approve") {
5385
+ if (!options.commandAuthorized) {
5386
+ await options.channel.sendMessage(options.target, "You are not authorized to use this command.");
5387
+ return { handled: true };
5388
+ }
5389
+ const approvalId = parsed[1]?.trim();
5390
+ const decision = parsed[2] ? normalizeDecision(parsed[2]) : null;
5391
+ if (!approvalId || !decision) {
5392
+ await options.channel.sendMessage(options.target, APPROVE_USAGE);
5393
+ return { handled: true };
5394
+ }
5395
+ if (!options.execApprovals.resolve(approvalId, decision, options.msg.userId)) {
5396
+ await options.channel.sendMessage(options.target, `Unknown or expired approval id: ${approvalId}`);
5397
+ return { handled: true };
5398
+ }
5399
+ await options.channel.sendMessage(options.target, `Approval ${decision} recorded for ${approvalId}.`);
5400
+ options.audit({
5401
+ sessionKey: options.sessionKey,
5402
+ actor: "channel",
5403
+ eventType: "exec.approval.resolve",
5404
+ payload: {
5405
+ id: approvalId,
5406
+ decision,
5407
+ resolvedBy: options.msg.userId
5408
+ }
5409
+ });
5410
+ return { handled: true };
5411
+ }
5412
+ if (command !== "/bash") return { handled: false };
5413
+ if (!options.execPolicy.enabled) {
5414
+ await options.channel.sendMessage(options.target, "/bash is disabled. Set runtime.tools.exec.enabled=true (or runtime.tools.bashEnabled=true) to enable.");
5415
+ return { handled: true };
5416
+ }
5417
+ if (!options.commandAuthorized) {
5418
+ await options.channel.sendMessage(options.target, "You are not authorized to use this command.");
5419
+ return { handled: true };
5420
+ }
5421
+ if (!options.msg.text.slice(5).trim()) {
5422
+ await options.channel.sendMessage(options.target, BASH_USAGE);
5423
+ return { handled: true };
5424
+ }
5425
+ const bashCommand = options.msg.text.slice(5).trim();
5426
+ const evaluation = evaluateExecPolicy({
5427
+ command: bashCommand,
5428
+ agentId: options.agentId,
5429
+ policy: options.execPolicy,
5430
+ manager: options.execApprovals,
5431
+ cwd: process.cwd(),
5432
+ env: process.env
5433
+ });
5434
+ options.audit({
5435
+ sessionKey: options.sessionKey,
5436
+ actor: "channel",
5437
+ eventType: "exec.chat.request",
5438
+ payload: {
5439
+ command: bashCommand,
5440
+ security: options.execPolicy.security,
5441
+ ask: options.execPolicy.ask,
5442
+ allowlistSatisfied: evaluation.allowlistSatisfied,
5443
+ requiresApproval: evaluation.requiresApproval
5444
+ }
5445
+ });
5446
+ if (evaluation.denied) {
5447
+ await options.channel.sendMessage(options.target, evaluation.deniedReason || "Command denied by policy.");
5448
+ return { handled: true };
5449
+ }
5450
+ if (evaluation.requiresApproval) {
5451
+ const approval = options.execApprovals.request({
5452
+ command: bashCommand,
5453
+ agentId: options.agentId,
5454
+ sessionKey: options.sessionKey,
5455
+ resolvedPath: evaluation.resolvedPath,
5456
+ timeoutMs: options.execPolicy.approvalTimeoutMs,
5457
+ target: {
5458
+ channel: options.target.channel,
5459
+ chatId: options.target.chatId,
5460
+ accountId: options.target.accountId
5461
+ },
5462
+ onApproved: async (record) => {
5463
+ if (record.decision === "deny") return;
5464
+ await executeApprovedCommand({
5465
+ runtime: options.runtime,
5466
+ channel: options.channel,
5467
+ target: options.target,
5468
+ command: bashCommand,
5469
+ timeoutMs: options.execPolicy.approvalTimeoutMs
5470
+ });
5471
+ }
5472
+ });
5473
+ await options.channel.sendMessage(options.target, [
5474
+ "Approval required for this command.",
5475
+ `id: ${approval.id}`,
5476
+ `expiresAt: ${new Date(approval.expiresAtMs).toISOString()}`,
5477
+ "",
5478
+ `Approve with: /approve ${approval.id} allow-once|allow-always|deny`
5479
+ ].join("\n"));
5480
+ return { handled: true };
5481
+ }
5482
+ try {
5483
+ const result = await options.runtime.exec(bashCommand, { timeoutMs: options.execPolicy.approvalTimeoutMs });
5484
+ if (evaluation.allowlistPattern && !evaluation.allowlistPattern.startsWith("safe-bin:") && evaluation.allowlistPattern !== "one-time-approval") options.execApprovals.recordAllowlistUse(options.agentId, evaluation.allowlistPattern, bashCommand, evaluation.resolvedPath);
5485
+ await options.channel.sendMessage(options.target, formatExecOutput$1(result));
5486
+ } catch (error) {
5487
+ const message = error instanceof Error ? error.message : String(error);
5488
+ await options.channel.sendMessage(options.target, `Command failed: ${message}`);
5489
+ }
5490
+ return { handled: true };
5491
+ }
5492
+
4914
5493
  //#endregion
4915
5494
  //#region src/gateway/methods/agent.ts
4916
5495
  const agentParamsSchema = z.object({
@@ -5083,14 +5662,12 @@ const configGetMethod = async (_params, context) => {
5083
5662
  const configSetMethod = async (params, context) => {
5084
5663
  const parsed = configSetParamsSchema.parse(params);
5085
5664
  context.writeFileConfig(parsed.config);
5086
- writeOpenClawMirror(loadConfig());
5087
5665
  return { ok: true };
5088
5666
  };
5089
5667
  const configPatchMethod = async (params, context) => {
5090
5668
  const parsed = configPatchParamsSchema.parse(params);
5091
5669
  const merged = deepMerge(context.readFileConfig(), parsed.patch);
5092
5670
  context.writeFileConfig(merged);
5093
- writeOpenClawMirror(loadConfig());
5094
5671
  return { ok: true };
5095
5672
  };
5096
5673
 
@@ -5109,6 +5686,68 @@ const cronStatusMethod = async (_params, context) => {
5109
5686
  };
5110
5687
  };
5111
5688
 
5689
+ //#endregion
5690
+ //#region src/gateway/methods/exec-approvals.ts
5691
+ const requestParamsSchema = z.object({
5692
+ command: z.string().min(1),
5693
+ agentId: z.string().min(1).optional(),
5694
+ sessionKey: z.string().min(1).optional(),
5695
+ resolvedPath: z.string().min(1).optional(),
5696
+ timeoutMs: z.number().int().positive().optional()
5697
+ });
5698
+ const resolveParamsSchema = z.object({
5699
+ id: z.string().min(1),
5700
+ decision: z.enum([
5701
+ "allow-once",
5702
+ "allow-always",
5703
+ "deny"
5704
+ ]),
5705
+ resolvedBy: z.string().min(1).optional()
5706
+ });
5707
+ const setParamsSchema = z.object({ file: z.unknown() });
5708
+ const execApprovalRequestMethod = async (params, context) => {
5709
+ const parsed = requestParamsSchema.parse(params);
5710
+ const record = context.execApprovals.request({
5711
+ command: parsed.command,
5712
+ agentId: parsed.agentId ?? "main",
5713
+ sessionKey: parsed.sessionKey,
5714
+ resolvedPath: parsed.resolvedPath,
5715
+ timeoutMs: parsed.timeoutMs
5716
+ });
5717
+ return {
5718
+ id: record.id,
5719
+ createdAtMs: record.createdAtMs,
5720
+ expiresAtMs: record.expiresAtMs,
5721
+ command: record.command,
5722
+ agentId: record.agentId
5723
+ };
5724
+ };
5725
+ const execApprovalResolveMethod = async (params, context) => {
5726
+ const parsed = resolveParamsSchema.parse(params);
5727
+ const record = context.execApprovals.resolve(parsed.id, parsed.decision, parsed.resolvedBy);
5728
+ if (!record) throw new Error(`Unknown or expired approval id: ${parsed.id}`);
5729
+ return {
5730
+ ok: true,
5731
+ id: record.id,
5732
+ decision: parsed.decision,
5733
+ resolvedBy: parsed.resolvedBy ?? null,
5734
+ resolvedAtMs: record.resolvedAtMs ?? Date.now()
5735
+ };
5736
+ };
5737
+ const execApprovalsGetMethod = async (_params, context) => {
5738
+ return {
5739
+ path: context.execApprovals.getConfigPath(),
5740
+ file: context.execApprovals.getSnapshot()
5741
+ };
5742
+ };
5743
+ const execApprovalsSetMethod = async (params, context) => {
5744
+ const parsed = setParamsSchema.parse(params);
5745
+ return {
5746
+ ok: true,
5747
+ file: context.execApprovals.setSnapshot(parsed.file)
5748
+ };
5749
+ };
5750
+
5112
5751
  //#endregion
5113
5752
  //#region src/gateway/methods/health.ts
5114
5753
  const healthMethod = async (_params, context) => {
@@ -5347,14 +5986,20 @@ const gatewayMethodHandlers = {
5347
5986
  "chat.abort": chatAbortMethod,
5348
5987
  "cron.list": cronListMethod,
5349
5988
  "cron.status": cronStatusMethod,
5350
- "logs.tail": logsTailMethod
5989
+ "logs.tail": logsTailMethod,
5990
+ "exec.approval.request": execApprovalRequestMethod,
5991
+ "exec.approval.resolve": execApprovalResolveMethod,
5992
+ "exec.approvals.get": execApprovalsGetMethod,
5993
+ "exec.approvals.set": execApprovalsSetMethod
5351
5994
  };
5352
5995
  const gatewayEventNames = [
5353
5996
  "tick",
5354
5997
  "health",
5355
5998
  "agent",
5356
5999
  "chat",
5357
- "shutdown"
6000
+ "shutdown",
6001
+ "exec.approval.requested",
6002
+ "exec.approval.resolved"
5358
6003
  ];
5359
6004
 
5360
6005
  //#endregion
@@ -5570,6 +6215,7 @@ function resolveUiDirectory() {
5570
6215
  }
5571
6216
  var HovClawGatewayServer = class {
5572
6217
  db;
6218
+ execApprovals;
5573
6219
  agentManager;
5574
6220
  channels;
5575
6221
  readFileConfig;
@@ -5585,6 +6231,7 @@ var HovClawGatewayServer = class {
5585
6231
  constructor(options) {
5586
6232
  this.runtimeConfig = options.config;
5587
6233
  this.db = options.db;
6234
+ this.execApprovals = options.execApprovals;
5588
6235
  this.agentManager = options.agentManager;
5589
6236
  this.channels = options.channels;
5590
6237
  this.readFileConfig = options.readFileConfig;
@@ -5858,6 +6505,7 @@ var HovClawGatewayServer = class {
5858
6505
  const context = {
5859
6506
  config: this.runtimeConfig,
5860
6507
  db: this.db,
6508
+ execApprovals: this.execApprovals,
5861
6509
  agentManager: this.agentManager,
5862
6510
  channels: this.channels,
5863
6511
  startedAt: this.startedAt,
@@ -6851,150 +7499,386 @@ function normalizeErrorMessage(error) {
6851
7499
  if (error instanceof Error) return error.message;
6852
7500
  return String(error);
6853
7501
  }
6854
- function createTools({ runtime, audit, bashEnabled }) {
7502
+ function resolveExecAgentId() {
7503
+ const context = getRuntimeSessionContext();
7504
+ if (context?.agentName?.trim()) return context.agentName.trim();
7505
+ if (context?.sessionKey?.trim()) {
7506
+ const first = context.sessionKey.split(":")[0];
7507
+ if (first?.trim()) return first.trim();
7508
+ }
7509
+ return "main";
7510
+ }
7511
+ function formatExecOutput(result) {
7512
+ return [
7513
+ `exitCode: ${result.exitCode}`,
7514
+ result.timedOut ? "timedOut: true" : "timedOut: false",
7515
+ result.truncated ? "truncated: true" : "truncated: false",
7516
+ "",
7517
+ result.stdout ? `stdout:\n${result.stdout}` : "stdout: <empty>",
7518
+ "",
7519
+ result.stderr ? `stderr:\n${result.stderr}` : "stderr: <empty>"
7520
+ ].join("\n");
7521
+ }
7522
+ function firstToken(command) {
7523
+ const trimmed = command.trim();
7524
+ if (!trimmed) return "";
7525
+ return trimmed.split(/\s+/)[0] ?? "";
7526
+ }
7527
+ function diagnosticsCommandsForPlatform(platform) {
7528
+ const common = [
7529
+ {
7530
+ key: "os",
7531
+ command: "uname -a"
7532
+ },
7533
+ {
7534
+ key: "uptime",
7535
+ command: "uptime"
7536
+ },
7537
+ {
7538
+ key: "disk",
7539
+ command: "df -h"
7540
+ },
7541
+ {
7542
+ key: "processes",
7543
+ command: "ps -A"
7544
+ }
7545
+ ];
7546
+ if (platform === "darwin") return [
7547
+ ...common,
7548
+ {
7549
+ key: "memory",
7550
+ command: "vm_stat"
7551
+ },
7552
+ {
7553
+ key: "cpu",
7554
+ command: "sysctl -n machdep.cpu.brand_string"
7555
+ },
7556
+ {
7557
+ key: "network",
7558
+ command: "ifconfig"
7559
+ }
7560
+ ];
7561
+ if (platform === "linux") return [
7562
+ ...common,
7563
+ {
7564
+ key: "memory",
7565
+ command: "free -m"
7566
+ },
7567
+ {
7568
+ key: "cpu",
7569
+ command: "lscpu"
7570
+ },
7571
+ {
7572
+ key: "network",
7573
+ command: "ip addr"
7574
+ }
7575
+ ];
7576
+ if (platform === "win32") return [
7577
+ {
7578
+ key: "os",
7579
+ command: "ver"
7580
+ },
7581
+ {
7582
+ key: "uptime",
7583
+ command: "net stats workstation"
7584
+ },
7585
+ {
7586
+ key: "disk",
7587
+ command: "wmic logicaldisk get size,freespace,caption"
7588
+ },
7589
+ {
7590
+ key: "processes",
7591
+ command: "tasklist"
7592
+ },
7593
+ {
7594
+ key: "cpu",
7595
+ command: "wmic cpu get name"
7596
+ },
7597
+ {
7598
+ key: "memory",
7599
+ command: "wmic os get totalvisiblememorysize,freephysicalmemory"
7600
+ },
7601
+ {
7602
+ key: "network",
7603
+ command: "ipconfig"
7604
+ }
7605
+ ];
7606
+ return common;
7607
+ }
7608
+ function createTools({ runtime, audit, execPolicy, execApprovals }) {
6855
7609
  const parser = new Parser();
6856
- const bashTool = {
6857
- name: "bash",
6858
- label: "Bash",
6859
- description: "Run a shell command on allowed command prefixes only.",
6860
- parameters: Type.Object({
6861
- command: Type.String(),
6862
- timeoutMs: Type.Optional(Type.Number({
6863
- minimum: 1e3,
6864
- maximum: 12e4
6865
- }))
6866
- }),
6867
- execute: async (_toolCallId, params) => {
6868
- audit({
6869
- actor: "tool",
6870
- eventType: "tool.exec",
6871
- payload: { command: params.command }
7610
+ const execSchema = Type.Object({
7611
+ command: Type.String(),
7612
+ timeoutMs: Type.Optional(Type.Number({
7613
+ minimum: 1e3,
7614
+ maximum: 12e4
7615
+ }))
7616
+ });
7617
+ async function executeCommandWithPolicy(toolName, params) {
7618
+ const command = params.command.trim();
7619
+ if (!command) throw new Error("Command cannot be empty.");
7620
+ const sessionContext = getRuntimeSessionContext();
7621
+ const agentId = resolveExecAgentId();
7622
+ const evaluation = evaluateExecPolicy({
7623
+ command,
7624
+ agentId,
7625
+ policy: execPolicy,
7626
+ manager: execApprovals,
7627
+ cwd: sessionContext?.workspaceDir,
7628
+ env: process.env
7629
+ });
7630
+ audit({
7631
+ sessionKey: sessionContext?.sessionKey,
7632
+ actor: "tool",
7633
+ eventType: "tool.exec",
7634
+ payload: {
7635
+ toolName,
7636
+ command,
7637
+ security: execPolicy.security,
7638
+ ask: execPolicy.ask,
7639
+ allowlistSatisfied: evaluation.allowlistSatisfied,
7640
+ safeBinSatisfied: evaluation.safeBinSatisfied,
7641
+ analysisOk: evaluation.analysisOk,
7642
+ requiresApproval: evaluation.requiresApproval
7643
+ }
7644
+ });
7645
+ if (evaluation.denied) throw new Error(evaluation.deniedReason || "exec denied by policy");
7646
+ if (evaluation.requiresApproval) {
7647
+ const approval = execApprovals.request({
7648
+ command,
7649
+ agentId,
7650
+ sessionKey: sessionContext?.sessionKey,
7651
+ resolvedPath: evaluation.resolvedPath,
7652
+ timeoutMs: execPolicy.approvalTimeoutMs
6872
7653
  });
6873
- const result = await runtime.exec(params.command, { timeoutMs: params.timeoutMs });
6874
7654
  return textResult([
6875
- `exitCode: ${result.exitCode}`,
6876
- result.timedOut ? "timedOut: true" : "timedOut: false",
6877
- result.truncated ? "truncated: true" : "truncated: false",
6878
- "",
6879
- result.stdout ? `stdout:\n${result.stdout}` : "stdout: <empty>",
7655
+ `approvalRequired: true`,
7656
+ `approvalId: ${approval.id}`,
7657
+ `expiresAtMs: ${approval.expiresAtMs}`,
6880
7658
  "",
6881
- result.stderr ? `stderr:\n${result.stderr}` : "stderr: <empty>"
6882
- ].join("\n"), result);
6883
- }
6884
- };
6885
- const readFileTool = {
6886
- name: "read_file",
6887
- label: "Read File",
6888
- description: "Read a text file from an allowlisted path.",
6889
- parameters: Type.Object({
6890
- path: Type.String(),
6891
- maxBytes: Type.Optional(Type.Number({
6892
- minimum: 128,
6893
- maximum: 1e6
6894
- }))
6895
- }),
6896
- execute: async (_toolCallId, params) => {
6897
- audit({
6898
- actor: "tool",
6899
- eventType: "tool.read_file",
6900
- payload: { path: params.path }
7659
+ "Run /approve <id> allow-once|allow-always|deny, then retry the command."
7660
+ ].join("\n"), {
7661
+ status: "approval-pending",
7662
+ approvalId: approval.id,
7663
+ expiresAtMs: approval.expiresAtMs,
7664
+ command,
7665
+ resolvedPath: evaluation.resolvedPath,
7666
+ analysisOk: evaluation.analysisOk
6901
7667
  });
6902
- const result = await runtime.readFile(params.path, { maxOutputBytes: params.maxBytes });
6903
- return textResult(result.content, result);
6904
7668
  }
6905
- };
6906
- const writeFileTool = {
6907
- name: "write_file",
6908
- label: "Write File",
6909
- description: "Write UTF-8 text content to an allowlisted path.",
6910
- parameters: Type.Object({
6911
- path: Type.String(),
6912
- content: Type.String()
6913
- }),
7669
+ const result = await runtime.exec(command, { timeoutMs: params.timeoutMs });
7670
+ if (evaluation.allowlistPattern && !evaluation.allowlistPattern.startsWith("safe-bin:") && evaluation.allowlistPattern !== "one-time-approval") execApprovals.recordAllowlistUse(agentId, evaluation.allowlistPattern, command, evaluation.resolvedPath);
7671
+ return textResult(formatExecOutput(result), {
7672
+ ...result,
7673
+ command,
7674
+ resolvedPath: evaluation.resolvedPath
7675
+ });
7676
+ }
7677
+ const execTool = {
7678
+ name: "exec",
7679
+ label: "Exec",
7680
+ description: "Run a shell command with approval and allowlist policy.",
7681
+ parameters: execSchema,
6914
7682
  execute: async (_toolCallId, params) => {
6915
- audit({
6916
- actor: "tool",
6917
- eventType: "tool.write_file",
6918
- payload: {
6919
- path: params.path,
6920
- bytes: Buffer.byteLength(params.content, "utf8")
6921
- }
6922
- });
6923
- const result = await runtime.writeFile(params.path, params.content);
6924
- return textResult(`Wrote ${result.bytesWritten} bytes to ${params.path}.`, result);
7683
+ return executeCommandWithPolicy("exec", params);
6925
7684
  }
6926
7685
  };
6927
- const webSearchTool = {
6928
- name: "web_search",
6929
- label: "Web Search",
6930
- description: "Fetch a URL and return readable article text.",
6931
- parameters: Type.Object({ url: Type.String({ format: "uri" }) }),
7686
+ const bashTool = {
7687
+ name: "bash",
7688
+ label: "Bash",
7689
+ description: "Compatibility alias for exec tool policy.",
7690
+ parameters: execSchema,
6932
7691
  execute: async (_toolCallId, params) => {
6933
- audit({
6934
- actor: "tool",
6935
- eventType: "tool.fetch_web",
6936
- payload: { url: params.url }
6937
- });
6938
- const result = await runtime.fetchWeb(params.url);
6939
- return textResult(`${result.title ? `# ${result.title}\n\n` : ""}${result.markdown}`.trim(), result);
7692
+ return executeCommandWithPolicy("bash", params);
6940
7693
  }
6941
7694
  };
6942
- const fetchPodcastFeedTool = {
6943
- name: "fetch_podcast_feed",
6944
- label: "Fetch Podcast Feed",
6945
- description: "Fetch one or more RSS podcast feeds and return recent episodes.",
7695
+ const diagnoseDeviceTool = {
7696
+ name: "diagnose_device",
7697
+ label: "Diagnose Device",
7698
+ description: "Run read-only core diagnostics for OS, CPU, memory, disk, network, and process status.",
6946
7699
  parameters: Type.Object({
6947
- urls: Type.Array(Type.String({ format: "uri" }), {
6948
- minItems: 1,
6949
- maxItems: 20
6950
- }),
6951
- limitPerFeed: Type.Optional(Type.Number({
6952
- minimum: 1,
6953
- maximum: 50
7700
+ profile: Type.Optional(Type.String({ default: "core" })),
7701
+ timeoutMs: Type.Optional(Type.Number({
7702
+ minimum: 1e3,
7703
+ maximum: 12e4
6954
7704
  }))
6955
7705
  }),
6956
7706
  execute: async (_toolCallId, params) => {
6957
- const limit = params.limitPerFeed ?? 5;
6958
- const output = [];
6959
- for (const url of params.urls) try {
6960
- const feed = await parser.parseURL(url);
6961
- const episodes = (feed.items ?? []).slice(0, limit).map((item) => ({
6962
- title: item.title ?? "Untitled episode",
6963
- publishedAt: item.pubDate || item.isoDate || null,
6964
- link: item.link ?? "",
6965
- summary: (item.contentSnippet || item.content || item.summary || "").replace(/\s+/g, " ").trim().slice(0, 400) || "No summary available."
6966
- }));
6967
- output.push({
6968
- url,
6969
- title: feed.title ?? "Untitled feed",
6970
- episodes
7707
+ const profile = (params.profile || "core").trim().toLowerCase();
7708
+ if (profile !== "core") throw new Error(`Unsupported diagnostic profile: ${profile}`);
7709
+ const timeoutMs = params.timeoutMs ?? 1e4;
7710
+ const commands = diagnosticsCommandsForPlatform(process.platform);
7711
+ const sections = [];
7712
+ for (const entry of commands) try {
7713
+ const result = await runtime.exec(entry.command, {
7714
+ timeoutMs,
7715
+ allowedCommandPrefixes: [firstToken(entry.command)]
7716
+ });
7717
+ sections.push({
7718
+ key: entry.key,
7719
+ command: entry.command,
7720
+ ok: result.exitCode === 0 && !result.timedOut,
7721
+ exitCode: result.exitCode,
7722
+ stdout: result.stdout,
7723
+ stderr: result.stderr,
7724
+ timedOut: result.timedOut,
7725
+ truncated: result.truncated
6971
7726
  });
6972
7727
  } catch (error) {
6973
- output.push({
6974
- url,
6975
- title: "Unknown feed",
6976
- episodes: [],
6977
- error: normalizeErrorMessage(error)
7728
+ sections.push({
7729
+ key: entry.key,
7730
+ command: entry.command,
7731
+ ok: false,
7732
+ exitCode: 1,
7733
+ stdout: "",
7734
+ stderr: normalizeErrorMessage(error),
7735
+ timedOut: false,
7736
+ truncated: false
6978
7737
  });
6979
7738
  }
6980
7739
  audit({
6981
7740
  actor: "tool",
6982
- eventType: "tool.fetch_podcast_feed",
7741
+ eventType: "tool.diagnose_device",
6983
7742
  payload: {
6984
- urls: params.urls,
6985
- limitPerFeed: limit
7743
+ profile,
7744
+ commandCount: sections.length,
7745
+ failed: sections.filter((entry) => !entry.ok).length
6986
7746
  }
6987
7747
  });
6988
- return textResult(output.map((feed) => {
6989
- if (feed.error) return `Feed: ${feed.url}\nError: ${feed.error}`;
6990
- const episodesText = feed.episodes.map((episode, index) => `${index + 1}. ${episode.title}${episode.publishedAt ? ` (${episode.publishedAt})` : ""}\n${episode.link}\n${episode.summary}`).join("\n\n");
6991
- return `Feed: ${feed.title}\nSource: ${feed.url}\n\n${episodesText}`;
6992
- }).join("\n\n---\n\n"), output);
7748
+ const summaryLines = [
7749
+ `profile: ${profile}`,
7750
+ `platform: ${process.platform}`,
7751
+ `sections: ${sections.length}`,
7752
+ `failed: ${sections.filter((entry) => !entry.ok).length}`
7753
+ ];
7754
+ for (const section of sections) {
7755
+ summaryLines.push("");
7756
+ summaryLines.push(`[${section.key}] ${section.command}`);
7757
+ summaryLines.push(`ok=${section.ok} exitCode=${section.exitCode} timedOut=${section.timedOut}`);
7758
+ if (section.stdout) summaryLines.push(`stdout:\n${section.stdout}`);
7759
+ if (section.stderr) summaryLines.push(`stderr:\n${section.stderr}`);
7760
+ }
7761
+ return textResult(summaryLines.join("\n"), {
7762
+ profile,
7763
+ platform: process.platform,
7764
+ sections
7765
+ });
6993
7766
  }
6994
7767
  };
6995
- const tools = [];
6996
- if (bashEnabled) tools.push(bashTool);
6997
- tools.push(readFileTool, writeFileTool, webSearchTool, fetchPodcastFeedTool);
7768
+ const tools = [
7769
+ {
7770
+ name: "read_file",
7771
+ label: "Read File",
7772
+ description: "Read a text file from an allowlisted path.",
7773
+ parameters: Type.Object({
7774
+ path: Type.String(),
7775
+ maxBytes: Type.Optional(Type.Number({
7776
+ minimum: 128,
7777
+ maximum: 1e6
7778
+ }))
7779
+ }),
7780
+ execute: async (_toolCallId, params) => {
7781
+ audit({
7782
+ actor: "tool",
7783
+ eventType: "tool.read_file",
7784
+ payload: { path: params.path }
7785
+ });
7786
+ const result = await runtime.readFile(params.path, { maxOutputBytes: params.maxBytes });
7787
+ return textResult(result.content, result);
7788
+ }
7789
+ },
7790
+ {
7791
+ name: "write_file",
7792
+ label: "Write File",
7793
+ description: "Write UTF-8 text content to an allowlisted path.",
7794
+ parameters: Type.Object({
7795
+ path: Type.String(),
7796
+ content: Type.String()
7797
+ }),
7798
+ execute: async (_toolCallId, params) => {
7799
+ audit({
7800
+ actor: "tool",
7801
+ eventType: "tool.write_file",
7802
+ payload: {
7803
+ path: params.path,
7804
+ bytes: Buffer.byteLength(params.content, "utf8")
7805
+ }
7806
+ });
7807
+ const result = await runtime.writeFile(params.path, params.content);
7808
+ return textResult(`Wrote ${result.bytesWritten} bytes to ${params.path}.`, result);
7809
+ }
7810
+ },
7811
+ {
7812
+ name: "web_search",
7813
+ label: "Web Search",
7814
+ description: "Fetch a URL and return readable article text.",
7815
+ parameters: Type.Object({ url: Type.String({ format: "uri" }) }),
7816
+ execute: async (_toolCallId, params) => {
7817
+ audit({
7818
+ actor: "tool",
7819
+ eventType: "tool.fetch_web",
7820
+ payload: { url: params.url }
7821
+ });
7822
+ const result = await runtime.fetchWeb(params.url);
7823
+ return textResult(`${result.title ? `# ${result.title}\n\n` : ""}${result.markdown}`.trim(), result);
7824
+ }
7825
+ },
7826
+ {
7827
+ name: "fetch_podcast_feed",
7828
+ label: "Fetch Podcast Feed",
7829
+ description: "Fetch one or more RSS podcast feeds and return recent episodes.",
7830
+ parameters: Type.Object({
7831
+ urls: Type.Array(Type.String({ format: "uri" }), {
7832
+ minItems: 1,
7833
+ maxItems: 20
7834
+ }),
7835
+ limitPerFeed: Type.Optional(Type.Number({
7836
+ minimum: 1,
7837
+ maximum: 50
7838
+ }))
7839
+ }),
7840
+ execute: async (_toolCallId, params) => {
7841
+ const limit = params.limitPerFeed ?? 5;
7842
+ const output = [];
7843
+ for (const url of params.urls) try {
7844
+ const feed = await parser.parseURL(url);
7845
+ const episodes = (feed.items ?? []).slice(0, limit).map((item) => ({
7846
+ title: item.title ?? "Untitled episode",
7847
+ publishedAt: item.pubDate || item.isoDate || null,
7848
+ link: item.link ?? "",
7849
+ summary: (item.contentSnippet || item.content || item.summary || "").replace(/\s+/g, " ").trim().slice(0, 400) || "No summary available."
7850
+ }));
7851
+ output.push({
7852
+ url,
7853
+ title: feed.title ?? "Untitled feed",
7854
+ episodes
7855
+ });
7856
+ } catch (error) {
7857
+ output.push({
7858
+ url,
7859
+ title: "Unknown feed",
7860
+ episodes: [],
7861
+ error: normalizeErrorMessage(error)
7862
+ });
7863
+ }
7864
+ audit({
7865
+ actor: "tool",
7866
+ eventType: "tool.fetch_podcast_feed",
7867
+ payload: {
7868
+ urls: params.urls,
7869
+ limitPerFeed: limit
7870
+ }
7871
+ });
7872
+ return textResult(output.map((feed) => {
7873
+ if (feed.error) return `Feed: ${feed.url}\nError: ${feed.error}`;
7874
+ const episodesText = feed.episodes.map((episode, index) => `${index + 1}. ${episode.title}${episode.publishedAt ? ` (${episode.publishedAt})` : ""}\n${episode.link}\n${episode.summary}`).join("\n\n");
7875
+ return `Feed: ${feed.title}\nSource: ${feed.url}\n\n${episodesText}`;
7876
+ }).join("\n\n---\n\n"), output);
7877
+ }
7878
+ },
7879
+ diagnoseDeviceTool
7880
+ ];
7881
+ if (execPolicy.enabled) tools.unshift(execTool, bashTool);
6998
7882
  return tools;
6999
7883
  }
7000
7884
 
@@ -7082,7 +7966,6 @@ async function main() {
7082
7966
  } catch (error) {
7083
7967
  logger.warn({ error }, "Workspace bootstrap failed");
7084
7968
  }
7085
- writeOpenClawMirror(config);
7086
7969
  const db = new HovClawDb(path.join(config.storeDir, "hovclaw.db"));
7087
7970
  db.ping();
7088
7971
  const runtime = config.runtime.mode === "container" ? new ContainerRuntime({
@@ -7101,10 +7984,40 @@ async function main() {
7101
7984
  allowedWriteRoots: config.runtime.allowedWriteRoots,
7102
7985
  allowedCommandPrefixes: config.runtime.allowedCommandPrefixes
7103
7986
  });
7987
+ let emitGatewayEvent = null;
7988
+ const execApprovals = new ExecApprovalsManager({
7989
+ storeDir: config.storeDir,
7990
+ defaults: {
7991
+ security: config.runtime.tools.exec.security,
7992
+ ask: config.runtime.tools.exec.ask
7993
+ },
7994
+ onRequested: (record) => {
7995
+ emitGatewayEvent?.("exec.approval.requested", {
7996
+ id: record.id,
7997
+ request: {
7998
+ command: record.command,
7999
+ agentId: record.agentId,
8000
+ sessionKey: record.sessionKey,
8001
+ resolvedPath: record.resolvedPath
8002
+ },
8003
+ createdAtMs: record.createdAtMs,
8004
+ expiresAtMs: record.expiresAtMs
8005
+ });
8006
+ },
8007
+ onResolved: (record) => {
8008
+ emitGatewayEvent?.("exec.approval.resolved", {
8009
+ id: record.id,
8010
+ decision: record.decision,
8011
+ resolvedBy: record.resolvedBy ?? null,
8012
+ resolvedAtMs: record.resolvedAtMs
8013
+ });
8014
+ }
8015
+ });
7104
8016
  const agentManager = new PiAgentManager(db, createTools({
7105
8017
  runtime,
7106
8018
  audit: (record) => db.appendAuditEvent(record),
7107
- bashEnabled: config.runtime.tools.bashEnabled
8019
+ execPolicy: config.runtime.tools.exec,
8020
+ execApprovals
7108
8021
  }));
7109
8022
  let lastMessageAt = null;
7110
8023
  const telegramPairingStore = new TelegramPairingStore(config.storeDir);
@@ -7128,6 +8041,19 @@ async function main() {
7128
8041
  if (!normalizedPrompt) return;
7129
8042
  let thinkingLevel = config.commands.defaultThinkingLevel;
7130
8043
  let thinkingLevelForced = false;
8044
+ const commandAuthorized = isCommandAuthorized(config, msg);
8045
+ if ((await handleExecChatCommand({
8046
+ msg,
8047
+ target,
8048
+ channel,
8049
+ runtime,
8050
+ execApprovals,
8051
+ execPolicy: config.runtime.tools.exec,
8052
+ commandAuthorized,
8053
+ agentId,
8054
+ sessionKey,
8055
+ audit: (record) => db.appendAuditEvent(record)
8056
+ })).handled) return;
7131
8057
  if (msg.channel === "telegram") {
7132
8058
  const policy = evaluateTelegramPolicy({
7133
8059
  config,
@@ -7272,12 +8198,16 @@ async function main() {
7272
8198
  const gatewayServer = config.gateway.enabled ? new HovClawGatewayServer({
7273
8199
  config,
7274
8200
  db,
8201
+ execApprovals,
7275
8202
  agentManager,
7276
8203
  channels,
7277
8204
  getChannelStatus: () => channelPluginManager.buildStatusPayload(),
7278
8205
  readFileConfig,
7279
8206
  writeFileConfig
7280
8207
  }) : null;
8208
+ emitGatewayEvent = gatewayServer ? (event, payload) => {
8209
+ gatewayServer.emitEvent(event, payload);
8210
+ } : null;
7281
8211
  gatewayServer?.start();
7282
8212
  const healthPortRaw = process.env.HEALTH_PORT || "8787";
7283
8213
  const healthPort = Number(healthPortRaw);