wayang-ai 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1464 -233
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -683,6 +683,7 @@ ${getStderrTail()}`));
683
683
 
684
684
  // src/infra/session-helpers.ts
685
685
  import * as fs3 from "fs";
686
+ import { closeSync as closeSync2, openSync as openSync2, readSync, statSync } from "fs";
686
687
  import * as path3 from "path";
687
688
 
688
689
  // src/infra/session-index.ts
@@ -1021,8 +1022,115 @@ function listSessions(homeDir2, query2 = {}) {
1021
1022
  function getSession(homeDir2, sessionId) {
1022
1023
  return createSessionIndex(homeDir2).get(sessionId);
1023
1024
  }
1024
- function getLatestSessionForWorkspace(homeDir2, workspaceDir2) {
1025
- return createSessionIndex(homeDir2).latestForWorkspace(workspaceDir2);
1025
+ var CONVERSATION_FILENAME = "conversation.jsonl";
1026
+ var ACTIVITY_WINDOW_BYTES = 64 * 1024;
1027
+ var MAX_ACTIVITY_WINDOW_BYTES = 2 * 1024 * 1024;
1028
+ function entryTimestamp(entry) {
1029
+ if (entry === null || typeof entry !== "object") return null;
1030
+ const e = entry;
1031
+ for (const raw of [e.timestamp, e.endedAt, e.startedAt]) {
1032
+ if (typeof raw === "string") {
1033
+ const ms = Date.parse(raw);
1034
+ if (Number.isFinite(ms)) return ms;
1035
+ }
1036
+ }
1037
+ return null;
1038
+ }
1039
+ function readTailEntryTimestamp(filePath) {
1040
+ let size;
1041
+ try {
1042
+ size = statSync(filePath).size;
1043
+ } catch {
1044
+ return null;
1045
+ }
1046
+ if (size === 0) return null;
1047
+ let windowBytes = Math.min(size, ACTIVITY_WINDOW_BYTES);
1048
+ for (; ; ) {
1049
+ const start = size - windowBytes;
1050
+ const chunk = Buffer.alloc(windowBytes);
1051
+ let fd = null;
1052
+ try {
1053
+ fd = openSync2(filePath, "r");
1054
+ readSync(fd, chunk, 0, windowBytes, start);
1055
+ } catch {
1056
+ return null;
1057
+ } finally {
1058
+ if (fd !== null) closeSync2(fd);
1059
+ }
1060
+ let body = chunk.toString("utf-8");
1061
+ if (start > 0) {
1062
+ const nl = body.indexOf("\n");
1063
+ if (nl === -1) {
1064
+ if (windowBytes >= MAX_ACTIVITY_WINDOW_BYTES) return null;
1065
+ windowBytes = Math.min(size, windowBytes * 2);
1066
+ continue;
1067
+ }
1068
+ body = body.slice(nl + 1);
1069
+ }
1070
+ let latest = null;
1071
+ for (const line of body.split("\n")) {
1072
+ if (line.length === 0) continue;
1073
+ let parsed;
1074
+ try {
1075
+ parsed = JSON.parse(line);
1076
+ } catch {
1077
+ continue;
1078
+ }
1079
+ const ts = entryTimestamp(parsed);
1080
+ if (ts !== null && (latest === null || ts > latest)) latest = ts;
1081
+ }
1082
+ if (latest !== null) return latest;
1083
+ if (start === 0 || windowBytes >= MAX_ACTIVITY_WINDOW_BYTES) return null;
1084
+ windowBytes = Math.min(size, windowBytes * 2);
1085
+ }
1086
+ }
1087
+ function readMetaForActivity(sessionDir) {
1088
+ try {
1089
+ const raw = fs3.readFileSync(path3.join(sessionDir, "meta.json"), "utf-8");
1090
+ const meta = JSON.parse(raw);
1091
+ return meta !== null && typeof meta === "object" ? meta : null;
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ function usableTimestamp(value) {
1097
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
1098
+ }
1099
+ function readSessionLastActivity(sessionDir) {
1100
+ const meta = readMetaForActivity(sessionDir);
1101
+ const candidates = [];
1102
+ const conversationAt = readTailEntryTimestamp(path3.join(sessionDir, CONVERSATION_FILENAME));
1103
+ if (conversationAt !== null) candidates.push({ at: conversationAt, source: "conversation" });
1104
+ const lastActiveAt = usableTimestamp(meta?.lastActiveAt);
1105
+ if (lastActiveAt !== null) candidates.push({ at: lastActiveAt, source: "meta.lastActiveAt" });
1106
+ const createdAt = usableTimestamp(meta?.createdAt);
1107
+ if (createdAt !== null) candidates.push({ at: createdAt, source: "meta.createdAt" });
1108
+ if (candidates.length === 0) return null;
1109
+ return candidates.reduce((best, candidate) => candidate.at > best.at ? candidate : best);
1110
+ }
1111
+ function pickMostRecentlyActive(candidates, activityOf) {
1112
+ let best = null;
1113
+ let bestAt = 0;
1114
+ for (const candidate of candidates) {
1115
+ const at = activityOf(candidate);
1116
+ if (best === null || at > bestAt || at === bestAt && candidate.sessionId > best.sessionId) {
1117
+ best = candidate;
1118
+ bestAt = at;
1119
+ }
1120
+ }
1121
+ return best;
1122
+ }
1123
+ function activityAtForEntry(entry) {
1124
+ const activity = readSessionLastActivity(entry.sessionDir);
1125
+ if (activity) return activity.at;
1126
+ return usableTimestamp(entry.meta.createdAt) ?? 0;
1127
+ }
1128
+ function getLatestSessionForWorkspace(homeDir2, workspaceDir2, opts = {}) {
1129
+ const candidates = createSessionIndex(homeDir2).list({
1130
+ workspace: workspaceDir2,
1131
+ includeArchived: opts.includeArchived
1132
+ });
1133
+ return pickMostRecentlyActive(candidates, activityAtForEntry);
1026
1134
  }
1027
1135
  function readSessionUnfinishedTasks(sessionDir) {
1028
1136
  const tasksPath = path3.join(sessionDir, "tasks.json");
@@ -1303,7 +1411,7 @@ function validateConfig(config) {
1303
1411
  if (!config.providers[wp]) return `worker.provider "${wp}" not found`;
1304
1412
  const permission = config.permission;
1305
1413
  if (permission !== void 0 && permission !== null) {
1306
- const keys = ["allowedDirs", "autoAllowRoots", "deniedDirs"];
1414
+ const keys = ["allowedDirs", "autoAllowRoots", "sensitiveDirs", "deniedDirs"];
1307
1415
  for (const key of keys) {
1308
1416
  const value = permission[key];
1309
1417
  if (value === void 0) continue;
@@ -1311,6 +1419,10 @@ function validateConfig(config) {
1311
1419
  return `permission.${key} must be an array of strings (got ${JSON.stringify(value)})`;
1312
1420
  }
1313
1421
  }
1422
+ const mode = permission.sensitiveMode;
1423
+ if (mode !== void 0 && mode !== "ask" && mode !== "deny") {
1424
+ return `permission.sensitiveMode must be 'ask' or 'deny' (got ${JSON.stringify(mode)})`;
1425
+ }
1314
1426
  }
1315
1427
  return null;
1316
1428
  }
@@ -1466,10 +1578,10 @@ import {
1466
1578
  existsSync as existsSync4,
1467
1579
  mkdirSync as mkdirSync3,
1468
1580
  renameSync as renameSync4,
1469
- openSync as openSync2,
1581
+ openSync as openSync3,
1470
1582
  writeSync as writeSync2,
1471
1583
  fsyncSync as fsyncSync2,
1472
- closeSync as closeSync2,
1584
+ closeSync as closeSync3,
1473
1585
  unlinkSync as unlinkSync3,
1474
1586
  copyFileSync
1475
1587
  } from "fs";
@@ -1498,16 +1610,16 @@ var JSONFileHelper = class {
1498
1610
  const tmp = this.tempPath();
1499
1611
  let fd = null;
1500
1612
  try {
1501
- fd = openSync2(tmp, "w");
1613
+ fd = openSync3(tmp, "w");
1502
1614
  writeSync2(fd, JSON.stringify(value, null, 2));
1503
1615
  fsyncSync2(fd);
1504
- closeSync2(fd);
1616
+ closeSync3(fd);
1505
1617
  fd = null;
1506
1618
  renameSync4(tmp, this.filePath);
1507
1619
  } catch (err) {
1508
1620
  if (fd !== null) {
1509
1621
  try {
1510
- closeSync2(fd);
1622
+ closeSync3(fd);
1511
1623
  } catch {
1512
1624
  }
1513
1625
  }
@@ -1958,56 +2070,6 @@ var theme = {
1958
2070
  }
1959
2071
  };
1960
2072
 
1961
- // src/tui/utils/chat-lookups.ts
1962
- var EMPTY_CHAT_LOOKUPS = {
1963
- permissionRequests: /* @__PURE__ */ new Map(),
1964
- inquiries: /* @__PURE__ */ new Map(),
1965
- workers: /* @__PURE__ */ new Map(),
1966
- workerTypeEmoji: /* @__PURE__ */ new Map()
1967
- };
1968
- function buildChatLookups(items) {
1969
- const permissionRequests = /* @__PURE__ */ new Map();
1970
- const inquiries = /* @__PURE__ */ new Map();
1971
- const workers = /* @__PURE__ */ new Map();
1972
- const workerTypeEmoji = /* @__PURE__ */ new Map();
1973
- for (const item of items) {
1974
- if (item.role !== "signal") continue;
1975
- if (item.workerType && item.emoji && !workerTypeEmoji.has(item.workerType)) {
1976
- workerTypeEmoji.set(item.workerType, item.emoji);
1977
- }
1978
- let worker;
1979
- if (item.workerId) {
1980
- const prev = workers.get(item.workerId);
1981
- worker = {
1982
- workerId: item.workerId,
1983
- emoji: item.emoji ?? prev?.emoji,
1984
- workerType: item.workerType ?? prev?.workerType,
1985
- taskTitle: item.taskTitle ?? prev?.taskTitle
1986
- };
1987
- workers.set(item.workerId, worker);
1988
- }
1989
- const requestId = item.permissionMeta?.requestId;
1990
- if (item.permissionMeta && requestId) {
1991
- permissionRequests.set(requestId, { meta: item.permissionMeta, ...worker && { worker } });
1992
- }
1993
- const inquiryId = item.inquiryMeta?.inquiryId;
1994
- if (item.inquiryMeta && inquiryId) {
1995
- inquiries.set(inquiryId, { meta: item.inquiryMeta, ...worker && { worker } });
1996
- }
1997
- }
1998
- return { permissionRequests, inquiries, workers, workerTypeEmoji };
1999
- }
2000
- function formatWorkerTag(ctx) {
2001
- const parts = [];
2002
- if (ctx.taskTitle) parts.push(ctx.taskTitle);
2003
- if (ctx.workerId) parts.push(`w:${ctx.workerId.slice(-4)}`);
2004
- const body = parts.join(" \xB7 ");
2005
- return [ctx.emoji, body].filter(Boolean).join(" ");
2006
- }
2007
-
2008
- // src/tui/components/chat-area/user-row.tsx
2009
- import { Box, Text } from "ink";
2010
-
2011
2073
  // src/tui/utils/text-width.ts
2012
2074
  function charWidth(cp) {
2013
2075
  if (cp >= 768 && cp <= 879) return 0;
@@ -2049,6 +2111,28 @@ function sliceToWidth(s, maxWidth) {
2049
2111
  }
2050
2112
  return out;
2051
2113
  }
2114
+ function sliceToWidthFromEnd(s, maxWidth) {
2115
+ if (maxWidth <= 0) return "";
2116
+ const chars = Array.from(s);
2117
+ let w = 0;
2118
+ let out = "";
2119
+ for (let i = chars.length - 1; i >= 0; i -= 1) {
2120
+ const cw = charWidth(chars[i].codePointAt(0) ?? 0);
2121
+ if (w + cw > maxWidth) break;
2122
+ out = chars[i] + out;
2123
+ w += cw;
2124
+ }
2125
+ return out;
2126
+ }
2127
+ function truncateMiddleToWidth(s, maxWidth) {
2128
+ if (maxWidth <= 0) return "";
2129
+ if (stringWidth(s) <= maxWidth) return s;
2130
+ if (maxWidth === 1) return "\u2026";
2131
+ const keep = maxWidth - 1;
2132
+ const headBudget = Math.ceil(keep / 2);
2133
+ const tailBudget = keep - headBudget;
2134
+ return sliceToWidth(s, headBudget) + "\u2026" + sliceToWidthFromEnd(s, tailBudget);
2135
+ }
2052
2136
  function collapseWhitespace(s) {
2053
2137
  return s.replace(/\s+/g, " ").trim();
2054
2138
  }
@@ -2063,7 +2147,144 @@ function shortId(id) {
2063
2147
  return id.slice(-4);
2064
2148
  }
2065
2149
 
2150
+ // src/tui/utils/workspace-display.ts
2151
+ var EMPTY_WORKSPACE_CONTEXT = { workspaces: [] };
2152
+ function formatWorkspaceSlug(workspace) {
2153
+ const slug = workspace?.slug?.trim();
2154
+ return slug ? slug : null;
2155
+ }
2156
+ function formatWorkspaceSummary(workspaces) {
2157
+ if (!workspaces || workspaces.length === 0) return null;
2158
+ const slugs = workspaces.map((workspace) => workspace?.slug?.trim()).filter((slug) => !!slug);
2159
+ if (slugs.length === 0) return null;
2160
+ if (slugs.length === 1) return slugs[0];
2161
+ return `${slugs[0]} +${slugs.length - 1}`;
2162
+ }
2163
+ function isSameWorkspacePath(a, b) {
2164
+ const left = a?.trim();
2165
+ const right = b?.trim();
2166
+ if (!left || !right) return false;
2167
+ return normalizeWorkspacePath(left) === normalizeWorkspacePath(right);
2168
+ }
2169
+ function resolveWorkspaceSlug(workspace) {
2170
+ const slug = workspace?.slug?.trim();
2171
+ if (slug) return slug;
2172
+ const path9 = workspace?.path?.trim();
2173
+ return path9 ? workspaceSlugFromPath(path9) : null;
2174
+ }
2175
+ function formatTaskWorkspaceMark(workspace, defaultWorkspacePath) {
2176
+ const slug = resolveWorkspaceSlug(workspace);
2177
+ if (!slug) return "";
2178
+ if (defaultWorkspacePath && isSameWorkspacePath(workspace?.path, defaultWorkspacePath)) {
2179
+ return "";
2180
+ }
2181
+ return ` \u2014 ${slug}`;
2182
+ }
2183
+ function resolveDeclaredWorkspaceSlug(declaredPath, options = {}) {
2184
+ const raw = declaredPath?.trim();
2185
+ if (!raw) return null;
2186
+ if (options.defaultWorkspacePath && isSameWorkspacePath(raw, options.defaultWorkspacePath)) {
2187
+ return null;
2188
+ }
2189
+ const known = (options.workspaces ?? []).find(
2190
+ (workspace) => workspace?.path && isSameWorkspacePath(workspace.path, raw)
2191
+ );
2192
+ if (known) return resolveWorkspaceSlug(known);
2193
+ return resolveWorkspaceSlug({ path: normalizeWorkspacePath(raw) });
2194
+ }
2195
+ var PROJECT_LIST_MAX_WIDTH = 80;
2196
+ var PROJECT_DEFAULT_MARKER = "*";
2197
+ var PROJECT_DEFAULT_LEGEND = "* = default workspace";
2198
+ function collectProjectEntries(input2) {
2199
+ const defaultPath = input2.defaultWorkspacePath?.trim();
2200
+ const seen = /* @__PURE__ */ new Set();
2201
+ const entries = [];
2202
+ const candidates = [
2203
+ ...input2.workspaces ?? [],
2204
+ ...input2.taskWorkspaces ?? [],
2205
+ ...defaultPath ? [{ path: defaultPath }] : []
2206
+ ];
2207
+ for (const candidate of candidates) {
2208
+ const rawPath = candidate?.path?.trim();
2209
+ if (!rawPath) continue;
2210
+ const canonical = normalizeWorkspacePath(rawPath);
2211
+ if (seen.has(canonical)) continue;
2212
+ const slug = resolveWorkspaceSlug({ ...candidate, path: rawPath });
2213
+ if (!slug) continue;
2214
+ seen.add(canonical);
2215
+ entries.push({
2216
+ slug,
2217
+ path: rawPath,
2218
+ isDefault: defaultPath !== void 0 && isSameWorkspacePath(rawPath, defaultPath)
2219
+ });
2220
+ }
2221
+ const defaultEntry = entries.find((entry) => entry.isDefault);
2222
+ return defaultEntry ? [defaultEntry, ...entries.filter((entry) => entry !== defaultEntry)] : entries;
2223
+ }
2224
+ function formatProjectList(input2, options = {}) {
2225
+ const maxWidth = options.maxWidth ?? PROJECT_LIST_MAX_WIDTH;
2226
+ const entries = collectProjectEntries(input2);
2227
+ if (entries.length === 0) return "No projects";
2228
+ const lines = entries.map((entry) => {
2229
+ const suffix = entry.isDefault ? ` ${PROJECT_DEFAULT_MARKER}` : "";
2230
+ const prefix = `${entry.slug} `;
2231
+ const pathBudget = Math.max(1, maxWidth - stringWidth(prefix) - stringWidth(suffix));
2232
+ return prefix + truncateMiddleToWidth(entry.path, pathBudget) + suffix;
2233
+ });
2234
+ if (entries.some((entry) => entry.isDefault)) lines.push(PROJECT_DEFAULT_LEGEND);
2235
+ return lines.join("\n");
2236
+ }
2237
+
2238
+ // src/tui/utils/chat-lookups.ts
2239
+ var EMPTY_CHAT_LOOKUPS = {
2240
+ permissionRequests: /* @__PURE__ */ new Map(),
2241
+ inquiries: /* @__PURE__ */ new Map(),
2242
+ workers: /* @__PURE__ */ new Map(),
2243
+ workerTypeEmoji: /* @__PURE__ */ new Map(),
2244
+ workspace: EMPTY_WORKSPACE_CONTEXT
2245
+ };
2246
+ function buildChatLookups(items, workspace = EMPTY_WORKSPACE_CONTEXT) {
2247
+ const permissionRequests = /* @__PURE__ */ new Map();
2248
+ const inquiries = /* @__PURE__ */ new Map();
2249
+ const workers = /* @__PURE__ */ new Map();
2250
+ const workerTypeEmoji = /* @__PURE__ */ new Map();
2251
+ for (const item of items) {
2252
+ if (item.role !== "signal") continue;
2253
+ if (item.workerType && item.emoji && !workerTypeEmoji.has(item.workerType)) {
2254
+ workerTypeEmoji.set(item.workerType, item.emoji);
2255
+ }
2256
+ let worker;
2257
+ if (item.workerId) {
2258
+ const prev = workers.get(item.workerId);
2259
+ worker = {
2260
+ workerId: item.workerId,
2261
+ emoji: item.emoji ?? prev?.emoji,
2262
+ workerType: item.workerType ?? prev?.workerType,
2263
+ taskTitle: item.taskTitle ?? prev?.taskTitle
2264
+ };
2265
+ workers.set(item.workerId, worker);
2266
+ }
2267
+ const requestId = item.permissionMeta?.requestId;
2268
+ if (item.permissionMeta && requestId) {
2269
+ permissionRequests.set(requestId, { meta: item.permissionMeta, ...worker && { worker } });
2270
+ }
2271
+ const inquiryId = item.inquiryMeta?.inquiryId;
2272
+ if (item.inquiryMeta && inquiryId) {
2273
+ inquiries.set(inquiryId, { meta: item.inquiryMeta, ...worker && { worker } });
2274
+ }
2275
+ }
2276
+ return { permissionRequests, inquiries, workers, workerTypeEmoji, workspace };
2277
+ }
2278
+ function formatWorkerTag(ctx) {
2279
+ const parts = [];
2280
+ if (ctx.taskTitle) parts.push(ctx.taskTitle);
2281
+ if (ctx.workerId) parts.push(`w:${ctx.workerId.slice(-4)}`);
2282
+ const body = parts.join(" \xB7 ");
2283
+ return [ctx.emoji, body].filter(Boolean).join(" ");
2284
+ }
2285
+
2066
2286
  // src/tui/components/chat-area/user-row.tsx
2287
+ import { Box, Text } from "ink";
2067
2288
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
2068
2289
  var PREFIX = `${theme.user.prefix} `;
2069
2290
  var PREFIX_WIDTH = stringWidth(PREFIX);
@@ -2199,15 +2420,18 @@ function toolTarget(toolName, args, budget) {
2199
2420
  function requestArgs(meta) {
2200
2421
  return meta.toolArgs && typeof meta.toolArgs === "object" && !Array.isArray(meta.toolArgs) ? meta.toolArgs : {};
2201
2422
  }
2202
- var RESOLVED_PATH_RE = /^\S+: (.*) \(resolved: (.*)\)$/s;
2423
+ var RESOLVED_PATH_RE = /^\S+: (.*) \(resolved: (.*?)\)(?: \[(.*)\])?$/s;
2203
2424
  function meaningfulDescription(description) {
2204
2425
  if (!description) return "";
2205
2426
  const text = description.trim();
2206
2427
  if (/^execute shell command:/i.test(text)) return "";
2207
2428
  const m = RESOLVED_PATH_RE.exec(text);
2208
2429
  if (m) {
2209
- const [, rawPath, resolved] = m;
2210
- return resolved && resolved !== rawPath ? `\u89E3\u6790\u4E3A ${resolved}` : "";
2430
+ const [, rawPath, resolved, hit] = m;
2431
+ const parts = [];
2432
+ if (resolved && resolved !== rawPath) parts.push(`\u89E3\u6790\u4E3A ${resolved}`);
2433
+ if (hit) parts.push(`[${hit}]`);
2434
+ return collapseWhitespace(parts.join(" "));
2211
2435
  }
2212
2436
  return collapseWhitespace(text);
2213
2437
  }
@@ -2318,11 +2542,25 @@ function summarizeWorkerMessage(argsJson, width, result2, maxLines = 3) {
2318
2542
  };
2319
2543
  }
2320
2544
  var TASK_ID_RE = /^Task (\S+) (?:added|assigned)/;
2321
- function summarizeAddTask(argsJson, width, result2, maxLines = 2) {
2545
+ var ADD_TASK_HEADER_CHROME = 27;
2546
+ var ADD_TASK_TAG_INSET = 1;
2547
+ var ADD_TASK_WORKSPACE_TAG_MAX_WIDTH = 16;
2548
+ function formatAddTaskWorkspaceTag(slug, maxWidth = ADD_TASK_WORKSPACE_TAG_MAX_WIDTH) {
2549
+ const label = slug?.trim();
2550
+ if (!label) return "";
2551
+ return truncateToWidth(` \u2014 ${label}`, maxWidth);
2552
+ }
2553
+ function summarizeAddTask(argsJson, width, result2, options = {}) {
2322
2554
  const args = parseArgs(argsJson) ?? {};
2555
+ const maxLines = options.maxLines ?? 2;
2323
2556
  const title = str(args.title) ?? "(untitled)";
2324
2557
  const description = str(args.description) ?? "";
2325
2558
  const { lines, hidden } = previewLines(description, maxLines, width);
2559
+ const declaredWorkspaceSlug = resolveDeclaredWorkspaceSlug(str(args.workspace), {
2560
+ defaultWorkspacePath: options.defaultWorkspacePath,
2561
+ workspaces: options.workspaces
2562
+ });
2563
+ const workspaceTag = formatAddTaskWorkspaceTag(declaredWorkspaceSlug);
2326
2564
  let outcome = "pending";
2327
2565
  let taskShortId;
2328
2566
  let failure = "";
@@ -2336,8 +2574,11 @@ function summarizeAddTask(argsJson, width, result2, maxLines = 2) {
2336
2574
  failure = failureText(result2.text, width);
2337
2575
  }
2338
2576
  }
2577
+ const tagCost = workspaceTag ? stringWidth(workspaceTag) + ADD_TASK_TAG_INSET : 0;
2578
+ const titleBudget = Math.max(1, width - ADD_TASK_HEADER_CHROME - tagCost);
2339
2579
  return {
2340
- title: fitWithSuffix(collapseWhitespace(title), "", width),
2580
+ title: fitWithSuffix(collapseWhitespace(title), "", titleBudget),
2581
+ ...declaredWorkspaceSlug && { workspaceSlug: declaredWorkspaceSlug },
2341
2582
  workerType: str(args.workerType) ?? str(args.worker_type) ?? "puppet",
2342
2583
  ...str(args.priority) && { priority: str(args.priority) },
2343
2584
  multiStage: args.multiStage === true,
@@ -2597,8 +2838,13 @@ function AddTaskRow({ section: section2, result: result2, lookups }) {
2597
2838
  const summary = summarizeAddTask(
2598
2839
  section2.text,
2599
2840
  SECONDARY_WIDTH,
2600
- result2 ? { text: result2.text, isError: result2.isError } : void 0
2841
+ result2 ? { text: result2.text, isError: result2.isError } : void 0,
2842
+ {
2843
+ defaultWorkspacePath: lookups.workspace.defaultWorkspacePath,
2844
+ workspaces: lookups.workspace.workspaces
2845
+ }
2601
2846
  );
2847
+ const workspaceTag = formatAddTaskWorkspaceTag(summary.workspaceSlug);
2602
2848
  const reuse = summary.assignToWorker !== void 0;
2603
2849
  const iconColor = summary.outcome === "failed" ? theme.action.failColor : theme.action.color;
2604
2850
  const reusedWorker = reuse ? lookups.workers.get(summary.assignToWorker) : void 0;
@@ -2623,6 +2869,7 @@ function AddTaskRow({ section: section2, result: result2, lookups }) {
2623
2869
  " #",
2624
2870
  summary.taskShortId
2625
2871
  ] }),
2872
+ workspaceTag ? /* @__PURE__ */ jsx3(Text2, { color: theme.secondary.color, children: workspaceTag }) : null,
2626
2873
  /* @__PURE__ */ jsx3(OutcomeMark, { outcome: summary.outcome, ok: "\u5DF2\u6D3E\u53D1", failed: "\u6D3E\u53D1\u5931\u8D25" })
2627
2874
  ] }),
2628
2875
  /* @__PURE__ */ jsx3(SecondaryLine, { children: metaLine }),
@@ -2817,14 +3064,20 @@ function shouldShowGap(prev, _current) {
2817
3064
  if (!prev) return false;
2818
3065
  return true;
2819
3066
  }
2820
- var ChatArea = React2.memo(function ChatArea2({ items, busy, streamingItems, contentWidth }) {
3067
+ var ChatArea = React2.memo(function ChatArea2({
3068
+ items,
3069
+ busy,
3070
+ streamingItems,
3071
+ contentWidth,
3072
+ workspace = EMPTY_WORKSPACE_CONTEXT
3073
+ }) {
2821
3074
  const width = contentWidth ?? 78;
2822
3075
  const { staticItems, dynamicItems } = useMemo(() => {
2823
3076
  const read = items.filter((i) => i.read);
2824
3077
  const unread = items.filter((i) => !i.read);
2825
3078
  return { staticItems: read, dynamicItems: unread };
2826
3079
  }, [items]);
2827
- const lookups = useMemo(() => buildChatLookups(items), [items]);
3080
+ const lookups = useMemo(() => buildChatLookups(items, workspace), [items, workspace]);
2828
3081
  return /* @__PURE__ */ jsxs5(Fragment2, { children: [
2829
3082
  /* @__PURE__ */ jsx8(Static, { items: staticItems, children: (item, idx) => /* @__PURE__ */ jsx8(
2830
3083
  Box5,
@@ -2975,29 +3228,61 @@ function renderShortcutList() {
2975
3228
  return lines.join("\n");
2976
3229
  }
2977
3230
 
2978
- // src/tui/utils/workspace-display.ts
2979
- function formatWorkspaceSlug(workspace) {
2980
- const slug = workspace?.slug?.trim();
2981
- return slug ? slug : null;
3231
+ // src/tui/utils/task-list.ts
3232
+ var TASK_LIST_DESCRIPTION_MAX_WIDTH = 80;
3233
+ var TASK_LIST_MAX_ITEMS = 20;
3234
+ function truncateForList(text, maxWidth = TASK_LIST_DESCRIPTION_MAX_WIDTH) {
3235
+ return truncateToWidth(collapseWhitespace(text), maxWidth);
2982
3236
  }
2983
- function formatWorkspaceSummary(workspaces) {
2984
- if (!workspaces || workspaces.length === 0) return null;
2985
- const slugs = workspaces.map((workspace) => workspace?.slug?.trim()).filter((slug) => !!slug);
2986
- if (slugs.length === 0) return null;
2987
- if (slugs.length === 1) return slugs[0];
2988
- return `${slugs[0]} +${slugs.length - 1}`;
2989
- }
2990
- function formatWorkspaceDetail(workspace) {
2991
- const path9 = workspace?.path?.trim();
2992
- const slug = workspace?.slug?.trim();
2993
- if (path9 && slug) return `${slug} (${path9})`;
2994
- return path9 || slug || null;
3237
+ var ACTIVE_TASK_STATUSES = /* @__PURE__ */ new Set([
3238
+ "pending",
3239
+ "running"
3240
+ ]);
3241
+ function isActiveTask(task) {
3242
+ return ACTIVE_TASK_STATUSES.has(task.status);
3243
+ }
3244
+ function limitForList(items, max = TASK_LIST_MAX_ITEMS, options = {}) {
3245
+ const isActive = options.isActive ?? (() => false);
3246
+ const total = items.length;
3247
+ if (total === 0) {
3248
+ return { items: [], total, hidden: 0, note: null };
3249
+ }
3250
+ const activeIndices = [];
3251
+ const historyIndices = [];
3252
+ items.forEach((item, index) => {
3253
+ (isActive(item) ? activeIndices : historyIndices).push(index);
3254
+ });
3255
+ const historyBudget = Math.max(0, max - activeIndices.length);
3256
+ const keptHistory = historyBudget === 0 ? [] : historyIndices.slice(-historyBudget);
3257
+ const keep = /* @__PURE__ */ new Set([...activeIndices, ...keptHistory]);
3258
+ const kept = items.filter((_, index) => keep.has(index));
3259
+ const hidden = total - kept.length;
3260
+ if (hidden === 0) {
3261
+ return { items: kept, total, hidden: 0, note: null };
3262
+ }
3263
+ const note = activeIndices.length > 0 ? `${noteFor(total, kept.length)}\uFF08\u53E6\u6709 ${hidden} \u6761\u5386\u53F2\u672A\u663E\u793A\uFF09` : noteFor(total, kept.length);
3264
+ return { items: kept, total, hidden, note };
3265
+ }
3266
+ function noteFor(total, shown) {
3267
+ return `\u2026 \u663E\u793A\u6700\u8FD1 ${shown} / \u5171 ${total} \u6761`;
3268
+ }
3269
+ function formatTaskRow(task, options = {}) {
3270
+ const workspacePart = formatTaskWorkspaceMark(task.workspace, options.defaultWorkspacePath);
3271
+ const description = truncateForList(task.description, options.descriptionMaxWidth);
3272
+ return `[${task.status}] ${task.id}: ${description}${workspacePart}${task.result ? ` \u2192 ${task.result}` : ""}`;
3273
+ }
3274
+ function formatTaskList(tasks, options = {}) {
3275
+ const limited = limitForList(tasks, options.maxItems, { isActive: isActiveTask });
3276
+ const lines = limited.items.map((task) => formatTaskRow(task, options));
3277
+ if (limited.note) lines.push(limited.note);
3278
+ return lines.join("\n");
2995
3279
  }
2996
3280
 
2997
3281
  // src/tui/hooks/use-slash-commands.ts
2998
3282
  var SLASH_COMMANDS = [
2999
3283
  { name: "tasks", args: "[status]", description: "List tasks" },
3000
3284
  { name: "workers", description: "List active workers" },
3285
+ { name: "projects", description: "List session workspaces" },
3001
3286
  { name: "cancel", args: "<id>", description: "Cancel a task" },
3002
3287
  { name: "context", description: "Show context token breakdown" },
3003
3288
  { name: "compact", description: "Compact context" },
@@ -3018,12 +3303,21 @@ function useSlashCommands(supervisor) {
3018
3303
  const status = parts[1];
3019
3304
  const tasks = await supervisor.engine.list(status);
3020
3305
  if (tasks.length === 0) return { handled: true, output: "No tasks" };
3021
- const lines = tasks.map((t) => {
3022
- const workspace = formatWorkspaceDetail(t.workspace);
3023
- const workspacePart = workspace ? ` \u2014 ${workspace}` : "";
3024
- return `[${t.status}] ${t.id}: ${t.description}${workspacePart}${t.result ? ` \u2192 ${t.result}` : ""}`;
3025
- });
3026
- return { handled: true, output: lines.join("\n") };
3306
+ return {
3307
+ handled: true,
3308
+ output: formatTaskList(tasks, { defaultWorkspacePath: supervisor.ctx.workspaceDir })
3309
+ };
3310
+ }
3311
+ case "projects": {
3312
+ const tasks = await supervisor.engine.list();
3313
+ return {
3314
+ handled: true,
3315
+ output: formatProjectList({
3316
+ workspaces: supervisor.ctx.workspaces,
3317
+ taskWorkspaces: tasks.map((task) => task.workspace ?? {}),
3318
+ defaultWorkspacePath: supervisor.ctx.workspaceDir
3319
+ })
3320
+ };
3027
3321
  }
3028
3322
  case "workers": {
3029
3323
  const activeWorkers = supervisor.engine.getActiveWorkers();
@@ -3057,8 +3351,9 @@ function useSlashCommands(supervisor) {
3057
3351
  handled: true,
3058
3352
  output: [
3059
3353
  "Commands:",
3060
- " /tasks [status] \u2014 List tasks",
3354
+ " /tasks [status] \u2014 List tasks (workspace slug when not the default)",
3061
3355
  " /workers \u2014 List active workers",
3356
+ " /projects \u2014 List session workspaces (* = default workspace)",
3062
3357
  " /cancel <id> \u2014 Cancel a task and abort its worker",
3063
3358
  " /context \u2014 Show context token breakdown",
3064
3359
  " /compact \u2014 Compact conversation context",
@@ -4162,6 +4457,13 @@ function ControllerPage({ onExit }) {
4162
4457
  break;
4163
4458
  }
4164
4459
  });
4460
+ const workspaceContext = useMemo4(
4461
+ () => ({
4462
+ defaultWorkspacePath: supervisor.ctx.workspaceDir,
4463
+ workspaces: supervisor.ctx.workspaces
4464
+ }),
4465
+ [supervisor]
4466
+ );
4165
4467
  const { handleCommand } = useSlashCommands(supervisor);
4166
4468
  const handleSubmit = useMemoizedFn(async (input2) => {
4167
4469
  let cmdResult;
@@ -4201,7 +4503,8 @@ function ControllerPage({ onExit }) {
4201
4503
  items: displayItems,
4202
4504
  busy,
4203
4505
  streamingItems,
4204
- contentWidth: chatContentWidth
4506
+ contentWidth: chatContentWidth,
4507
+ workspace: workspaceContext
4205
4508
  }
4206
4509
  ) }),
4207
4510
  overlay && /* @__PURE__ */ jsx13(OverlayPanel, { overlay }),
@@ -4268,7 +4571,7 @@ function AppContent() {
4268
4571
  // package.json
4269
4572
  var package_default = {
4270
4573
  name: "wayang-ai",
4271
- version: "0.8.0",
4574
+ version: "0.8.2",
4272
4575
  description: "Multi-agent orchestration platform",
4273
4576
  type: "module",
4274
4577
  license: "MIT",
@@ -4395,6 +4698,36 @@ import * as fs5 from "fs";
4395
4698
  import * as path6 from "path";
4396
4699
  import * as childProcess from "child_process";
4397
4700
  import prompts from "prompts";
4701
+
4702
+ // src/infra/permission/permission-config.ts
4703
+ var DEFAULT_SENSITIVE_MODE = "ask";
4704
+ function mergeSensitiveDirs(permission) {
4705
+ const entries = [...permission?.sensitiveDirs ?? [], ...permission?.deniedDirs ?? []];
4706
+ const seen = /* @__PURE__ */ new Set();
4707
+ const merged = [];
4708
+ for (const entry of entries) {
4709
+ if (typeof entry !== "string" || entry.length === 0) continue;
4710
+ if (seen.has(entry)) continue;
4711
+ seen.add(entry);
4712
+ merged.push(entry);
4713
+ }
4714
+ return merged;
4715
+ }
4716
+ function resolveSensitiveMode(permission) {
4717
+ return permission?.sensitiveMode ?? DEFAULT_SENSITIVE_MODE;
4718
+ }
4719
+ var deprecatedDeniedDirsWarned = false;
4720
+ function warnDeprecatedPermissionKeys(permission, warn) {
4721
+ if (deprecatedDeniedDirsWarned) return;
4722
+ const legacy = permission?.deniedDirs;
4723
+ if (!Array.isArray(legacy) || legacy.length === 0) return;
4724
+ deprecatedDeniedDirsWarned = true;
4725
+ warn(
4726
+ "permission.deniedDirs is deprecated and will be removed in a future release \u2014 use permission.sensitiveDirs instead (both keys are read and merged)."
4727
+ );
4728
+ }
4729
+
4730
+ // src/onboard.ts
4398
4731
  function fillApiKeys(config) {
4399
4732
  const envKey = process.env.WAYANG_LLM_API_KEY || "";
4400
4733
  for (const provider of Object.values(config.providers)) {
@@ -4549,6 +4882,7 @@ async function loadConfig(configPath2, options = {}) {
4549
4882
  }
4550
4883
  }
4551
4884
  fillApiKeys(config);
4885
+ warnDeprecatedPermissionKeys(config.permission, (message) => console.warn(`Warning: ${message}`));
4552
4886
  const err = validateConfig(config);
4553
4887
  if (err) {
4554
4888
  if (nonInteractive) throw new Error(`Invalid config: ${err}`);
@@ -5261,7 +5595,7 @@ var SignalQueue = class {
5261
5595
  };
5262
5596
 
5263
5597
  // src/services/worker-transcript.ts
5264
- import { closeSync as closeSync3, existsSync as existsSync8, openSync as openSync3, readSync, statSync } from "fs";
5598
+ import { closeSync as closeSync4, existsSync as existsSync8, openSync as openSync4, readSync as readSync2, statSync as statSync2 } from "fs";
5265
5599
  import { join as join12 } from "path";
5266
5600
 
5267
5601
  // src/services/agents/worker-state.ts
@@ -5805,7 +6139,7 @@ function readWorkerTranscriptWindow(sessionDir, workerId, opts = {}) {
5805
6139
  const path9 = workerTranscriptPath(sessionDir, workerId);
5806
6140
  let size;
5807
6141
  try {
5808
- size = statSync(path9).size;
6142
+ size = statSync2(path9).size;
5809
6143
  } catch {
5810
6144
  return null;
5811
6145
  }
@@ -5819,11 +6153,11 @@ function readWorkerTranscriptWindow(sessionDir, workerId, opts = {}) {
5819
6153
  const readLen = Math.min(size, windowBytes);
5820
6154
  const start = size - readLen;
5821
6155
  const chunk = Buffer.alloc(readLen);
5822
- const fd = openSync3(path9, "r");
6156
+ const fd = openSync4(path9, "r");
5823
6157
  try {
5824
- readSync(fd, chunk, 0, readLen, start);
6158
+ readSync2(fd, chunk, 0, readLen, start);
5825
6159
  } finally {
5826
- closeSync3(fd);
6160
+ closeSync4(fd);
5827
6161
  }
5828
6162
  const parsed = parseWindow(chunk.toString("utf8"), start === 0);
5829
6163
  const enough = parsed.entries.filter(matches).length >= need;
@@ -5873,7 +6207,7 @@ function entryLabel(entry) {
5873
6207
  return subtype ? `${entry.type}/${subtype}` : entry.type;
5874
6208
  }
5875
6209
  var TERMINAL_SUBTYPES = /* @__PURE__ */ new Set(["task_result" /* TaskResult */, "error" /* Error */]);
5876
- function entryTimestamp(entry) {
6210
+ function entryTimestamp2(entry) {
5877
6211
  const e = entry;
5878
6212
  for (const raw of [e.timestamp, e.endedAt, e.startedAt]) {
5879
6213
  if (typeof raw === "string") {
@@ -5886,7 +6220,7 @@ function entryTimestamp(entry) {
5886
6220
  function summarizeTranscript(entries) {
5887
6221
  let lastActivityAt = null;
5888
6222
  for (let i = entries.length - 1; i >= 0; i--) {
5889
- lastActivityAt = entryTimestamp(entries[i]);
6223
+ lastActivityAt = entryTimestamp2(entries[i]);
5890
6224
  if (lastActivityAt !== null) break;
5891
6225
  }
5892
6226
  let currentTool = null;
@@ -7050,7 +7384,7 @@ function buildWorkerList(workers) {
7050
7384
  }
7051
7385
  return section("Available workers", lines.join("\n"));
7052
7386
  }
7053
- function buildControllerSystemPrompt(ctx, skills) {
7387
+ function buildControllerSystemPrompt(ctx, skills, opts) {
7054
7388
  return assemble(
7055
7389
  IDENTITY,
7056
7390
  RESPONSE_STYLE,
@@ -7061,13 +7395,82 @@ function buildControllerSystemPrompt(ctx, skills) {
7061
7395
  HARD_CONSTRAINTS,
7062
7396
  buildWorkerList(ctx.config.workers),
7063
7397
  buildSkillCatalog(skills),
7064
- buildEnvironment(ctx)
7398
+ buildEnvironment(ctx),
7399
+ // Project conventions go last: they are the most specific instruction and
7400
+ // the last word on conflicts with the generic sections above.
7401
+ opts?.rules
7065
7402
  );
7066
7403
  }
7067
7404
 
7405
+ // src/services/agents/prompts/controller-rules.ts
7406
+ import { readFileSync as readFileSync8, realpathSync as realpathSync2, statSync as statSync3 } from "fs";
7407
+ import { join as join14 } from "path";
7408
+ var CONTROLLER_RULES_FILENAME = "AGENTS.md";
7409
+ var CONTROLLER_RULES_MAX_BYTES = 32 * 1024;
7410
+ function loadControllerRules(opts) {
7411
+ const maxBytes = opts.maxBytes ?? CONTROLLER_RULES_MAX_BYTES;
7412
+ const files = [];
7413
+ for (const dir of [opts.homeDir, opts.cwd]) {
7414
+ if (!dir) continue;
7415
+ const file = readRulesFile(dir);
7416
+ if (!file) continue;
7417
+ if (files.some((seen) => seen.path === file.path)) continue;
7418
+ files.push(file);
7419
+ }
7420
+ if (files.length === 0) return "";
7421
+ const bodies = [];
7422
+ let usedBytes = 0;
7423
+ let truncated = false;
7424
+ for (const file of files) {
7425
+ const heading = `## Project rules (from ${file.path})`;
7426
+ const size = Buffer.byteLength(file.content, "utf8");
7427
+ if (usedBytes + size <= maxBytes) {
7428
+ bodies.push(`${heading}
7429
+
7430
+ ${file.content.trimEnd()}`);
7431
+ usedBytes += size;
7432
+ continue;
7433
+ }
7434
+ const budget = maxBytes - usedBytes;
7435
+ const head = budget > 0 ? truncateToBytes(file.content, budget).trimEnd() : "";
7436
+ bodies.push(head ? `${heading}
7437
+
7438
+ ${head}` : heading);
7439
+ truncated = true;
7440
+ break;
7441
+ }
7442
+ const intro = `${CONTROLLER_RULES_FILENAME} content loaded once at startup. The home directory file comes first as the baseline; the working directory file comes second and takes precedence wherever the two disagree. Follow these rules unless the user explicitly overrides them.`;
7443
+ const parts = [`# Project rules
7444
+
7445
+ ${intro}`, ...bodies];
7446
+ if (truncated) {
7447
+ parts.push(
7448
+ `...truncated at ${maxBytes} bytes (rules content exceeded the ${maxBytes}-byte total limit: the rest of the last file and any later files were dropped)`
7449
+ );
7450
+ }
7451
+ return parts.join("\n\n");
7452
+ }
7453
+ function readRulesFile(dir) {
7454
+ const candidate = join14(dir, CONTROLLER_RULES_FILENAME);
7455
+ try {
7456
+ const path9 = realpathSync2(candidate);
7457
+ if (!statSync3(path9).isFile()) return null;
7458
+ const content = readFileSync8(path9, "utf8");
7459
+ if (!content.trim()) return null;
7460
+ return { path: path9, content };
7461
+ } catch {
7462
+ return null;
7463
+ }
7464
+ }
7465
+ function truncateToBytes(text, maxBytes) {
7466
+ const buf = Buffer.from(text, "utf8");
7467
+ if (buf.length <= maxBytes) return text;
7468
+ return buf.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD+$/, "");
7469
+ }
7470
+
7068
7471
  // src/services/agents/prompts/project-rules.ts
7069
- import { existsSync as existsSync9, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
7070
- import { basename as basename5, dirname as dirname6, join as join14, resolve as resolve3 } from "path";
7472
+ import { existsSync as existsSync9, readFileSync as readFileSync9, statSync as statSync4 } from "fs";
7473
+ import { basename as basename5, dirname as dirname6, join as join15, resolve as resolve3 } from "path";
7071
7474
  var RULE_FILE_NAMES = ["AGENTS.md", "CLAUDE.md"];
7072
7475
  var MAX_LEVELS = 5;
7073
7476
  var PROJECT_RULES_MAX_LENGTH = 8e3;
@@ -7076,7 +7479,7 @@ function findProjectRules(startDir) {
7076
7479
  for (let level = 0; level < MAX_LEVELS; level++) {
7077
7480
  const found = readRuleFile(current);
7078
7481
  if (found) return found;
7079
- if (existsSync9(join14(current, ".git"))) break;
7482
+ if (existsSync9(join15(current, ".git"))) break;
7080
7483
  const parent = dirname6(current);
7081
7484
  if (parent === current) break;
7082
7485
  current = parent;
@@ -7085,10 +7488,10 @@ function findProjectRules(startDir) {
7085
7488
  }
7086
7489
  function readRuleFile(dir) {
7087
7490
  for (const name of RULE_FILE_NAMES) {
7088
- const path9 = join14(dir, name);
7491
+ const path9 = join15(dir, name);
7089
7492
  try {
7090
- if (!statSync2(path9).isFile()) continue;
7091
- const content = readFileSync8(path9, "utf8");
7493
+ if (!statSync4(path9).isFile()) continue;
7494
+ const content = readFileSync9(path9, "utf8");
7092
7495
  if (!content.trim()) continue;
7093
7496
  return { path: path9, content };
7094
7497
  } catch {
@@ -7588,9 +7991,9 @@ function toolResultBlockToRecord(block, toolName, endedAt) {
7588
7991
  import { resolve as resolve5 } from "path";
7589
7992
 
7590
7993
  // src/infra/permission/policy.ts
7591
- import { realpathSync as realpathSync2 } from "fs";
7592
- import { basename as basename6, dirname as dirname7, isAbsolute, join as join15, resolve as resolve4, sep } from "path";
7593
- var DEFAULT_DENIED_RELATIVE_PATHS = [
7994
+ import { realpathSync as realpathSync3 } from "fs";
7995
+ import { basename as basename6, dirname as dirname7, isAbsolute, join as join16, resolve as resolve4, sep } from "path";
7996
+ var DEFAULT_SENSITIVE_RELATIVE_PATHS = [
7594
7997
  ".ssh",
7595
7998
  ".gnupg",
7596
7999
  ".aws",
@@ -7624,12 +8027,15 @@ var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([
7624
8027
  var SENSITIVE_FILENAMES = /* @__PURE__ */ new Set([
7625
8028
  ".wayang.config.json"
7626
8029
  ]);
8030
+ var SENSITIVE_SEGMENT_PAIRS = [
8031
+ [".docker", "config.json"]
8032
+ ];
7627
8033
  var MAX_ANCESTOR_DEPTH = 32;
7628
8034
  function normalizePath(p, opts = {}) {
7629
8035
  const base = opts.base ?? process.cwd();
7630
8036
  const lexical = isAbsolute(p) ? resolve4(p) : resolve4(base, p);
7631
8037
  try {
7632
- return realpathSync2(lexical);
8038
+ return realpathSync3(lexical);
7633
8039
  } catch {
7634
8040
  return resolveDeepestExistingAncestor(lexical);
7635
8041
  }
@@ -7639,8 +8045,8 @@ function resolveDeepestExistingAncestor(lexical) {
7639
8045
  let candidate = lexical;
7640
8046
  for (let depth = 0; depth <= MAX_ANCESTOR_DEPTH; depth += 1) {
7641
8047
  try {
7642
- const resolved = realpathSync2(candidate);
7643
- return peeled.length === 0 ? resolved : resolve4(join15(resolved, ...peeled));
8048
+ const resolved = realpathSync3(candidate);
8049
+ return peeled.length === 0 ? resolved : resolve4(join16(resolved, ...peeled));
7644
8050
  } catch {
7645
8051
  }
7646
8052
  const parent = dirname7(candidate);
@@ -7650,8 +8056,8 @@ function resolveDeepestExistingAncestor(lexical) {
7650
8056
  }
7651
8057
  return lexical;
7652
8058
  }
7653
- function defaultDeniedPaths(homeDir2) {
7654
- return DEFAULT_DENIED_RELATIVE_PATHS.map((rel) => resolve4(homeDir2, rel));
8059
+ function defaultSensitivePaths(homeDir2) {
8060
+ return DEFAULT_SENSITIVE_RELATIVE_PATHS.map((rel) => resolve4(homeDir2, rel));
7655
8061
  }
7656
8062
  function isPathWithin(parent, child) {
7657
8063
  if (parent === sep) return child.startsWith(sep);
@@ -7659,21 +8065,39 @@ function isPathWithin(parent, child) {
7659
8065
  const prefix = parent.endsWith(sep) ? parent : parent + sep;
7660
8066
  return child.startsWith(prefix);
7661
8067
  }
7662
- function isDenied(absPath, deniedPaths) {
7663
- return deniedPaths.some((denied) => isPathWithin(denied, absPath));
8068
+ function isDenied(absPath, sensitivePaths) {
8069
+ return sensitivePaths.some((sensitive) => isPathWithin(sensitive, absPath));
7664
8070
  }
7665
- function isSensitiveSegmentPath(absPath) {
8071
+ function matchSensitiveEntry(absPath) {
7666
8072
  const segments = absPath.split(sep).filter((s) => s.length > 0);
7667
8073
  for (let i = 0; i < segments.length; i += 1) {
7668
8074
  const segment = segments[i];
7669
- if (SENSITIVE_SEGMENTS.has(segment) || SENSITIVE_FILENAMES.has(segment)) {
7670
- return true;
7671
- }
7672
- if (segment === ".docker" && segments[i + 1] === "config.json") {
7673
- return true;
8075
+ if (SENSITIVE_SEGMENTS.has(segment)) return { entry: segment, source: "segment" };
8076
+ if (SENSITIVE_FILENAMES.has(segment)) return { entry: segment, source: "filename" };
8077
+ for (const [dir, child] of SENSITIVE_SEGMENT_PAIRS) {
8078
+ if (segment === dir && segments[i + 1] === child) {
8079
+ return { entry: `${dir}/${child}`, source: "pair" };
8080
+ }
7674
8081
  }
7675
8082
  }
7676
- return false;
8083
+ return void 0;
8084
+ }
8085
+ function sensitivePathEntries(homeDir2, extraSensitiveDirs) {
8086
+ const builtIn = DEFAULT_SENSITIVE_RELATIVE_PATHS.map((rel) => ({
8087
+ absPath: normalizePath(resolve4(homeDir2, rel)),
8088
+ entry: rel,
8089
+ source: "home-default"
8090
+ }));
8091
+ const configured = (extraSensitiveDirs ?? []).filter((d) => typeof d === "string" && d.length > 0).map((d) => ({
8092
+ absPath: normalizePath(d, { base: homeDir2 }),
8093
+ entry: d,
8094
+ source: "config-dir"
8095
+ }));
8096
+ return [...builtIn, ...configured];
8097
+ }
8098
+ function matchSensitivePath(homeDir2, absPath, extraSensitiveDirs) {
8099
+ const hit = sensitivePathEntries(homeDir2, extraSensitiveDirs).find((entry) => isPathWithin(entry.absPath, absPath));
8100
+ return hit ? { entry: hit.entry, source: hit.source } : void 0;
7677
8101
  }
7678
8102
  function normalizeDirs(dirs, base) {
7679
8103
  if (!dirs) return [];
@@ -7689,15 +8113,17 @@ function decideAccess(input2) {
7689
8113
  const taskAllowedDirs = normalizeDirs(input2.taskAllowedDirs, taskWorkspace ?? homeDir2);
7690
8114
  const configAllowedDirs = normalizeDirs(input2.configAllowedDirs, homeDir2);
7691
8115
  const autoAllowRoots = normalizeDirs(input2.autoAllowRoots, homeDir2);
7692
- const deniedPaths = [
7693
- ...defaultDeniedPaths(homeDir2),
7694
- ...normalizeDirs(input2.extraDeniedDirs, homeDir2)
7695
- ].map((d) => normalizePath(d));
7696
- if (isSensitiveSegmentPath(resolvedPath)) {
7697
- return { decision: "deny", matchedRule: "denylist", resolvedPath };
7698
- }
7699
- if (isDenied(resolvedPath, deniedPaths)) {
7700
- return { decision: "deny", matchedRule: "denylist", resolvedPath };
8116
+ const sensitiveMode = input2.sensitiveMode ?? "ask";
8117
+ const sensitive = matchSensitiveEntry(resolvedPath) ?? matchSensitivePath(homeDir2, resolvedPath, input2.extraSensitiveDirs);
8118
+ if (sensitive) {
8119
+ const hit = { matchedEntry: sensitive.entry, matchedSource: sensitive.source, sensitive: true };
8120
+ if (input2.access === "write") {
8121
+ return { decision: "deny", matchedRule: "sensitive-list-write", resolvedPath, ...hit };
8122
+ }
8123
+ if (sensitiveMode === "deny") {
8124
+ return { decision: "deny", matchedRule: "sensitive-list", resolvedPath, ...hit };
8125
+ }
8126
+ return { decision: "ask", matchedRule: "sensitive-list", resolvedPath, ...hit };
7701
8127
  }
7702
8128
  if (findContaining(configAllowedDirs, resolvedPath) !== void 0) {
7703
8129
  return { decision: "allow", matchedRule: "config-allowed", resolvedPath };
@@ -7717,11 +8143,403 @@ function decideAccess(input2) {
7717
8143
  return { decision: "ask", matchedRule: "outside-all", resolvedPath };
7718
8144
  }
7719
8145
 
8146
+ // src/services/tools/shell-lexer.ts
8147
+ var CMDSUBST_PLACEHOLDER = "__cmdsubst__";
8148
+ var DISCARD_TARGETS = /* @__PURE__ */ new Set(["/dev/null", "/dev/stdout", "/dev/stderr"]);
8149
+ function isDiscardRedirectTarget(target) {
8150
+ return target !== void 0 && DISCARD_TARGETS.has(target);
8151
+ }
8152
+ function isWordTerminator(ch) {
8153
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "|" || ch === ";" || ch === "&" || ch === "<" || ch === ">";
8154
+ }
8155
+ function findClosingDoubleQuote(command, start) {
8156
+ let i = start + 1;
8157
+ while (i < command.length) {
8158
+ const ch = command[i];
8159
+ if (ch === "\\") {
8160
+ i += 2;
8161
+ continue;
8162
+ }
8163
+ if (ch === '"') return i;
8164
+ i += 1;
8165
+ }
8166
+ return -1;
8167
+ }
8168
+ function findClosingBacktick(command, start) {
8169
+ let i = start + 1;
8170
+ while (i < command.length) {
8171
+ const ch = command[i];
8172
+ if (ch === "\\") {
8173
+ i += 2;
8174
+ continue;
8175
+ }
8176
+ if (ch === "`") return i;
8177
+ i += 1;
8178
+ }
8179
+ return -1;
8180
+ }
8181
+ function scanBacktickSubstitution(command, start) {
8182
+ const end = findClosingBacktick(command, start);
8183
+ if (end === -1) return { error: 'unbalanced backtick \u2014 no closing "`"' };
8184
+ return { inner: command.slice(start + 1, end), next: end + 1 };
8185
+ }
8186
+ function scanDollarSubstitution(command, start) {
8187
+ const n = command.length;
8188
+ let i = start + 2;
8189
+ let depth = 1;
8190
+ let inner = "";
8191
+ while (i < n) {
8192
+ const ch = command[i];
8193
+ if (ch === "\\") {
8194
+ inner += command.slice(i, i + 2);
8195
+ i += 2;
8196
+ continue;
8197
+ }
8198
+ if (ch === "'") {
8199
+ const end = command.indexOf("'", i + 1);
8200
+ if (end === -1) return { error: "unterminated single quote inside command substitution" };
8201
+ inner += command.slice(i, end + 1);
8202
+ i = end + 1;
8203
+ continue;
8204
+ }
8205
+ if (ch === '"') {
8206
+ const end = findClosingDoubleQuote(command, i);
8207
+ if (end === -1) return { error: "unterminated double quote inside command substitution" };
8208
+ inner += command.slice(i, end + 1);
8209
+ i = end + 1;
8210
+ continue;
8211
+ }
8212
+ if (ch === "`") {
8213
+ const end = findClosingBacktick(command, i);
8214
+ if (end === -1) return { error: "unterminated backtick inside command substitution" };
8215
+ inner += command.slice(i, end + 1);
8216
+ i = end + 1;
8217
+ continue;
8218
+ }
8219
+ if (ch === "(") {
8220
+ depth += 1;
8221
+ inner += ch;
8222
+ i += 1;
8223
+ continue;
8224
+ }
8225
+ if (ch === ")") {
8226
+ depth -= 1;
8227
+ if (depth === 0) return { inner, next: i + 1 };
8228
+ inner += ch;
8229
+ i += 1;
8230
+ continue;
8231
+ }
8232
+ inner += ch;
8233
+ i += 1;
8234
+ }
8235
+ return { error: 'unbalanced "$(" \u2014 no closing ")"' };
8236
+ }
8237
+ function scanSubstitutionAt(command, start) {
8238
+ const dollarForm = command[start] === "$" && command[start + 1] === "(";
8239
+ const backtickForm = command[start] === "`";
8240
+ if (!dollarForm && !backtickForm) return void 0;
8241
+ const scanned = dollarForm ? scanDollarSubstitution(command, start) : scanBacktickSubstitution(command, start);
8242
+ if ("error" in scanned) return scanned;
8243
+ return {
8244
+ substitution: { inner: scanned.inner, raw: command.slice(start, scanned.next) },
8245
+ next: scanned.next
8246
+ };
8247
+ }
8248
+ function scanWordLite(command, start) {
8249
+ const n = command.length;
8250
+ let i = start;
8251
+ let text = "";
8252
+ const substitutions = [];
8253
+ const takeSubstitution = () => {
8254
+ const scanned = scanSubstitutionAt(command, i);
8255
+ if (!scanned) return { ok: true, consumed: false };
8256
+ if ("error" in scanned) return { ok: false, error: scanned.error };
8257
+ substitutions.push(scanned.substitution);
8258
+ text += CMDSUBST_PLACEHOLDER;
8259
+ i = scanned.next;
8260
+ return { ok: true, consumed: true };
8261
+ };
8262
+ while (i < n) {
8263
+ const ch = command[i];
8264
+ if (ch === "\\") {
8265
+ if (i + 1 < n) {
8266
+ text += command[i + 1];
8267
+ i += 2;
8268
+ } else {
8269
+ text += ch;
8270
+ i += 1;
8271
+ }
8272
+ continue;
8273
+ }
8274
+ if (ch === "'") {
8275
+ const end = command.indexOf("'", i + 1);
8276
+ if (end === -1) return { error: "unterminated single quote" };
8277
+ text += command.slice(i + 1, end);
8278
+ i = end + 1;
8279
+ continue;
8280
+ }
8281
+ if (ch === '"') {
8282
+ i += 1;
8283
+ let closed = false;
8284
+ while (i < n) {
8285
+ const inner = command[i];
8286
+ if (inner === "\\") {
8287
+ if (i + 1 < n) {
8288
+ text += command[i + 1];
8289
+ i += 2;
8290
+ } else {
8291
+ text += inner;
8292
+ i += 1;
8293
+ }
8294
+ continue;
8295
+ }
8296
+ if (inner === '"') {
8297
+ i += 1;
8298
+ closed = true;
8299
+ break;
8300
+ }
8301
+ const taken2 = takeSubstitution();
8302
+ if (!taken2.ok) return { error: taken2.error };
8303
+ if (taken2.consumed) continue;
8304
+ text += inner;
8305
+ i += 1;
8306
+ }
8307
+ if (!closed) return { error: "unterminated double quote" };
8308
+ continue;
8309
+ }
8310
+ const taken = takeSubstitution();
8311
+ if (!taken.ok) return { error: taken.error };
8312
+ if (taken.consumed) continue;
8313
+ if (isWordTerminator(ch)) break;
8314
+ text += ch;
8315
+ i += 1;
8316
+ }
8317
+ return { word: { text, substitutions }, next: i };
8318
+ }
8319
+ function scanFdDupTail(command, start) {
8320
+ const match = /^&(\d+|-)/.exec(command.slice(start, start + 8));
8321
+ if (!match) return { error: "unrecognised file-descriptor redirection" };
8322
+ return { tail: match[0], next: start + match[0].length };
8323
+ }
8324
+ function scanRedirectTarget(command, start) {
8325
+ let i = start;
8326
+ while (i < command.length && (command[i] === " " || command[i] === " ")) i += 1;
8327
+ const ch = command[i];
8328
+ if (ch === void 0 || isWordTerminator(ch)) return { error: "redirection without a target" };
8329
+ const scanned = scanWordLite(command, i);
8330
+ if ("error" in scanned) return scanned;
8331
+ if (scanned.word.text.length === 0 && scanned.word.substitutions.length === 0) {
8332
+ return { error: "redirection without a target" };
8333
+ }
8334
+ return scanned;
8335
+ }
8336
+ function scanRedirect(command, start, fd) {
8337
+ const ch = command[start];
8338
+ if (ch === "&") {
8339
+ if (command[start + 1] !== ">") return { error: 'unexpected "&" in a redirection' };
8340
+ const append2 = command[start + 2] === ">";
8341
+ const target2 = scanRedirectTarget(command, start + (append2 ? 3 : 2));
8342
+ if ("error" in target2) return target2;
8343
+ return {
8344
+ redirect: { operator: append2 ? "&>>" : "&>", kind: "output", fd: 2, target: target2.word },
8345
+ next: target2.next
8346
+ };
8347
+ }
8348
+ if (ch === "<") {
8349
+ if (command[start + 1] === "<") {
8350
+ if (command[start + 2] === "<") {
8351
+ const target4 = scanRedirectTarget(command, start + 3);
8352
+ if ("error" in target4) return target4;
8353
+ return {
8354
+ redirect: { operator: "<<<", kind: "here-string", target: target4.word },
8355
+ next: target4.next
8356
+ };
8357
+ }
8358
+ const stripTabs = command[start + 2] === "-";
8359
+ const target3 = scanRedirectTarget(command, start + (stripTabs ? 3 : 2));
8360
+ if ("error" in target3) return target3;
8361
+ return {
8362
+ redirect: { operator: stripTabs ? "<<-" : "<<", kind: "here-doc", target: target3.word },
8363
+ next: target3.next,
8364
+ heredoc: { delimiter: target3.word.text, stripTabs }
8365
+ };
8366
+ }
8367
+ if (command[start + 1] === "&") {
8368
+ const dup = scanFdDupTail(command, start + 1);
8369
+ if ("error" in dup) return dup;
8370
+ return { redirect: { operator: `${fd ?? ""}<${dup.tail}`, kind: "fd-dup" }, next: dup.next };
8371
+ }
8372
+ if (command[start + 1] === ">") {
8373
+ const target3 = scanRedirectTarget(command, start + 2);
8374
+ if ("error" in target3) return target3;
8375
+ return {
8376
+ redirect: { operator: `${fd ?? ""}<>`, kind: "output", fd: fd ?? 0, target: target3.word },
8377
+ next: target3.next
8378
+ };
8379
+ }
8380
+ const target2 = scanRedirectTarget(command, start + 1);
8381
+ if ("error" in target2) return target2;
8382
+ return {
8383
+ redirect: { operator: `${fd ?? ""}<`, kind: "input", target: target2.word },
8384
+ next: target2.next
8385
+ };
8386
+ }
8387
+ const append = command[start + 1] === ">";
8388
+ let cursor = start + (append ? 2 : 1);
8389
+ const clobber = !append && command[cursor] === "|";
8390
+ if (clobber) cursor += 1;
8391
+ const operator = `${fd ?? ""}${append ? ">>" : ">"}${clobber ? "|" : ""}`;
8392
+ if (command[cursor] === "&") {
8393
+ const dup = scanFdDupTail(command, cursor);
8394
+ if ("error" in dup) return dup;
8395
+ return { redirect: { operator: `${operator}${dup.tail}`, kind: "fd-dup" }, next: dup.next };
8396
+ }
8397
+ const target = scanRedirectTarget(command, cursor);
8398
+ if ("error" in target) return target;
8399
+ return {
8400
+ redirect: { operator, kind: "output", fd: fd ?? 1, target: target.word },
8401
+ next: target.next
8402
+ };
8403
+ }
8404
+ function consumeHeredocBodies(command, start, heredocs) {
8405
+ let pos = start;
8406
+ for (const heredoc of heredocs) {
8407
+ let matched = false;
8408
+ while (pos < command.length) {
8409
+ const newline = command.indexOf("\n", pos);
8410
+ const lineEnd2 = newline === -1 ? command.length : newline;
8411
+ const raw = command.slice(pos, lineEnd2);
8412
+ const line = heredoc.stripTabs ? raw.replace(/^\t+/, "") : raw;
8413
+ pos = lineEnd2 + 1;
8414
+ if (line === heredoc.delimiter) {
8415
+ matched = true;
8416
+ break;
8417
+ }
8418
+ if (newline === -1) break;
8419
+ }
8420
+ if (!matched) return void 0;
8421
+ }
8422
+ return pos;
8423
+ }
8424
+ function tokenizeShellLite(command) {
8425
+ const n = command.length;
8426
+ const segments = [];
8427
+ let tokens = [];
8428
+ let pendingHeredocs = [];
8429
+ let i = 0;
8430
+ const endSegment = () => {
8431
+ if (tokens.length > 0) segments.push({ tokens });
8432
+ tokens = [];
8433
+ };
8434
+ const takeRedirect = (redirectStart, fd) => {
8435
+ const scanned = scanRedirect(command, redirectStart, fd);
8436
+ if ("error" in scanned) return { ok: false, error: scanned.error };
8437
+ tokens.push({ kind: "redirect", redirect: scanned.redirect });
8438
+ if (scanned.heredoc) pendingHeredocs.push(scanned.heredoc);
8439
+ i = scanned.next;
8440
+ return void 0;
8441
+ };
8442
+ while (i < n) {
8443
+ const ch = command[i];
8444
+ if (ch === " " || ch === " " || ch === "\r") {
8445
+ i += 1;
8446
+ continue;
8447
+ }
8448
+ if (ch === "\\" && command[i + 1] === "\n") {
8449
+ i += 2;
8450
+ continue;
8451
+ }
8452
+ if (ch === "\n") {
8453
+ endSegment();
8454
+ i += 1;
8455
+ if (pendingHeredocs.length > 0) {
8456
+ const after = consumeHeredocBodies(command, i, pendingHeredocs);
8457
+ if (after === void 0) return { ok: false, error: "here-document without its terminator line" };
8458
+ i = after;
8459
+ pendingHeredocs = [];
8460
+ }
8461
+ continue;
8462
+ }
8463
+ if (ch === "#") {
8464
+ while (i < n && command[i] !== "\n") i += 1;
8465
+ continue;
8466
+ }
8467
+ if (ch === "|" || ch === ";" || ch === "&") {
8468
+ if (ch === "&" && command[i + 1] === ">") {
8469
+ const failed = takeRedirect(i);
8470
+ if (failed) return failed;
8471
+ continue;
8472
+ }
8473
+ i += 1;
8474
+ while (i < n && (command[i] === "|" || command[i] === ";" || command[i] === "&")) i += 1;
8475
+ endSegment();
8476
+ continue;
8477
+ }
8478
+ const fdMatch = /^(\d+)(?=>>|>|<>|<)/.exec(command.slice(i, i + 24));
8479
+ const fd = fdMatch ? Number(fdMatch[1]) : void 0;
8480
+ const redirectStart = i + (fdMatch ? fdMatch[1].length : 0);
8481
+ const redirectChar = command[redirectStart];
8482
+ if (redirectChar === "<" || redirectChar === ">") {
8483
+ const failed = takeRedirect(redirectStart, fd);
8484
+ if (failed) return failed;
8485
+ continue;
8486
+ }
8487
+ const scanned = scanWordLite(command, i);
8488
+ if ("error" in scanned) return { ok: false, error: scanned.error };
8489
+ if (scanned.next === i) return { ok: false, error: `unexpected character "${ch}"` };
8490
+ tokens.push({ kind: "word", word: scanned.word });
8491
+ i = scanned.next;
8492
+ }
8493
+ if (pendingHeredocs.length > 0) {
8494
+ return { ok: false, error: "here-document without a body" };
8495
+ }
8496
+ endSegment();
8497
+ return { ok: true, segments };
8498
+ }
8499
+ function classifyRedirections(segments) {
8500
+ const result2 = {
8501
+ writeTargets: [],
8502
+ readTargets: [],
8503
+ duplicates: [],
8504
+ discarded: []
8505
+ };
8506
+ for (const segment of segments) {
8507
+ for (const token of segment.tokens) {
8508
+ if (token.kind !== "redirect") continue;
8509
+ const { redirect } = token;
8510
+ const target = redirect.target?.text;
8511
+ if (redirect.kind === "fd-dup") {
8512
+ result2.duplicates.push(redirect.operator);
8513
+ continue;
8514
+ }
8515
+ if (redirect.kind === "here-string" || redirect.kind === "here-doc") continue;
8516
+ if (target === void 0) continue;
8517
+ if (isDiscardRedirectTarget(target)) {
8518
+ result2.discarded.push(target);
8519
+ continue;
8520
+ }
8521
+ if (redirect.kind === "input") {
8522
+ if (target.includes(CMDSUBST_PLACEHOLDER)) {
8523
+ result2.unresolved ??= `${redirect.operator} ${target}`;
8524
+ continue;
8525
+ }
8526
+ result2.readTargets.push(target);
8527
+ continue;
8528
+ }
8529
+ if (target.includes(CMDSUBST_PLACEHOLDER)) {
8530
+ result2.unresolved ??= `${redirect.operator} ${target}`;
8531
+ continue;
8532
+ }
8533
+ result2.writeTargets.push(target);
8534
+ }
8535
+ }
8536
+ return result2;
8537
+ }
8538
+
7720
8539
  // src/services/tools/permission-rules.ts
7721
8540
  var FILE_TOOL_NAMES = ["read_file", "write_file", "edit_file", "search_files", "search_content"];
7722
8541
  var READ_ONLY_FILE_TOOLS = /* @__PURE__ */ new Set(["read_file", "search_files", "search_content"]);
7723
8542
  var DANGEROUS_BASH_PATTERN = /(?:^|\||;|&&)\s*(?:rm\s|sudo\b|chmod\b|chown\b|mkfs\b|dd\s|shutdown\b|reboot\b|kill\s+-9)/;
7724
- var URL_RE = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\S+/g;
7725
8543
  function expandHome(p, homeDir2) {
7726
8544
  if (p === "~") return { path: homeDir2, unresolvable: false };
7727
8545
  if (p.startsWith("~/")) return { path: resolve5(homeDir2, p.slice(2)), unresolvable: false };
@@ -7731,25 +8549,110 @@ function expandHome(p, homeDir2) {
7731
8549
  function expandHomeVars(command, homeDir2) {
7732
8550
  return command.replace(/\$\{HOME\}/g, homeDir2).replace(/\$HOME(?![A-Za-z0-9_])/g, homeDir2);
7733
8551
  }
7734
- function detectUnsafeDynamicTarget(command) {
7735
- if (command.includes("`")) return "command substitution (backticks)";
7736
- if (/\$\(/.test(command)) return "command substitution ($(...))";
7737
- if (hasOutputRedirection(command)) return "output redirection";
7738
- return void 0;
8552
+ var CMDSUBST_READONLY_VERBS = /* @__PURE__ */ new Set([
8553
+ "cat",
8554
+ "ls",
8555
+ "grep",
8556
+ "head",
8557
+ "tail",
8558
+ "wc",
8559
+ "stat",
8560
+ "file",
8561
+ "which",
8562
+ "command",
8563
+ "readlink",
8564
+ "echo",
8565
+ "printf",
8566
+ "date",
8567
+ "pwd"
8568
+ ]);
8569
+ var MAX_CMDSUBST_DEPTH = 5;
8570
+ var BASH_WRITE_VERBS = /* @__PURE__ */ new Set([
8571
+ "rm",
8572
+ "rmdir",
8573
+ "mv",
8574
+ "cp",
8575
+ "ln",
8576
+ "tee",
8577
+ "dd",
8578
+ "truncate",
8579
+ "install",
8580
+ "mkdir",
8581
+ "touch",
8582
+ "chmod",
8583
+ "chown",
8584
+ "chgrp",
8585
+ "shred",
8586
+ "unlink",
8587
+ "chattr",
8588
+ "mkfs",
8589
+ "mkswap",
8590
+ "mount",
8591
+ "umount"
8592
+ ]);
8593
+ var BASH_FLAGGED_WRITE_VERBS = /* @__PURE__ */ new Map([
8594
+ ["sed", ["-i", "--in-place"]],
8595
+ ["perl", ["-i"]],
8596
+ ["ruby", ["-i"]],
8597
+ ["find", ["-delete", "-fprint", "-fls", "-fprintf", "-exec", "-execdir", "-ok"]]
8598
+ ]);
8599
+ function isWriteSegment(segment) {
8600
+ const words = [];
8601
+ for (const token of segment.tokens) {
8602
+ if (token.kind === "word") {
8603
+ words.push(token.word.text);
8604
+ continue;
8605
+ }
8606
+ const { redirect } = token;
8607
+ if (redirect.kind !== "output") continue;
8608
+ if (isDiscardRedirectTarget(redirect.target?.text)) continue;
8609
+ return true;
8610
+ }
8611
+ for (const word of words) {
8612
+ if (BASH_WRITE_VERBS.has(word)) return true;
8613
+ const flags2 = BASH_FLAGGED_WRITE_VERBS.get(word);
8614
+ if (flags2 && flags2.some((flag) => words.includes(flag))) return true;
8615
+ }
8616
+ return false;
7739
8617
  }
7740
- function hasOutputRedirection(command) {
7741
- return /(?:^|[^<>])>>?\s*(?!&)\S/.test(command);
8618
+ function pathPieces(text, access) {
8619
+ const candidates = [];
8620
+ for (const piece of text.split("=")) {
8621
+ if (!piece.startsWith("/") && !piece.startsWith("~")) continue;
8622
+ if (isDiscardRedirectTarget(piece)) continue;
8623
+ candidates.push({ path: piece, access });
8624
+ }
8625
+ return candidates;
7742
8626
  }
7743
- function extractPathCandidates(command) {
7744
- const withoutUrls = command.replace(URL_RE, " ");
8627
+ function extractPathCandidates(segments) {
7745
8628
  const candidates = [];
7746
- for (const raw of withoutUrls.split(/[\s=;|&]+/)) {
7747
- const word = raw.replace(/^['"]+/, "").replace(/['",;|&()<>'`]+$/, "");
7748
- if (word.startsWith("/") || word.startsWith("~")) candidates.push(word);
8629
+ for (const segment of segments) {
8630
+ const segmentAccess = isWriteSegment(segment) ? "write" : "unknown";
8631
+ for (const token of segment.tokens) {
8632
+ if (token.kind === "word") {
8633
+ candidates.push(...pathPieces(token.word.text, segmentAccess));
8634
+ continue;
8635
+ }
8636
+ const { redirect } = token;
8637
+ if (redirect.kind === "fd-dup" || redirect.kind === "here-doc" || redirect.kind === "here-string") continue;
8638
+ const target = redirect.target?.text;
8639
+ if (target === void 0 || isDiscardRedirectTarget(target)) continue;
8640
+ if (target.includes(CMDSUBST_PLACEHOLDER)) continue;
8641
+ const access = redirect.kind === "output" ? "write" : segmentAccess;
8642
+ for (const candidate of pathPieces(target, access)) {
8643
+ candidates.push(
8644
+ redirect.kind === "output" ? { ...candidate, reason: `output redirection to "${target}"` } : candidate
8645
+ );
8646
+ }
8647
+ }
7749
8648
  }
7750
8649
  return candidates;
7751
8650
  }
8651
+ function commandAccess(candidates) {
8652
+ return candidates.some((candidate) => candidate.access === "write") ? "write" : "unknown";
8653
+ }
7752
8654
  function decidePath(scope, path9, access) {
8655
+ const permission = scope.permission;
7753
8656
  return decideAccess({
7754
8657
  path: path9,
7755
8658
  access,
@@ -7757,11 +8660,28 @@ function decidePath(scope, path9, access) {
7757
8660
  homeDir: scope.homeDir,
7758
8661
  taskWorkspace: scope.workspace,
7759
8662
  taskAllowedDirs: scope.taskAllowedDirs,
7760
- configAllowedDirs: scope.permission?.allowedDirs,
7761
- autoAllowRoots: scope.permission?.autoAllowRoots,
7762
- extraDeniedDirs: scope.permission?.deniedDirs
8663
+ configAllowedDirs: permission?.allowedDirs,
8664
+ autoAllowRoots: permission?.autoAllowRoots,
8665
+ // `sensitiveDirs` is the current key; `deniedDirs` is its deprecated alias
8666
+ // and is merged (deduped) here so a legacy config keeps protecting exactly
8667
+ // the directories it declares.
8668
+ extraSensitiveDirs: mergeSensitiveDirs(permission),
8669
+ sensitiveMode: resolveSensitiveMode(permission)
7763
8670
  });
7764
8671
  }
8672
+ function toToolDecision(verdict, access) {
8673
+ return {
8674
+ decision: verdict.decision,
8675
+ matchedRule: verdict.matchedRule,
8676
+ resolvedPath: verdict.resolvedPath,
8677
+ access,
8678
+ ...verdict.matchedEntry !== void 0 && {
8679
+ matchedEntry: verdict.matchedEntry,
8680
+ matchedSource: verdict.matchedSource,
8681
+ sensitive: true
8682
+ }
8683
+ };
8684
+ }
7765
8685
  function evaluateFileAccess(scope, toolName, args) {
7766
8686
  const rawPath = args.path;
7767
8687
  if (typeof rawPath !== "string" || !rawPath) return { decision: "allow", matchedRule: "no-path" };
@@ -7776,35 +8696,163 @@ function evaluateFileAccess(scope, toolName, args) {
7776
8696
  }
7777
8697
  const verdict = decidePath(scope, expanded.path, access);
7778
8698
  if (verdict.decision === "allow" && verdict.withinReadScope) {
7779
- return { decision: "ask", matchedRule: "read-within-home" };
8699
+ return { decision: "ask", matchedRule: "read-within-home", resolvedPath: verdict.resolvedPath, access };
7780
8700
  }
7781
- return { decision: verdict.decision, matchedRule: verdict.matchedRule };
8701
+ return toToolDecision(verdict, access);
8702
+ }
8703
+ function collectSubstitutions(segments) {
8704
+ const substitutions = [];
8705
+ for (const segment of segments) {
8706
+ for (const token of segment.tokens) {
8707
+ const word = token.kind === "word" ? token.word : token.redirect.target;
8708
+ if (word) substitutions.push(...word.substitutions);
8709
+ }
8710
+ }
8711
+ return substitutions;
8712
+ }
8713
+ function checkSubstitutionInner(inner) {
8714
+ const tokenized = tokenizeShellLite(inner);
8715
+ if (!tokenized.ok) return { ok: false, reason: `cannot parse it (${tokenized.error})` };
8716
+ if (tokenized.segments.length === 0) return { ok: false, reason: "it is empty" };
8717
+ for (const segment of tokenized.segments) {
8718
+ const verb = segment.tokens.find((token) => token.kind === "word");
8719
+ if (!verb || verb.kind !== "word" || verb.word.text === CMDSUBST_PLACEHOLDER) {
8720
+ return { ok: false, reason: "it has no command word to judge" };
8721
+ }
8722
+ if (!CMDSUBST_READONLY_VERBS.has(verb.word.text)) {
8723
+ return { ok: false, reason: `\`${verb.word.text}\` is not a read-only command` };
8724
+ }
8725
+ if (isWriteSegment(segment)) return { ok: false, reason: `\`${verb.word.text}\` writes` };
8726
+ }
8727
+ return { ok: true };
8728
+ }
8729
+ function evaluateSubstitutions(scope, segments, depth) {
8730
+ const substitutions = collectSubstitutions(segments);
8731
+ if (substitutions.length === 0) return void 0;
8732
+ if (depth + 1 > MAX_CMDSUBST_DEPTH) {
8733
+ return {
8734
+ decision: "ask",
8735
+ matchedRule: "dynamic-shell",
8736
+ reason: `command substitution nested deeper than ${MAX_CMDSUBST_DEPTH} levels`
8737
+ };
8738
+ }
8739
+ for (const substitution of substitutions) {
8740
+ const inner = substitution.inner.trim();
8741
+ if (inner.length === 0) {
8742
+ return { decision: "ask", matchedRule: "dynamic-shell", reason: `empty command substitution ${substitution.raw}` };
8743
+ }
8744
+ const readOnly = checkSubstitutionInner(inner);
8745
+ if (!readOnly.ok) {
8746
+ return {
8747
+ decision: "ask",
8748
+ matchedRule: "dynamic-shell",
8749
+ reason: `command substitution ${substitution.raw}: ${readOnly.reason}`
8750
+ };
8751
+ }
8752
+ const verdict = evaluateBashCommand(scope, inner, depth + 1);
8753
+ if (verdict.decision === "deny") return verdict;
8754
+ if (verdict.decision === "ask") {
8755
+ const detail = verdict.reason ?? (verdict.resolvedPath ? `target ${verdict.resolvedPath}` : verdict.matchedRule);
8756
+ return {
8757
+ decision: "ask",
8758
+ matchedRule: "dynamic-shell",
8759
+ reason: `command substitution ${substitution.raw} needs approval: ${detail}`,
8760
+ // Carry the inner hit through, so an approval request still names the
8761
+ // path (and the sensitive entry) the substituted command would touch.
8762
+ ...verdict.resolvedPath !== void 0 && { resolvedPath: verdict.resolvedPath },
8763
+ ...verdict.matchedEntry !== void 0 && {
8764
+ matchedEntry: verdict.matchedEntry,
8765
+ matchedSource: verdict.matchedSource,
8766
+ sensitive: true
8767
+ }
8768
+ };
8769
+ }
8770
+ }
8771
+ return void 0;
8772
+ }
8773
+ function fallbackSensitiveWrite(scope, expandedCommand) {
8774
+ for (const raw of expandedCommand.split(/[\s=]+/)) {
8775
+ const word = raw.replace(/^['"]+/, "").replace(/['"]+$/, "");
8776
+ if (!word.startsWith("/") && !word.startsWith("~")) continue;
8777
+ const expanded = expandHome(word, scope.homeDir);
8778
+ if (expanded.unresolvable) continue;
8779
+ const verdict = decidePath(scope, expanded.path, "write");
8780
+ if (verdict.decision === "deny") return toToolDecision(verdict, "write");
8781
+ }
8782
+ return void 0;
7782
8783
  }
7783
8784
  function evaluateBashAccess(scope, command) {
8785
+ return evaluateBashCommand(scope, command, 0);
8786
+ }
8787
+ function evaluateBashCommand(scope, command, depth) {
7784
8788
  const expandedCommand = expandHomeVars(command, scope.homeDir);
8789
+ const tokenized = tokenizeShellLite(expandedCommand);
8790
+ if (!tokenized.ok) {
8791
+ const refusal = fallbackSensitiveWrite(scope, expandedCommand);
8792
+ if (refusal) return refusal;
8793
+ return {
8794
+ decision: "ask",
8795
+ matchedRule: "dynamic-shell",
8796
+ reason: `could not parse the shell command (${tokenized.error})`
8797
+ };
8798
+ }
8799
+ const segments = tokenized.segments;
8800
+ const redirections = classifyRedirections(segments);
7785
8801
  const candidates = [];
7786
8802
  let hasUnresolvableHome = false;
7787
- for (const raw of extractPathCandidates(expandedCommand)) {
7788
- const expanded = expandHome(raw, scope.homeDir);
8803
+ for (const candidate of extractPathCandidates(segments)) {
8804
+ const expanded = expandHome(candidate.path, scope.homeDir);
7789
8805
  if (expanded.unresolvable) {
7790
8806
  hasUnresolvableHome = true;
7791
8807
  continue;
7792
8808
  }
7793
- candidates.push(expanded.path);
8809
+ candidates.push({ ...candidate, path: expanded.path });
7794
8810
  }
7795
8811
  let firstAsk;
7796
8812
  let firstAllow;
7797
8813
  for (const candidate of candidates) {
7798
- const verdict = decidePath(scope, candidate, "write");
7799
- if (verdict.decision === "deny") return verdict;
7800
- if (verdict.decision === "ask") firstAsk ??= verdict;
7801
- else firstAllow ??= verdict;
8814
+ const verdict = decidePath(scope, candidate.path, candidate.access);
8815
+ if (verdict.decision === "deny") return toToolDecision(verdict, candidate.access);
8816
+ if (verdict.decision === "ask") {
8817
+ firstAsk ??= {
8818
+ ...toToolDecision(verdict, candidate.access),
8819
+ ...candidate.reason !== void 0 && { reason: candidate.reason }
8820
+ };
8821
+ } else firstAllow ??= toToolDecision(verdict, candidate.access);
7802
8822
  }
8823
+ const lineAccess = commandAccess(candidates);
8824
+ if (firstAsk) firstAsk = { ...firstAsk, access: lineAccess };
8825
+ if (firstAllow) firstAllow = { ...firstAllow, access: lineAccess };
7803
8826
  if (DANGEROUS_BASH_PATTERN.test(expandedCommand)) {
7804
- return { decision: "ask", matchedRule: "dangerous-bash" };
8827
+ const hit = firstAsk?.matchedEntry !== void 0 ? firstAsk : void 0;
8828
+ return {
8829
+ decision: "ask",
8830
+ matchedRule: "dangerous-bash",
8831
+ access: lineAccess,
8832
+ ...hit !== void 0 && {
8833
+ matchedEntry: hit.matchedEntry,
8834
+ matchedSource: hit.matchedSource,
8835
+ resolvedPath: hit.resolvedPath,
8836
+ sensitive: true
8837
+ }
8838
+ };
8839
+ }
8840
+ const substitution = evaluateSubstitutions(scope, segments, depth);
8841
+ if (substitution) return substitution;
8842
+ if (redirections.unresolved !== void 0) {
8843
+ return {
8844
+ decision: "ask",
8845
+ matchedRule: "dynamic-shell",
8846
+ reason: `output redirection target cannot be determined statically (${redirections.unresolved})`
8847
+ };
8848
+ }
8849
+ if (redirections.writeTargets.length > 0 && !firstAsk) {
8850
+ return {
8851
+ decision: "ask",
8852
+ matchedRule: "dynamic-shell",
8853
+ reason: `output redirection writes a file ("${redirections.writeTargets.join('", "')}")`
8854
+ };
7805
8855
  }
7806
- const dynamic = detectUnsafeDynamicTarget(expandedCommand);
7807
- if (dynamic) return { decision: "ask", matchedRule: "dynamic-shell", reason: dynamic };
7808
8856
  if (hasUnresolvableHome) {
7809
8857
  return {
7810
8858
  decision: "ask",
@@ -7815,12 +8863,13 @@ function evaluateBashAccess(scope, command) {
7815
8863
  if (firstAsk) return firstAsk;
7816
8864
  return firstAllow ?? { decision: "allow", matchedRule: "no-path" };
7817
8865
  }
7818
- function denyMessage(toolName, requestedPath) {
8866
+ function denyMessage(toolName, requestedPath, matchedRule) {
8867
+ const write = matchedRule === "sensitive-list-write";
7819
8868
  if (toolName === "bash") {
7820
- return "[ERROR] bash: refusing to run this command \u2014 it touches a path on the deny list (a sensitive location).";
8869
+ return write ? "[ERROR] bash: refusing to run this command \u2014 it would write to a path on the sensitive list (sensitive paths are never writable)." : "[ERROR] bash: refusing to run this command \u2014 it touches a path on the sensitive list (a protected location).";
7821
8870
  }
7822
8871
  const target = requestedPath ? ` "${requestedPath}"` : "";
7823
- return `[ERROR] ${toolName}: refusing access to${target} \u2014 the path is on the deny list (a sensitive location).`;
8872
+ return write ? `[ERROR] ${toolName}: refusing to write${target} \u2014 the path is on the sensitive list (sensitive paths are never writable).` : `[ERROR] ${toolName}: refusing access to${target} \u2014 the path is on the sensitive list (a protected location).`;
7824
8873
  }
7825
8874
  function decideToolAccess(scope, toolName, args) {
7826
8875
  let verdict;
@@ -7832,7 +8881,51 @@ function decideToolAccess(scope, toolName, args) {
7832
8881
  return { decision: "allow", matchedRule: "no-path" };
7833
8882
  }
7834
8883
  if (verdict.decision !== "deny") return verdict;
7835
- return { ...verdict, message: denyMessage(toolName, typeof args.path === "string" ? args.path : void 0) };
8884
+ return {
8885
+ ...verdict,
8886
+ message: denyMessage(toolName, typeof args.path === "string" ? args.path : void 0, verdict.matchedRule)
8887
+ };
8888
+ }
8889
+
8890
+ // src/services/permission/permission-audit.ts
8891
+ function operationForTool(toolName) {
8892
+ if (toolName === "bash") return "exec";
8893
+ if (toolName === "read_file" || toolName === "search_files" || toolName === "search_content") return "read";
8894
+ return "write";
8895
+ }
8896
+ function auditAccessFor(access) {
8897
+ return access ?? "unknown";
8898
+ }
8899
+ function recordPermissionAudit(logger, entry) {
8900
+ if (!logger) return;
8901
+ logger.info(
8902
+ {
8903
+ audit: "permission",
8904
+ decision: entry.decision,
8905
+ operation: entry.operation,
8906
+ ...entry.access !== void 0 && { access: entry.access },
8907
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
8908
+ ...entry.resolvedPath !== void 0 && { resolvedPath: entry.resolvedPath },
8909
+ ...entry.requestedPath !== void 0 && { requestedPath: entry.requestedPath },
8910
+ ...entry.matchedEntry !== void 0 && { matchedEntry: entry.matchedEntry },
8911
+ ...entry.matchedSource !== void 0 && { matchedSource: entry.matchedSource },
8912
+ ...entry.matchedRule !== void 0 && { matchedRule: entry.matchedRule },
8913
+ ...entry.taskId !== void 0 && { taskId: entry.taskId },
8914
+ ...entry.workerId !== void 0 && { workerId: entry.workerId },
8915
+ ...entry.toolName !== void 0 && { toolName: entry.toolName },
8916
+ ...entry.source !== void 0 && { source: entry.source }
8917
+ },
8918
+ "permission audit"
8919
+ );
8920
+ }
8921
+ function verdictDetailSuffix(detail) {
8922
+ const parts = [];
8923
+ if (detail.matchedEntry) {
8924
+ parts.push(`hit: ${detail.matchedEntry}${detail.matchedSource ? ` (${detail.matchedSource})` : ""}`);
8925
+ }
8926
+ if (detail.matchedRule) parts.push(`rule: ${detail.matchedRule}`);
8927
+ if (detail.operation) parts.push(`op: ${detail.operation}`);
8928
+ return parts.length > 0 ? ` [${parts.join(" ; ")}]` : "";
7836
8929
  }
7837
8930
 
7838
8931
  // src/infra/blocking-requests.ts
@@ -8243,10 +9336,11 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8243
9336
  }
8244
9337
  /**
8245
9338
  * Route a Claude Code tool call through the SAME access policy a puppet
8246
- * worker uses (`decideToolAccess`). A denylisted path is denied outright
8247
- * (no approval prompt); an ambiguous target asks the controller; a path
8248
- * inside the allowed scope runs immediately. This closes the parity gap where
8249
- * the old regex-only gate let denylisted paths through silently.
9339
+ * worker uses (`decideToolAccess`). A sensitive WRITE is denied outright
9340
+ * (no approval prompt), a sensitive READ asks with the matched entry named,
9341
+ * an ambiguous target asks the controller and a path inside the allowed scope
9342
+ * runs immediately. This closes the parity gap where the old regex-only gate
9343
+ * let sensitive paths through silently.
8250
9344
  */
8251
9345
  async handleCanUseTool(toolName, input2) {
8252
9346
  if (!this._signalQueue || !this._runContext) {
@@ -8255,42 +9349,91 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8255
9349
  const scope = this.effectiveAccessScope();
8256
9350
  if (toolName === "Bash") {
8257
9351
  const verdict = decideToolAccess(scope, "bash", { command: String(input2.command ?? "") });
8258
- if (verdict.decision === "deny") {
8259
- return { behavior: "deny", message: verdict.message ?? "Denied by the permission policy" };
8260
- }
8261
- if (verdict.decision === "ask") return this.requestPermission(toolName, input2, verdict.reason);
9352
+ const operation = operationForTool("bash");
9353
+ if (verdict.decision === "deny") return this.denyWithAudit(toolName, input2, verdict, operation);
9354
+ if (verdict.decision === "ask") return this.requestPermission(toolName, input2, this.askOf(verdict, operation));
8262
9355
  return { behavior: "allow" };
8263
9356
  }
8264
9357
  const fileTool = _ClaudeCodeWorker.SDK_FILE_TOOL_MAP[toolName];
8265
9358
  if (fileTool) {
9359
+ const operation = operationForTool(fileTool.policyTool);
8266
9360
  const rawPath = input2[fileTool.pathKey];
8267
9361
  if (typeof rawPath !== "string" || rawPath.length === 0) {
8268
9362
  if (fileTool.pathRequired) {
8269
- return this.requestPermission(toolName, input2, "the target path could not be determined");
9363
+ return this.requestPermission(toolName, input2, {
9364
+ reason: "the target path could not be determined",
9365
+ operation
9366
+ });
8270
9367
  }
8271
9368
  return { behavior: "allow" };
8272
9369
  }
8273
9370
  const verdict = decideToolAccess(scope, fileTool.policyTool, { path: rawPath });
8274
- if (verdict.decision === "deny") {
8275
- return { behavior: "deny", message: verdict.message ?? "Denied by the permission policy" };
8276
- }
8277
- if (verdict.decision === "ask") return this.requestPermission(toolName, input2, verdict.reason);
9371
+ if (verdict.decision === "deny") return this.denyWithAudit(toolName, input2, verdict, operation);
9372
+ if (verdict.decision === "ask") return this.requestPermission(toolName, input2, this.askOf(verdict, operation));
8278
9373
  return { behavior: "allow" };
8279
9374
  }
8280
9375
  if (_ClaudeCodeWorker.SDK_NON_PATH_TOOLS.has(toolName)) {
8281
9376
  return { behavior: "allow" };
8282
9377
  }
8283
- return this.requestPermission(toolName, input2, "the tool is not recognized by the permission policy");
9378
+ return this.requestPermission(toolName, input2, {
9379
+ reason: "the tool is not recognized by the permission policy",
9380
+ operation: operationForTool("bash")
9381
+ });
9382
+ }
9383
+ /**
9384
+ * Refuse a call the policy hard-denied (sensitive write) and record it.
9385
+ *
9386
+ * The worker receives the policy's disclosure-safe message (its own path
9387
+ * spelling, never the matched entry); the audit trail gets the metadata.
9388
+ * Same shape as the puppet worker's audit, so both entries agree.
9389
+ */
9390
+ denyWithAudit(toolName, input2, verdict, operation) {
9391
+ recordPermissionAudit(this.logger, {
9392
+ decision: "hard-denied",
9393
+ operation,
9394
+ access: auditAccessFor(verdict.access),
9395
+ resolvedPath: verdict.resolvedPath,
9396
+ requestedPath: this.requestedPathOf(input2),
9397
+ matchedEntry: verdict.matchedEntry,
9398
+ matchedSource: verdict.matchedSource,
9399
+ matchedRule: verdict.matchedRule,
9400
+ taskId: this._runContext?.taskId,
9401
+ workerId: this._runContext?.workerId,
9402
+ toolName
9403
+ });
9404
+ return { behavior: "deny", message: verdict.message ?? "Denied by the permission policy" };
9405
+ }
9406
+ /** Project a policy verdict onto the approval-request/audit fields. */
9407
+ askOf(verdict, operation) {
9408
+ return {
9409
+ reason: verdict.reason,
9410
+ matchedRule: verdict.matchedRule,
9411
+ matchedEntry: verdict.matchedEntry,
9412
+ matchedSource: verdict.matchedSource,
9413
+ resolvedPath: verdict.resolvedPath,
9414
+ operation,
9415
+ sensitive: verdict.sensitive,
9416
+ access: verdict.access
9417
+ };
9418
+ }
9419
+ /** The target as the worker spelled it (for auditing against the prompt). */
9420
+ requestedPathOf(input2) {
9421
+ for (const key of ["file_path", "notebook_path", "path"]) {
9422
+ const value = input2[key];
9423
+ if (typeof value === "string" && value.length > 0) return value;
9424
+ }
9425
+ return void 0;
8284
9426
  }
8285
9427
  /**
8286
9428
  * Emit a permission_request and block until the controller answers (or it
8287
9429
  * times out → deny). Shared by every `ask` verdict so the request flow stays
8288
9430
  * identical to the puppet worker's.
8289
9431
  */
8290
- async requestPermission(toolName, input2, reason) {
9432
+ async requestPermission(toolName, input2, ask) {
8291
9433
  if (!this._signalQueue || !this._runContext) return { behavior: "allow" };
8292
9434
  const requestId = generateId("perm");
8293
- const description = this.describePermissionRequest(toolName, input2, reason);
9435
+ const description = this.describePermissionRequest(toolName, input2, ask);
9436
+ const operation = ask.operation;
8294
9437
  this._signalQueue.enqueue({
8295
9438
  source: "worker",
8296
9439
  type: "permission_request",
@@ -8303,16 +9446,43 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8303
9446
  emoji: this._runContext.emoji,
8304
9447
  toolName,
8305
9448
  toolArgs: input2,
8306
- description
9449
+ description,
9450
+ ...ask.matchedRule !== void 0 && { matchedRule: ask.matchedRule },
9451
+ ...ask.matchedEntry !== void 0 && { matchedEntry: ask.matchedEntry },
9452
+ ...ask.matchedSource !== void 0 && { matchedSource: ask.matchedSource },
9453
+ ...ask.resolvedPath !== void 0 && { resolvedPath: ask.resolvedPath },
9454
+ ...ask.sensitive !== void 0 && { sensitive: ask.sensitive },
9455
+ ...ask.access !== void 0 && { access: ask.access },
9456
+ operation
8307
9457
  },
8308
9458
  ...this._turnLineage
8309
9459
  });
8310
- return this._pendingPermissions.wait(requestId, _ClaudeCodeWorker.PERMISSION_TIMEOUT_MS, () => {
8311
- return { behavior: "deny", message: "Permission request timed out" };
9460
+ const result2 = await this._pendingPermissions.wait(
9461
+ requestId,
9462
+ _ClaudeCodeWorker.PERMISSION_TIMEOUT_MS,
9463
+ () => ({ behavior: "deny", message: "Permission request timed out" })
9464
+ );
9465
+ const denied = result2.behavior === "deny";
9466
+ recordPermissionAudit(this.logger, {
9467
+ decision: denied ? "denied" : "approved",
9468
+ operation,
9469
+ access: auditAccessFor(ask.access),
9470
+ resolvedPath: ask.resolvedPath,
9471
+ requestedPath: this.requestedPathOf(input2),
9472
+ matchedEntry: ask.matchedEntry,
9473
+ matchedSource: ask.matchedSource,
9474
+ matchedRule: ask.matchedRule,
9475
+ taskId: this._runContext.taskId,
9476
+ workerId: this._runContext.workerId,
9477
+ toolName,
9478
+ ...denied && {
9479
+ source: result2.message === "Permission request timed out" ? "timeout" : "controller"
9480
+ }
8312
9481
  });
9482
+ return result2;
8313
9483
  }
8314
9484
  /** Human-readable summary of a gated call for the approval prompt. */
8315
- describePermissionRequest(toolName, input2, reason) {
9485
+ describePermissionRequest(toolName, input2, ask) {
8316
9486
  let detail;
8317
9487
  if (toolName === "Bash") {
8318
9488
  detail = `Execute shell command: ${String(input2.command ?? "")}`;
@@ -8325,7 +9495,15 @@ var ClaudeCodeWorker = class _ClaudeCodeWorker {
8325
9495
  } else {
8326
9496
  detail = `${toolName}: ${JSON.stringify(input2)}`;
8327
9497
  }
8328
- return reason ? `${detail} (${reason})` : detail;
9498
+ const hit = ask.sensitive ? verdictDetailSuffix({
9499
+ matchedRule: ask.matchedRule,
9500
+ matchedEntry: ask.matchedEntry,
9501
+ matchedSource: ask.matchedSource,
9502
+ resolvedPath: ask.resolvedPath,
9503
+ operation: ask.operation
9504
+ }) : "";
9505
+ const withHit = `${detail}${hit}`;
9506
+ return ask.reason ? `${withHit} (${ask.reason})` : withHit;
8329
9507
  }
8330
9508
  // --- Prompt as AsyncIterable (supports follow-up messages) ---
8331
9509
  buildPromptIterable(initialPrompt) {
@@ -8633,8 +9811,8 @@ controller: ${answer}`,
8633
9811
  import { z as z3 } from "zod";
8634
9812
 
8635
9813
  // src/services/tools/workspace-rules.ts
8636
- import { readFileSync as readFileSync9, statSync as statSync3 } from "fs";
8637
- import { join as join16 } from "path";
9814
+ import { readFileSync as readFileSync10, statSync as statSync5 } from "fs";
9815
+ import { join as join17 } from "path";
8638
9816
  var RULE_FILE_NAMES2 = ["AGENTS.md", "CLAUDE.md"];
8639
9817
  var WORKSPACE_RULES_MAX_LINES = 40;
8640
9818
  var WORKSPACE_RULES_MAX_CHARS = 2e3;
@@ -8642,7 +9820,7 @@ function buildWorkspaceRulesSummary(input2) {
8642
9820
  const workspacePath = input2.workspacePath;
8643
9821
  if (!workspacePath) return "";
8644
9822
  for (const name of RULE_FILE_NAMES2) {
8645
- const rulesPath = join16(workspacePath, name);
9823
+ const rulesPath = join17(workspacePath, name);
8646
9824
  if (!isRegularFile(rulesPath)) continue;
8647
9825
  const header = `Workspace rules (from ${rulesPath}):`;
8648
9826
  const refusal = describeRefusal(rulesPath, workspacePath, input2);
@@ -8664,16 +9842,17 @@ function describeRefusal(rulesPath, workspacePath, input2) {
8664
9842
  if (!homeDir2) {
8665
9843
  return "unavailable \u2014 the permission policy cannot be evaluated here (no home directory in this context), so the file was not read.";
8666
9844
  }
8667
- const { decision } = decideAccess({
9845
+ const { decision, matchedEntry } = decideAccess({
8668
9846
  access: "read",
8669
9847
  path: rulesPath,
8670
9848
  cwd: workspacePath,
8671
9849
  homeDir: homeDir2,
8672
9850
  taskWorkspace: workspacePath,
8673
- extraDeniedDirs: input2.permissionConfig?.deniedDirs
9851
+ extraSensitiveDirs: mergeSensitiveDirs(input2.permissionConfig),
9852
+ sensitiveMode: resolveSensitiveMode(input2.permissionConfig)
8674
9853
  });
8675
- if (decision === "deny") {
8676
- return "unavailable \u2014 the file resolves to a denied location (a sensitive one such as credentials or tool config), so its content was not read.";
9854
+ if (matchedEntry !== void 0 || decision === "deny") {
9855
+ return "unavailable \u2014 the file resolves to a sensitive location (a protected path such as credentials or tool config), so its content was not read.";
8677
9856
  }
8678
9857
  if (decision === "ask") {
8679
9858
  return "unavailable \u2014 reading it was not approved by the permission policy (the path is outside the readable scope), so its content was not read.";
@@ -8700,14 +9879,14 @@ function summarizeRules(content) {
8700
9879
  }
8701
9880
  function isRegularFile(p) {
8702
9881
  try {
8703
- return statSync3(p).isFile();
9882
+ return statSync5(p).isFile();
8704
9883
  } catch {
8705
9884
  return false;
8706
9885
  }
8707
9886
  }
8708
9887
  function readTextFile(p) {
8709
9888
  try {
8710
- return readFileSync9(p, "utf8");
9889
+ return readFileSync10(p, "utf8");
8711
9890
  } catch {
8712
9891
  return null;
8713
9892
  }
@@ -8739,9 +9918,9 @@ function resolveTaskAllowedDirs(requested, workspacePath, deps) {
8739
9918
  }
8740
9919
  const home = normalizePath(homeDir2);
8741
9920
  const base = workspacePath !== void 0 ? normalizePath(workspacePath, { base: home }) : home;
8742
- const deniedPaths = [
8743
- ...defaultDeniedPaths(home),
8744
- ...(deps.permissionConfig?.deniedDirs ?? []).filter((d) => typeof d === "string" && d.length > 0).map((d) => normalizePath(d, { base: home }))
9921
+ const sensitivePaths = [
9922
+ ...defaultSensitivePaths(home),
9923
+ ...mergeSensitiveDirs(deps.permissionConfig).map((d) => normalizePath(d, { base: home }))
8745
9924
  ].map((d) => normalizePath(d));
8746
9925
  const seen = /* @__PURE__ */ new Set();
8747
9926
  const allowedDirs = [];
@@ -8757,9 +9936,9 @@ function resolveTaskAllowedDirs(requested, workspacePath, deps) {
8757
9936
  error: `Invalid allowedDirs entry "${raw}" (resolved to ${resolved}): it is outside the home directory (${home}). Only directories under home can be granted to a task. The task was NOT created.`
8758
9937
  };
8759
9938
  }
8760
- if (isDenied(resolved, deniedPaths)) {
9939
+ if (isDenied(resolved, sensitivePaths)) {
8761
9940
  return {
8762
- error: `Invalid allowedDirs entry "${raw}" (resolved to ${resolved}): it matches the denied-path list (a sensitive location such as credentials or tool config). The task was NOT created.`
9941
+ error: `Invalid allowedDirs entry "${raw}" (resolved to ${resolved}): it matches the sensitive list (a protected location such as credentials or tool config). The task was NOT created.`
8763
9942
  };
8764
9943
  }
8765
9944
  if (!seen.has(resolved)) {
@@ -9362,7 +10541,7 @@ ${detail}`;
9362
10541
 
9363
10542
  // src/services/tools/read-file.ts
9364
10543
  import { z as z14 } from "zod";
9365
- import { readFileSync as readFileSync10 } from "fs";
10544
+ import { readFileSync as readFileSync11 } from "fs";
9366
10545
  import { resolve as resolve6 } from "path";
9367
10546
  var DEFAULT_MAX_LINES = 500;
9368
10547
  function readFileTool(deps = {}) {
@@ -9376,7 +10555,7 @@ function readFileTool(deps = {}) {
9376
10555
  }),
9377
10556
  execute: safeExecute("read_file", async ({ path: path9, offset, limit }) => {
9378
10557
  const resolved = resolve6(workspace, path9);
9379
- const content = readFileSync10(resolved, "utf-8");
10558
+ const content = readFileSync11(resolved, "utf-8");
9380
10559
  const lines = content.split("\n");
9381
10560
  const totalLines = lines.length;
9382
10561
  const start = offset ? offset - 1 : 0;
@@ -9419,7 +10598,7 @@ function writeFileTool(deps = {}) {
9419
10598
  }
9420
10599
 
9421
10600
  // src/services/tools/edit-file.ts
9422
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
10601
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "fs";
9423
10602
  import { resolve as resolve8 } from "path";
9424
10603
  import { z as z16 } from "zod";
9425
10604
  function editFileTool(deps) {
@@ -9435,7 +10614,7 @@ function editFileTool(deps) {
9435
10614
  const resolved = resolve8(workspace, path9);
9436
10615
  let content;
9437
10616
  try {
9438
- content = readFileSync11(resolved, "utf-8");
10617
+ content = readFileSync12(resolved, "utf-8");
9439
10618
  } catch {
9440
10619
  return `[ERROR] edit_file: File not found: ${path9}`;
9441
10620
  }
@@ -9466,15 +10645,15 @@ function searchFilesTool(deps) {
9466
10645
  execute: safeExecute("search_files", async ({ pattern, path: path9 }) => {
9467
10646
  const basePath = path9 ? normalizePath(path9, { base: workspace }) : normalizePath(workspace);
9468
10647
  if (homeDir2) {
9469
- const { decision } = decideAccess({
10648
+ const { decision, matchedEntry } = decideAccess({
9470
10649
  access: "read",
9471
10650
  homeDir: homeDir2,
9472
10651
  cwd: workspace,
9473
10652
  path: basePath
9474
10653
  });
9475
10654
  const requested = path9 ?? workspace;
9476
- if (decision === "deny") {
9477
- return `[ERROR] search_files: refusing to search "${requested}" \u2014 the path is on the read denylist (a sensitive location).`;
10655
+ if (matchedEntry !== void 0 || decision === "deny") {
10656
+ return `[ERROR] search_files: refusing to search "${requested}" \u2014 the path is on the sensitive list (a protected location).`;
9478
10657
  }
9479
10658
  if (decision === "ask") {
9480
10659
  return `[ERROR] search_files: refusing to search "${requested}" \u2014 the path is outside the home boundary (${homeDir2}). The controller can only read files under the home directory.`;
@@ -9878,11 +11057,32 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
9878
11057
  return originalExecute(args, options);
9879
11058
  }
9880
11059
  if (verdict.decision === "deny") {
11060
+ recordPermissionAudit(deps.logger, {
11061
+ decision: "hard-denied",
11062
+ operation: verdict.operation ?? operationForTool(toolName),
11063
+ access: auditAccessFor(verdict.access),
11064
+ resolvedPath: verdict.resolvedPath,
11065
+ requestedPath: typeof args.path === "string" ? args.path : void 0,
11066
+ matchedEntry: verdict.matchedEntry,
11067
+ matchedSource: verdict.matchedSource,
11068
+ matchedRule: verdict.matchedRule,
11069
+ taskId: deps.taskId,
11070
+ workerId: deps.workerId,
11071
+ toolName
11072
+ });
9881
11073
  return verdict.message ?? `[ERROR] ${toolName}: refused by the path permission policy`;
9882
11074
  }
9883
11075
  const requestId = generateId("perm");
11076
+ const operation = verdict.operation ?? operationForTool(toolName);
9884
11077
  const baseDescription = config.describeCall(toolName, args);
9885
- const description = verdict.reason ? `${baseDescription} [reason: ${verdict.reason}]` : baseDescription;
11078
+ const hitDetail = verdict.sensitive ? verdictDetailSuffix({
11079
+ matchedRule: verdict.matchedRule,
11080
+ matchedEntry: verdict.matchedEntry,
11081
+ matchedSource: verdict.matchedSource,
11082
+ resolvedPath: verdict.resolvedPath,
11083
+ operation
11084
+ }) : "";
11085
+ const description = `${baseDescription}${hitDetail}${verdict.reason ? ` [reason: ${verdict.reason}]` : ""}`;
9886
11086
  deps.signalQueue.enqueue({
9887
11087
  source: "worker",
9888
11088
  type: "permission_request",
@@ -9895,7 +11095,14 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
9895
11095
  emoji: deps.emoji,
9896
11096
  toolName,
9897
11097
  toolArgs: args,
9898
- description
11098
+ description,
11099
+ ...verdict.matchedRule !== void 0 && { matchedRule: verdict.matchedRule },
11100
+ ...verdict.matchedEntry !== void 0 && { matchedEntry: verdict.matchedEntry },
11101
+ ...verdict.matchedSource !== void 0 && { matchedSource: verdict.matchedSource },
11102
+ ...verdict.resolvedPath !== void 0 && { resolvedPath: verdict.resolvedPath },
11103
+ ...verdict.sensitive !== void 0 && { sensitive: verdict.sensitive },
11104
+ ...verdict.access !== void 0 && { access: verdict.access },
11105
+ operation
9899
11106
  },
9900
11107
  ...deps.turnLineage ?? {}
9901
11108
  });
@@ -9913,6 +11120,22 @@ function wrapWithPermissionMiddleware(tools, config, deps) {
9913
11120
  timer
9914
11121
  });
9915
11122
  });
11123
+ recordPermissionAudit(deps.logger, {
11124
+ decision: result2.approved ? "approved" : "denied",
11125
+ operation,
11126
+ access: auditAccessFor(verdict.access),
11127
+ resolvedPath: verdict.resolvedPath,
11128
+ requestedPath: typeof args.path === "string" ? args.path : void 0,
11129
+ matchedEntry: verdict.matchedEntry,
11130
+ matchedSource: verdict.matchedSource,
11131
+ matchedRule: verdict.matchedRule,
11132
+ taskId: deps.taskId,
11133
+ workerId: deps.workerId,
11134
+ toolName,
11135
+ ...!result2.approved && {
11136
+ source: result2.reason === "timeout" ? "timeout" : result2.reason === "Worker aborted" ? "aborted" : "controller"
11137
+ }
11138
+ });
9916
11139
  if (!result2.approved) {
9917
11140
  if (result2.reason === "timeout") {
9918
11141
  return `[ERROR] Permission request timed out for ${toolName}`;
@@ -10944,9 +12167,10 @@ var TaskExecuteEngine = class {
10944
12167
  if (workerType === PUPPET_WORKER_TYPE) {
10945
12168
  const middleware = wrapWithPermissionMiddleware(tools, {
10946
12169
  gatedTools: ["bash", ...FILE_TOOL_NAMES],
10947
- // Single decision source: the shared access policy (denylist →
12170
+ // Single decision source: the shared access policy (sensitive list →
10948
12171
  // config roots → task allowedDirs → task workspace → read-in-home →
10949
- // ask). `deny` refuses here without ever emitting a permission_request.
12172
+ // ask). `deny` refuses here without ever emitting a permission_request;
12173
+ // both `deny` and every answered `ask` are audited (see the middleware).
10950
12174
  decide: (toolName, args) => decideToolAccess(accessScope, toolName, args),
10951
12175
  describeCall: (toolName, args) => {
10952
12176
  if (toolName === "bash") return `Execute shell command: ${args.command}`;
@@ -10964,7 +12188,9 @@ var TaskExecuteEngine = class {
10964
12188
  workerType,
10965
12189
  emoji: workerMeta.emoji,
10966
12190
  signalQueue: this.signalQueue,
10967
- turnLineage: this.workerLineage(workerId)
12191
+ turnLineage: this.workerLineage(workerId),
12192
+ // Permission audit trail (metadata only) shares the session logger.
12193
+ logger: this.ctx.logger
10968
12194
  });
10969
12195
  tools = middleware.wrappedTools;
10970
12196
  this.permissionResolvers.set(workerId, middleware);
@@ -11218,7 +12444,7 @@ var ControllerLoop = class {
11218
12444
  };
11219
12445
 
11220
12446
  // src/services/agents/controller-state.ts
11221
- import { join as join17 } from "path";
12447
+ import { join as join18 } from "path";
11222
12448
 
11223
12449
  // src/services/conversation/conversation-index.ts
11224
12450
  function emptyUsage() {
@@ -11442,8 +12668,8 @@ var ControllerAgentState = class extends BaseWayangState {
11442
12668
  /** In-memory resolve function for the current inquiry — never persisted. */
11443
12669
  inquiryResolver = null;
11444
12670
  constructor(ctx) {
11445
- const jsonFile = new JSONFileHelper(join17(ctx.sessionDir, "runtime-state.json"));
11446
- const jsonlFile = new JSONLFileHelper(join17(ctx.sessionDir, "conversation.jsonl"));
12671
+ const jsonFile = new JSONFileHelper(join18(ctx.sessionDir, "runtime-state.json"));
12672
+ const jsonlFile = new JSONLFileHelper(join18(ctx.sessionDir, "conversation.jsonl"));
11447
12673
  const initialData = {
11448
12674
  runtimeState: {
11449
12675
  session: { id: "", startedAt: 0 },
@@ -11613,8 +12839,8 @@ function buildSummaryInput(entries, maxChars) {
11613
12839
  lines = formatAll(entries, caps);
11614
12840
  if (totalLength(lines) <= maxChars) return lines;
11615
12841
  }
11616
- const noteFor = (n) => `[${n} older entries omitted \u2014 over the summarizer input budget]`;
11617
- const reserve = noteFor(lines.length).length + 1;
12842
+ const noteFor2 = (n) => `[${n} older entries omitted \u2014 over the summarizer input budget]`;
12843
+ const reserve = noteFor2(lines.length).length + 1;
11618
12844
  const kept = [];
11619
12845
  let used = 0;
11620
12846
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -11624,7 +12850,7 @@ function buildSummaryInput(entries, maxChars) {
11624
12850
  used += size;
11625
12851
  }
11626
12852
  const omitted = lines.length - kept.length;
11627
- return omitted > 0 ? [noteFor(omitted), ...kept] : kept;
12853
+ return omitted > 0 ? [noteFor2(omitted), ...kept] : kept;
11628
12854
  }
11629
12855
 
11630
12856
  // src/services/agents/controller-agent.ts
@@ -11662,7 +12888,7 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11662
12888
  * cancelled while its workers are still running.
11663
12889
  */
11664
12890
  turnInterruptRecorded = false;
11665
- constructor(ctx, state, provider, tools, skills, recorder = new ToolCallRecorder()) {
12891
+ constructor(ctx, state, provider, tools, skills, recorder = new ToolCallRecorder(), controllerRules) {
11666
12892
  super(provider);
11667
12893
  this.ctx = ctx;
11668
12894
  this.state = state;
@@ -11673,7 +12899,7 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11673
12899
  this.workspaceSlug = ctx.workspaceDir ? resolveWorkspaceRef(wayangDirForSessionDir(ctx.sessionDir), ctx.workspaceDir).slug : void 0;
11674
12900
  this.contextManager = new ContextManager(
11675
12901
  state,
11676
- buildControllerSystemPrompt(this.ctx, skills),
12902
+ buildControllerSystemPrompt(this.ctx, skills, { rules: controllerRules }),
11677
12903
  provider.contextWindowTokens
11678
12904
  );
11679
12905
  this.setHooks({
@@ -11696,6 +12922,10 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11696
12922
  const recorder = new ToolCallRecorder(ctx.logger);
11697
12923
  const wayangDir = wayangDirForSessionDir(ctx.sessionDir);
11698
12924
  const homeDir2 = wayangDir && basename9(wayangDir) === ".wayang" ? dirname11(wayangDir) : homedir3();
12925
+ const controllerRules = loadControllerRules({
12926
+ homeDir: homeDir2,
12927
+ cwd: ctx.workspaceDir || process.cwd()
12928
+ });
11699
12929
  const tools = createControllerTools({
11700
12930
  addTask: (task) => engine.add(task),
11701
12931
  validateWorkerType: (type) => engine.validateWorkerType(type),
@@ -11725,7 +12955,8 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11725
12955
  cwd: ctx.workspaceDir,
11726
12956
  homeDir: homeDir2,
11727
12957
  // User-config permission section: add_task validates a task's allowedDirs
11728
- // against the built-in denylist plus these configured deniedDirs.
12958
+ // against the built-in sensitive list plus these configured
12959
+ // sensitiveDirs (the deprecated deniedDirs alias is merged in).
11729
12960
  permissionConfig: opts.config.permission,
11730
12961
  getNotebook: () => state.get("runtimeState.notebook") ?? "",
11731
12962
  setNotebook: (content, mode) => {
@@ -11750,7 +12981,7 @@ var ControllerAgent = class _ControllerAgent extends BaseAgent {
11750
12981
  workspaceDir: ctx.workspaceDir,
11751
12982
  wayangDir
11752
12983
  });
11753
- return new _ControllerAgent(ctx, state, provider, tools, skills, recorder);
12984
+ return new _ControllerAgent(ctx, state, provider, tools, skills, recorder, controllerRules);
11754
12985
  }
11755
12986
  /** Run the controller agent — streams text chunks while updating streamingEntries in state. */
11756
12987
  async run(signals) {
@@ -12347,8 +13578,8 @@ function signalToEntry(sig, ts, sessionId) {
12347
13578
  }
12348
13579
 
12349
13580
  // src/services/skills/registry.ts
12350
- import { readFileSync as readFileSync12, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
12351
- import { join as join18 } from "path";
13581
+ import { readFileSync as readFileSync13, readdirSync as readdirSync2, statSync as statSync6 } from "fs";
13582
+ import { join as join19 } from "path";
12352
13583
  import { parse as parseYaml } from "yaml";
12353
13584
  var SKILL_FILE = "SKILL.md";
12354
13585
  function parseFrontmatter(raw) {
@@ -12383,7 +13614,7 @@ function listResources(skillDir) {
12383
13614
  const walk = (relDir) => {
12384
13615
  let entries;
12385
13616
  try {
12386
- entries = readdirSync2(join18(skillDir, relDir), { withFileTypes: true });
13617
+ entries = readdirSync2(join19(skillDir, relDir), { withFileTypes: true });
12387
13618
  } catch {
12388
13619
  return;
12389
13620
  }
@@ -12430,10 +13661,10 @@ var SkillRegistry = class {
12430
13661
  if (existing) return existing;
12431
13662
  const desc = this.descriptors.get(name);
12432
13663
  if (!desc) return void 0;
12433
- const filePath = join18(desc.dir, SKILL_FILE);
13664
+ const filePath = join19(desc.dir, SKILL_FILE);
12434
13665
  let raw;
12435
13666
  try {
12436
- raw = readFileSync12(filePath, "utf-8");
13667
+ raw = readFileSync13(filePath, "utf-8");
12437
13668
  } catch (err) {
12438
13669
  this.logger?.warn({ skill: name, error: err instanceof Error ? err.message : String(err) }, "Failed to read skill file");
12439
13670
  return void 0;
@@ -12461,8 +13692,8 @@ var SkillRegistry = class {
12461
13692
  for (const entry of entries) {
12462
13693
  if (!entry.isDirectory()) continue;
12463
13694
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
12464
- const skillDir = join18(dir, entry.name);
12465
- const skillFile = join18(skillDir, SKILL_FILE);
13695
+ const skillDir = join19(dir, entry.name);
13696
+ const skillFile = join19(skillDir, SKILL_FILE);
12466
13697
  if (!existsAsFile(skillFile)) continue;
12467
13698
  const { frontmatter } = safeReadFrontmatter(skillFile);
12468
13699
  const description = asString(frontmatter.description);
@@ -12477,14 +13708,14 @@ var SkillRegistry = class {
12477
13708
  };
12478
13709
  function existsAsFile(path9) {
12479
13710
  try {
12480
- return statSync4(path9).isFile();
13711
+ return statSync6(path9).isFile();
12481
13712
  } catch {
12482
13713
  return false;
12483
13714
  }
12484
13715
  }
12485
13716
  function safeReadFrontmatter(path9) {
12486
13717
  try {
12487
- return parseFrontmatter(readFileSync12(path9, "utf-8"));
13718
+ return parseFrontmatter(readFileSync13(path9, "utf-8"));
12488
13719
  } catch {
12489
13720
  return { frontmatter: {}, body: "" };
12490
13721
  }
@@ -12493,11 +13724,11 @@ function safeReadFrontmatter(path9) {
12493
13724
  // src/services/skills/skill-dir.ts
12494
13725
  import { existsSync as existsSync11 } from "fs";
12495
13726
  import { homedir as homedir4 } from "os";
12496
- import { join as join19, resolve as resolve11 } from "path";
13727
+ import { join as join20, resolve as resolve11 } from "path";
12497
13728
  function resolveSkillDirs(workspaceDir2, configDirs) {
12498
13729
  const candidates = [
12499
- join19(homedir4(), ".wayang", "skills"),
12500
- join19(workspaceDir2, ".wayang", "skills"),
13730
+ join20(homedir4(), ".wayang", "skills"),
13731
+ join20(workspaceDir2, ".wayang", "skills"),
12501
13732
  ...(configDirs ?? []).map((d) => resolve11(d))
12502
13733
  ];
12503
13734
  const seen = /* @__PURE__ */ new Set();