blun-king-cli 9.1.563 → 9.1.564

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.
@@ -5,7 +5,7 @@ import { recordAttentionEvent } from "./lib/attention.js";
5
5
  import { catalogForStateRoot, saveCatalog } from "./lib/catalog.js";
6
6
  import { loadGraph } from "./lib/graph.js";
7
7
  import { canonicalPath } from "./lib/paths.js";
8
- import { resolveHostSourceCatalog } from "./lib/source-roots.js";
8
+ import { isInaccessibleScanError, resolveHostSourceCatalog } from "./lib/source-roots.js";
9
9
  import { sessionBriefing } from "./lib/briefing.js";
10
10
  import { captureContinuityPrompt, loadContinuity } from "./lib/continuity.js";
11
11
  import { recordLearningApplications, recordLearningDeliveries } from "./lib/learning.js";
@@ -16,6 +16,7 @@ import { claimChannelEvent } from "./lib/channel-runtime.js";
16
16
  import { syncPersonaRosterFromEnvironment } from "./lib/persona-runtime.js";
17
17
  import { captureMustRememberPrompt, recordPreflightFailure, runPreflight, verifyPreflightReceipt } from "./lib/preflight.js";
18
18
  import { isMainModule } from "./lib/runtime.js";
19
+ import { recordHookScanAudit } from "./lib/hook-audit.js";
19
20
 
20
21
  const MAX_STDIN_BYTES = 64 * 1024;
21
22
  const STANDARD_HOST_CONTEXT_BYTES = 9500;
@@ -471,6 +472,30 @@ function isMutationTool(name = "") {
471
472
  return /(^|__)(apply_patch|edit|write|delete|move|rename|bash|exec_command|shell)(_|$)/i.test(name);
472
473
  }
473
474
 
475
+ function filesystemScanError(error) {
476
+ return Boolean(error && (isInaccessibleScanError(error)
477
+ || error.code === "AGENTSPINE_SCAN_INCOMPLETE" || error.agentSpineScan === true));
478
+ }
479
+
480
+ async function auditSkippedScans(input, phase, skipped = []) {
481
+ for (const item of skipped) {
482
+ await recordHookScanAudit({
483
+ event: "PreToolUse", toolName: input.tool_name || null, phase,
484
+ error: { code: item.code, message: `${item.code}: ${item.kind || item.operation || "scan"} skipped ${item.relativePath || item.path}` },
485
+ path: item.path || item.relativePath, operation: item.operation || item.kind,
486
+ now: input.timestamp || new Date()
487
+ });
488
+ }
489
+ }
490
+
491
+ async function allowScanFailure(input, phase, error) {
492
+ await recordHookScanAudit({
493
+ event: "PreToolUse", toolName: input.tool_name || null, phase, error,
494
+ path: error?.path || input.cwd || process.cwd(), now: input.timestamp || new Date()
495
+ });
496
+ return { blocked: false, degraded: true, scanFailedOpen: true, phase, error: error.message };
497
+ }
498
+
474
499
  function stringValues(value, output = []) {
475
500
  if (typeof value === "string") output.push(value);
476
501
  else if (Array.isArray(value)) value.forEach((item) => stringValues(item, output));
@@ -546,6 +571,19 @@ export async function runHook(payload = null, options = {}) {
546
571
  resolvedSources = await resolveHostSourceCatalog({ host: instructionHost, cwd, input });
547
572
  } catch (error) {
548
573
  const reason = `AgentSpine source resolution failed closed: ${error.message}`;
574
+ if (event === "PreToolUse" && isMutationTool(input.tool_name) && filesystemScanError(error)) {
575
+ const allowed = await allowScanFailure(input, "source-resolution", error);
576
+ const context = JSON.stringify({
577
+ schema: "agentspine.hook-context/v1", event, loaded: false, failedClosed: false,
578
+ indexedSources: 0,
579
+ sourceResolution: { status: "degraded", reason: error.message, auditFinding: "inaccessible-source-scan" },
580
+ instruction: "The filesystem scan was incomplete. The requested tool remains governed by native host permissions.",
581
+ authority: "context-only"
582
+ });
583
+ if (payload) return { ...allowed, context };
584
+ process.stdout.write("{}\n");
585
+ return;
586
+ }
549
587
  if (event === "PreToolUse" && isMutationTool(input.tool_name)) {
550
588
  if (payload) return { blocked: true, failedClosed: true, reason };
551
589
  deny(reason);
@@ -575,6 +613,11 @@ export async function runHook(payload = null, options = {}) {
575
613
  let channelEvent = null;
576
614
  let learningDelivery = null;
577
615
 
616
+ if (event === "PreToolUse" && isMutationTool(input.tool_name)
617
+ && resolvedSources.diagnostics.skippedInaccessibleDirectories?.length) {
618
+ await auditSkippedScans(input, "source-resolution", resolvedSources.diagnostics.skippedInaccessibleDirectories);
619
+ }
620
+
578
621
  if (event === "PreToolUse" && isMutationTool(input.tool_name)) {
579
622
  const { graph } = await loadGraph(root, catalog);
580
623
  const inferredProtected = new Set(graph.annotations
@@ -624,6 +667,12 @@ export async function runHook(payload = null, options = {}) {
624
667
  });
625
668
  }
626
669
  } catch (error) {
670
+ if (isMutationTool(input.tool_name) && filesystemScanError(error)) {
671
+ const allowed = await allowScanFailure(input, "self-starter", error);
672
+ if (payload) return allowed;
673
+ process.stdout.write("{}\n");
674
+ return;
675
+ }
627
676
  const reason = `AgentSpine self-starter denied this effect: ${error.message}`;
628
677
  if (payload) return { blocked: true, reason, selfstarter: { allowed: false, reason: error.message } };
629
678
  deny(reason);
@@ -288,7 +288,7 @@ export async function runAudit(root = process.cwd(), { host = null } = {}) {
288
288
  gate(2, "Discovery", catalog.schema === "agentspine.catalog/v1"
289
289
  && (!host || (sourceResolution?.status === "loaded" && !sourceResolutionError)), sourceResolutionError
290
290
  ? `${catalog.documents.length} project documents; host-native source resolution failed closed: ${sourceResolutionError}`
291
- : sourceResolution ? `${sourceResolution.scopes.user} user, ${sourceResolution.scopes.project} project, and ${sourceResolution.scopes["project-memory"]} memory sources; broad home scan disabled`
291
+ : sourceResolution ? `${sourceResolution.scopes.user} user, ${sourceResolution.scopes.project} project, and ${sourceResolution.scopes["project-memory"]} memory sources; broad home scan disabled; ${(sourceResolution.skippedInaccessibleDirectories || []).length} inaccessible directories skipped`
292
292
  : `${catalog.documents.length} Markdown documents indexed`),
293
293
  gate(3, "State isolation", [catalogPath, graphPath, attentionPath, learningPath, continuityPath,
294
294
  policyPath, coordinationPath, executionPolicyPath, selfstarterPath, channelPolicyPath,
@@ -0,0 +1,29 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { stateRoot } from "./paths.js";
4
+
5
+ export function hookScanAuditPath(env = process.env) {
6
+ return join(stateRoot(env), "hook-scan-audit.jsonl");
7
+ }
8
+
9
+ export async function recordHookScanAudit({ event = "PreToolUse", toolName = null, phase, error = null,
10
+ path = null, operation = null, now = new Date(), env = process.env }) {
11
+ try {
12
+ const target = hookScanAuditPath(env);
13
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
14
+ const record = {
15
+ schema: "agentspine.hook-scan-audit/v1",
16
+ at: new Date(now).toISOString(), event, toolName, phase,
17
+ code: String(error?.code || "SCAN_ERROR").slice(0, 64),
18
+ error: String(error?.message || error || "filesystem scan skipped").slice(0, 2048),
19
+ path: String(path || error?.path || "unknown").slice(0, 4096),
20
+ operation: operation || error?.syscall || null,
21
+ decision: "allow",
22
+ authority: "diagnostic-only"
23
+ };
24
+ await appendFile(target, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
@@ -6,6 +6,7 @@ import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.j
6
6
  import { loadCoordination } from "./coordination.js";
7
7
  import { loadGraph } from "./graph.js";
8
8
  import { projectStateDir } from "./paths.js";
9
+ import { isInaccessibleScanError } from "./source-roots.js";
9
10
 
10
11
  const POLICY_SCHEMA = "agentspine.execution-policy/v1";
11
12
  const JOB_SCHEMA = "agentspine.selfstarter/v1";
@@ -149,16 +150,39 @@ async function pathsFor(root, providedCatalog = null) {
149
150
 
150
151
  async function collectWorkspaceFiles(root) {
151
152
  const files = [];
153
+ const skippedInaccessibleEntries = [];
152
154
  let totalBytes = 0;
153
155
  async function walk(directory) {
154
- const stream = await opendir(directory);
156
+ let stream;
157
+ try {
158
+ stream = await opendir(directory);
159
+ } catch (error) {
160
+ if (!isInaccessibleScanError(error)) throw error;
161
+ skippedInaccessibleEntries.push({
162
+ relativePath: relative(root, directory).split(sep).join("/") || ".",
163
+ code: error.code,
164
+ kind: "directory"
165
+ });
166
+ return;
167
+ }
155
168
  const entries = [];
156
169
  for await (const entry of stream) entries.push(entry);
157
170
  entries.sort((a, b) => a.name.localeCompare(b.name));
158
171
  for (const entry of entries) {
159
172
  if (EXCLUDED_NAMES.has(entry.name)) continue;
160
173
  const path = join(directory, entry.name);
161
- const metadata = await lstat(path);
174
+ let metadata;
175
+ try {
176
+ metadata = await lstat(path);
177
+ } catch (error) {
178
+ if (!isInaccessibleScanError(error)) throw error;
179
+ skippedInaccessibleEntries.push({
180
+ relativePath: relative(root, path).split(sep).join("/"),
181
+ code: error.code,
182
+ kind: "entry"
183
+ });
184
+ continue;
185
+ }
162
186
  if (metadata.isSymbolicLink()) throw new Error(`workspace fingerprint rejects symbolic link: ${relative(root, path)}`);
163
187
  if (metadata.isDirectory()) await walk(path);
164
188
  else if (metadata.isFile()) {
@@ -171,18 +195,38 @@ async function collectWorkspaceFiles(root) {
171
195
  }
172
196
  }
173
197
  await walk(root);
174
- return files;
198
+ return { files, skippedInaccessibleEntries };
175
199
  }
176
200
 
177
201
  export async function workspaceFingerprint(inputRoot = process.cwd()) {
178
202
  const root = resolve(inputRoot);
179
- const files = await collectWorkspaceFiles(root);
203
+ const { files, skippedInaccessibleEntries } = await collectWorkspaceFiles(root);
180
204
  const hash = createHash("sha256");
205
+ for (const skipped of skippedInaccessibleEntries) {
206
+ hash.update("inaccessible\0").update(skipped.kind).update("\0")
207
+ .update(skipped.relativePath).update("\0").update(skipped.code).update("\0");
208
+ }
181
209
  for (const file of files) {
182
210
  hash.update(file.relativePath).update("\0").update(String(file.size)).update("\0");
183
211
  hash.update(await readFile(file.path)).update("\0");
184
212
  }
185
- return { digest: hash.digest("hex"), files: files.length, bytes: files.reduce((sum, file) => sum + file.size, 0) };
213
+ return {
214
+ digest: hash.digest("hex"), files: files.length,
215
+ bytes: files.reduce((sum, file) => sum + file.size, 0),
216
+ skippedInaccessibleEntries: skippedInaccessibleEntries.length,
217
+ skippedInaccessibleDetails: skippedInaccessibleEntries
218
+ };
219
+ }
220
+
221
+ function incompleteWorkspaceScan(skipped) {
222
+ const first = skipped[0];
223
+ const error = new Error(`workspace scan skipped ${skipped.length} inaccessible path${skipped.length === 1 ? "" : "s"}: ${first.relativePath}`);
224
+ error.code = "AGENTSPINE_SCAN_INCOMPLETE";
225
+ error.path = first.relativePath;
226
+ error.syscall = first.kind;
227
+ error.agentSpineScan = true;
228
+ error.skipped = skipped;
229
+ return error;
186
230
  }
187
231
 
188
232
  function knownActor(graph, id) {
@@ -649,6 +693,9 @@ export async function authorizeJobEffect({
649
693
  const deliveryId = stableId(toolUseId, "toolUseId");
650
694
  const at = timestamp(now, "now");
651
695
  const fingerprint = await workspaceFingerprint(root);
696
+ if (fingerprint.skippedInaccessibleDetails.length) {
697
+ throw incompleteWorkspaceScan(fingerprint.skippedInaccessibleDetails);
698
+ }
652
699
  return lockedStates(root, ({ policy, state, coordination, paths }) => {
653
700
  const job = state.jobs.find((item) => item.id === scope.jobId);
654
701
  if (!job) throw new Error("unknown self-starter job");
@@ -20,6 +20,10 @@ const SOURCE_RESOLUTION_MS = 2000;
20
20
  const SAFE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
21
21
  const SKIP_EXTRA_DIRS = new Set([".git", ".hg", ".svn", ".claude", ".codex", "node_modules", "vendor", "dist", "build", "coverage"]);
22
22
 
23
+ export function isInaccessibleScanError(error) {
24
+ return error?.code === "EACCES" || error?.code === "EPERM";
25
+ }
26
+
23
27
  function digest(value) { return createHash("sha256").update(value).digest("hex"); }
24
28
  function registryPath(env = process.env) { return join(stateRoot(env), "source-roots.json"); }
25
29
  function emptyRegistry() { return { schema: SOURCE_REGISTRY_SCHEMA, revision: 0, bindings: [], history: [] }; }
@@ -202,32 +206,41 @@ async function containsEmbeddedHostProfile(directory) {
202
206
  async function boundedMarkdownTree(directory, prefix, host, scope, precedenceStart, deadline, {
203
207
  projectBoundary = false,
204
208
  maxFiles = MAX_RULE_FILES,
205
- maxDirectoryEntries = MAX_DIRECTORY_ENTRIES
209
+ maxDirectoryEntries = MAX_DIRECTORY_ENTRIES,
210
+ skippedInaccessibleDirectories = []
206
211
  } = {}) {
207
212
  const root = await existingDirectory(directory);
208
213
  if (!root) return [];
209
214
  const output = [];
210
215
  let visitedEntries = 0;
211
216
  async function walk(current) {
212
- if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
213
- if (projectBoundary && current !== root
214
- && (await containsProjectMarker(current) || await containsEmbeddedHostProfile(current))) return;
215
- const entries = [];
216
- for await (const entry of await opendir(current)) {
217
- visitedEntries += 1;
218
- if (visitedEntries > maxDirectoryEntries) throw new Error(`host-native source tree exceeds ${maxDirectoryEntries} entries`);
219
- entries.push(entry);
220
- }
221
- entries.sort((a, b) => a.name.localeCompare(b.name));
222
- for (const entry of entries) {
223
- if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
224
- if (entry.isSymbolicLink()) continue;
225
- const path = join(current, entry.name);
226
- if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
227
- else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
228
- output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
229
- binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
217
+ try {
218
+ if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
219
+ if (projectBoundary && current !== root
220
+ && (await containsProjectMarker(current) || await containsEmbeddedHostProfile(current))) return;
221
+ const entries = [];
222
+ for await (const entry of await opendir(current)) {
223
+ visitedEntries += 1;
224
+ if (visitedEntries > maxDirectoryEntries) throw new Error(`host-native source tree exceeds ${maxDirectoryEntries} entries`);
225
+ entries.push(entry);
230
226
  }
227
+ entries.sort((a, b) => a.name.localeCompare(b.name));
228
+ for (const entry of entries) {
229
+ if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
230
+ if (entry.isSymbolicLink()) continue;
231
+ const path = join(current, entry.name);
232
+ if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
233
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
234
+ output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
235
+ binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
236
+ }
237
+ }
238
+ } catch (error) {
239
+ if (!isInaccessibleScanError(error)) throw error;
240
+ skippedInaccessibleDirectories.push({
241
+ relativePath: relative(root, current).replaceAll("\\", "/") || ".",
242
+ code: error.code
243
+ });
231
244
  }
232
245
  }
233
246
  await walk(root);
@@ -434,9 +447,11 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
434
447
  const knownHomeRoots = await homeRoots(env);
435
448
  const skippedHomeTree = knownHomeRoots.some((root) => samePath(root, projectRoot));
436
449
  const skippedFallbackHomeTree = skippedHomeTree && rootResolution === "cwd-fallback";
450
+ const skippedInaccessibleDirectories = [];
437
451
  if (!skippedHomeTree) {
438
452
  sources.push(...await boundedMarkdownTree(projectRoot, "agentspine:project", host, "project", 3000, deadline,
439
- { projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES }));
453
+ { projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES,
454
+ skippedInaccessibleDirectories }));
440
455
  }
441
456
  const nativeNames = new Set(host === "codex"
442
457
  ? ["AGENTS.override.md", "AGENTS.md", ...(hostDetails.fallbackNames || [])]
@@ -459,6 +474,7 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
459
474
  personalContinuityLoaded: documents.some((item) => item.sourceScope === "user") || Boolean(activeUserState),
460
475
  broadHomeScan: false, projectTreeScan: skippedFallbackHomeTree ? "skipped-unmarked-home"
461
476
  : skippedHomeTree ? "skipped-home-root" : "bounded",
477
+ skippedInaccessibleDirectories,
462
478
  rootResolution, registryRevision: registry.revision,
463
479
  ...(host === "claude" ? {
464
480
  memoryBound: Boolean(hostDetails.memoryRoot),
package/blun.mjs CHANGED
@@ -342898,6 +342898,7 @@ function detectedUiLocale() {
342898
342898
  const SOURCE_LOCALE = "en";
342899
342899
  const PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9_]*)\}/g;
342900
342900
  const catalogs = {};
342901
+ const missingLocalizedUiTextWarnings = /* @__PURE__ */ new Set();
342901
342902
  let currentUiLocale = SOURCE_LOCALE;
342902
342903
  function registerUiCatalogFragment(fragment) {
342903
342904
  for (const [locale, copy] of Object.entries(fragment)) {
@@ -342928,7 +342929,13 @@ function uiTextFor(locale, key, params = {}) {
342928
342929
  const sourceTemplate = catalogs[SOURCE_LOCALE]?.[key];
342929
342930
  if (sourceTemplate === void 0) throw new Error(`Missing English UI source text for key "${key}".`);
342930
342931
  const localizedTemplate = catalogs[locale]?.[key];
342931
- if (locale !== SOURCE_LOCALE && isUiLocaleAvailable(locale) && localizedTemplate === void 0) throw new Error(`Missing UI text for key "${key}" in released locale "${locale}".`);
342932
+ if (locale !== SOURCE_LOCALE && isUiLocaleAvailable(locale) && localizedTemplate === void 0) {
342933
+ const warningKey = `${locale}:${key}`;
342934
+ if (!missingLocalizedUiTextWarnings.has(warningKey)) {
342935
+ missingLocalizedUiTextWarnings.add(warningKey);
342936
+ log?.warn("Missing localized UI text; using English fallback", { locale, key });
342937
+ }
342938
+ }
342932
342939
  const template = localizedTemplate ?? sourceTemplate;
342933
342940
  validateCatalogPlaceholders(key, sourceTemplate, template);
342934
342941
  return interpolate(key, template, params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blun-king-cli",
3
- "version": "9.1.563",
3
+ "version": "9.1.564",
4
4
  "description": "BLUN CLI - your own AI agent with a Telegram channel. Get it done. With BLUN.",
5
5
  "license": "MIT",
6
6
  "bin": {