@lmzhen/dsh-evolution-core 0.3.64 → 0.3.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -393,7 +393,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
393
393
  } catch {}
394
394
  continue;
395
395
  }
396
- if (name.includes(".corrupt")) {
396
+ if (/\.corrupt(\.\d+)?$/.test(name)) {
397
397
  const corruptPath = join(dir, name);
398
398
  try {
399
399
  const st = await stat(corruptPath);
@@ -576,29 +576,30 @@ function parseUsage(raw) {
576
576
  async function loadUsage(root, io = nodeEvolutionIo()) {
577
577
  return parseUsage(await io.readText(usageFile(root)));
578
578
  }
579
- /**
580
- * Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
581
- * the map parsed from the current on-disk state and may mutate it; the result
582
- * is persisted inside the same transact so a second process sharing DSH_HOME
583
- * cannot interleave its RMW and lose a counter update. Callers keep their own
584
- * single-process serialize chain as the second layer.
585
- */
586
- async function mutateUsage(root, io, task) {
579
+ async function mutateUsage(root, io, task, options = {}) {
587
580
  await transactIo(io, usageFile(root), async (current) => {
588
581
  let shapePreserved = false;
582
+ let recovered = null;
589
583
  if (current !== null) try {
590
584
  const probe = JSON.parse(current);
591
585
  if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
592
586
  else {
593
587
  const record = probe;
594
- if (Object.values(record).some((value) => value === null || typeof value !== "object" || Array.isArray(value))) shapePreserved = true;
588
+ const isMalformed = (value) => value === null || typeof value !== "object" || Array.isArray(value);
595
589
  if (typeof record.version === "number" && record.version > 1) shapePreserved = true;
590
+ else if (Object.values(record).some(isMalformed)) {
591
+ const bad = Object.keys(record).filter((key) => isMalformed(record[key]));
592
+ const corruptPath = `${usageFile(root)}.corrupt`;
593
+ await io.writeText(corruptPath, current).catch(() => {});
594
+ recovered = JSON.stringify(Object.fromEntries(Object.entries(record).filter(([, value]) => !isMalformed(value))));
595
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the original bytes were copied to ${corruptPath} and the remaining entries continue to be served`);
596
+ }
596
597
  }
597
598
  } catch {
598
599
  return current;
599
600
  }
600
601
  if (shapePreserved) return current;
601
- const map = parseUsage(current);
602
+ const map = parseUsage(recovered ?? current);
602
603
  await task(map);
603
604
  return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
604
605
  });
@@ -1976,9 +1977,16 @@ function clampedNumber(value, fallback, opts) {
1976
1977
  * Threat scanning for agent-authored memory and skill content.
1977
1978
  *
1978
1979
  * Ported as a small, dependency-free subset of Hermes Agent's
1979
- * `tools/threat_patterns.py` + hermes-claw `threats.ts`. The policy is the
1980
- * load-bearing part: ANY in-scope hit blocks. Severity and category are
1981
- * metadata for diagnostics only.
1980
+ * `tools/threat_patterns.py` + hermes-claw `threats.ts`.
1981
+ *
1982
+ * Policy (P1-1, v19): a finding either BLOCKS the write or only REPORTS.
1983
+ * Blocking is reserved for shapes with no legitimate use in stored knowledge
1984
+ * (prompt-injection phrasing, credential exfiltration, the invisible-character
1985
+ * smuggling core). Typography and presentation characters that are legitimate
1986
+ * in ordinary prose — variation selectors (❤️), zero-width non-joiner, soft
1987
+ * hyphen from PDF paste, Arabic letter mark, Mongolian vowel separator — are
1988
+ * REPORT findings: they stay visible to operators and tests but never reject a
1989
+ * write. Blocking them turned every emoji into a security event.
1982
1990
  */
1983
1991
  const FILLER = String.raw`(?:\w+\s+){0,8}`;
1984
1992
  const PATTERNS = [
@@ -2145,7 +2153,9 @@ const PATTERNS = [
2145
2153
  regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
2146
2154
  }
2147
2155
  ];
2148
- const ZERO_WIDTH_CHARS = new RegExp(`[\\u00ad\\u034f\\u061c\\u180e\\u200b\\u200c\\u200d\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\ufe00-\\ufe0f]|\\u{e0000}-\\u{e007f}`, "u");
2156
+ const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
2157
+ const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
2158
+ const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
2149
2159
  const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
2150
2160
  const SCOPE_ORDER = {
2151
2161
  all: 1,
@@ -2172,11 +2182,17 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2172
2182
  const windowSize = clampedNumber(maxScanChars, 65536, { min: 4097 });
2173
2183
  const findings = [];
2174
2184
  const excluded = new Set(options.excludeLabels ?? []);
2175
- if (ZERO_WIDTH_CHARS.test(text) && !excluded.has("unicode_zero_width")) findings.push({
2185
+ if ((ZERO_WIDTH_CHARS.test(text) || ZWJ_OUTSIDE_EMOJI.test(text)) && !excluded.has("unicode_zero_width")) findings.push({
2176
2186
  label: "unicode_zero_width",
2177
2187
  category: "unicode_obfuscation",
2178
2188
  scope: "all"
2179
2189
  });
2190
+ if (TYPOGRAPHY_CHARS.test(text) && !excluded.has("unicode_typography")) findings.push({
2191
+ label: "unicode_typography",
2192
+ category: "unicode_obfuscation",
2193
+ scope: "all",
2194
+ severity: "report"
2195
+ });
2180
2196
  if (BIDI_CHARS.test(text) && !excluded.has("unicode_bidi_override")) findings.push({
2181
2197
  label: "unicode_bidi_override",
2182
2198
  category: "unicode_obfuscation",
@@ -2205,11 +2221,13 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2205
2221
  }
2206
2222
  return findings;
2207
2223
  }
2208
- /** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
2224
+ /** Blocking policy (P1-1, v19): a finding blocks unless it is explicitly
2225
+ * `report`-only. Pattern findings carry no severity and therefore block as
2226
+ * before. */
2209
2227
  function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
2210
2228
  const findings = scanThreats(text, scope, maxScanChars, options);
2211
2229
  return {
2212
- blocked: findings.length > 0,
2230
+ blocked: findings.some((finding) => finding.severity !== "report"),
2213
2231
  findings
2214
2232
  };
2215
2233
  }
@@ -3140,7 +3158,7 @@ const SECRET_PATTERNS = [
3140
3158
  ["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
3141
3159
  ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3142
3160
  ];
3143
- const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\s]*[:=][\\s]*)(?:\"([^\"\\r\\n]*)\"|'([^'\\r\\n]*)'|([^\\r\\n]+))", "gi");
3161
+ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
3144
3162
  /**
3145
3163
  * Mask credential-shaped text before it crosses a session boundary.
3146
3164
  * @param text - the text about to be sent to a model outside this session.
@@ -3836,6 +3854,13 @@ function authoringFeedback(frontmatter) {
3836
3854
  lines
3837
3855
  };
3838
3856
  }
3857
+ /** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
3858
+ * the rename landed and only the directory fsync failed. Every single-file
3859
+ * writer must treat that as "written, durability unconfirmed" — never as a
3860
+ * plain failure (which a caller would retry, or a two-phase caller roll back). */
3861
+ function isCommittedOnly(error) {
3862
+ return error?.committed === true;
3863
+ }
3839
3864
  async function listNames(root, io) {
3840
3865
  const entries = await io.list(root);
3841
3866
  const names = [];
@@ -4392,6 +4417,7 @@ var SkillLibrary = class {
4392
4417
  ok: false,
4393
4418
  message: "Only the foreground (user or the main agent) may pin or unpin skills."
4394
4419
  };
4420
+ let durabilityWarning = "";
4395
4421
  const dir = this.dirOf(normalized);
4396
4422
  const marker = markerPath(dir, "pinned");
4397
4423
  const existing = await this.io.exists(marker);
@@ -4409,12 +4435,17 @@ var SkillLibrary = class {
4409
4435
  ok: false,
4410
4436
  message: `Skill "${normalized}" not found.`
4411
4437
  };
4412
- if (pinned) await this.io.writeText(marker, "");
4438
+ if (pinned) try {
4439
+ await this.io.writeText(marker, "");
4440
+ } catch (error) {
4441
+ if (!isCommittedOnly(error)) throw error;
4442
+ durabilityWarning = error instanceof Error ? error.message : String(error);
4443
+ }
4413
4444
  else await this.io.remove(marker);
4414
4445
  await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
4415
4446
  return {
4416
4447
  ok: true,
4417
- message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`,
4448
+ message: `${pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`}${durabilityWarning === "" ? "" : ` (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`}`,
4418
4449
  path: dir
4419
4450
  };
4420
4451
  }
@@ -4465,20 +4496,31 @@ var SkillLibrary = class {
4465
4496
  const createPath = join(dir, "SKILL.md");
4466
4497
  let existsAtCommit = false;
4467
4498
  let taskRan = false;
4468
- if (this.transact) await this.transact(this.io, createPath, (current) => {
4469
- taskRan = true;
4470
- if (current !== null) {
4471
- existsAtCommit = true;
4472
- return current;
4473
- }
4474
- return onDisk;
4475
- });
4499
+ let createDurabilityWarning = "";
4500
+ if (this.transact) try {
4501
+ await this.transact(this.io, createPath, (current) => {
4502
+ taskRan = true;
4503
+ if (current !== null) {
4504
+ existsAtCommit = true;
4505
+ return current;
4506
+ }
4507
+ return onDisk;
4508
+ });
4509
+ } catch (error) {
4510
+ if (!isCommittedOnly(error)) throw error;
4511
+ createDurabilityWarning = error instanceof Error ? error.message : String(error);
4512
+ }
4476
4513
  else if (await this.io.exists(createPath)) {
4477
4514
  taskRan = true;
4478
4515
  existsAtCommit = true;
4479
4516
  } else {
4480
4517
  taskRan = true;
4481
- await this.io.writeText(createPath, onDisk);
4518
+ try {
4519
+ await this.io.writeText(createPath, onDisk);
4520
+ } catch (error) {
4521
+ if (!isCommittedOnly(error)) throw error;
4522
+ createDurabilityWarning = error instanceof Error ? error.message : String(error);
4523
+ }
4482
4524
  }
4483
4525
  if (!taskRan) return {
4484
4526
  ok: false,
@@ -4497,7 +4539,7 @@ var SkillLibrary = class {
4497
4539
  });
4498
4540
  return {
4499
4541
  ok: true,
4500
- message: `Skill "${normalized}" created.`,
4542
+ message: `Skill "${normalized}" created.${createDurabilityWarning === "" ? "" : ` (warning: the write landed but the directory fsync failed — durability unconfirmed: ${createDurabilityWarning})`}`,
4501
4543
  path: dir,
4502
4544
  ...norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}
4503
4545
  };
@@ -4848,15 +4890,23 @@ var SkillLibrary = class {
4848
4890
  }
4849
4891
  /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
4850
4892
  * only a single, non-traversing path component is safe. Dotfiles
4851
- * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed. */
4893
+ * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
4894
+ * P2-4 (v19): a non-string entry (`skills: [123]`) is refused structurally
4895
+ * instead of throwing `name.includes is not a function`. */
4852
4896
  safeSnapshotEntryName(name) {
4897
+ if (typeof name !== "string") return false;
4853
4898
  return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && basename(name) === name;
4854
4899
  }
4855
4900
  /** A1-7 (v18): a root-level lock whose holder is alive must refuse the
4856
4901
  * restore; a dead residue is swept so a crashed writer cannot block
4857
4902
  * recovery. A non-lock body shape is left alone (user file). */
4858
4903
  async refuseLiveLockOrSweep(lockPath, label) {
4859
- const body = await this.io.readText(lockPath).catch(() => null);
4904
+ let body;
4905
+ try {
4906
+ body = await this.io.readText(lockPath);
4907
+ } catch (error) {
4908
+ throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
4909
+ }
4860
4910
  if (body === null) return;
4861
4911
  const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
4862
4912
  if (match === null) return;
@@ -5660,13 +5710,19 @@ var SkillLibrary = class {
5660
5710
  */
5661
5711
  async restoreSnapshotIntoRoot(snapshotPath) {
5662
5712
  const manifest = await this.readSnapshotManifest(snapshotPath);
5663
- if (manifest === null) {
5664
- if (await this.io.exists(join(snapshotPath, "manifest.json"))) throw new Error(`snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree`);
5665
- } else {
5666
- for (const name of [...manifest.skills, ...manifest.sidecars]) if (!this.safeSnapshotEntryName(name)) throw new Error(`snapshot ${snapshotPath} declares an unsafe entry name ${JSON.stringify(name)}; refusing to restore`);
5667
- if (manifest.skills.length === 0) {
5668
- if ((await this.io.list(snapshotPath)).some((entry) => entry !== "manifest.json" && entry !== "extras" && entry !== ".archive")) throw new Error(`snapshot ${snapshotPath} declares no skills but contains entries; refusing to clear the active tree`);
5669
- }
5713
+ if (manifest === null) throw new Error(await this.io.exists(join(snapshotPath, "manifest.json")) ? `snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree` : `snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
5714
+ for (const name of [...manifest.skills, ...manifest.sidecars]) if (!this.safeSnapshotEntryName(name)) throw new Error(`snapshot ${snapshotPath} declares an unsafe entry name ${JSON.stringify(name)}; refusing to restore`);
5715
+ if (manifest.skills.length === 0) {
5716
+ const snapshotEntries = await this.io.list(snapshotPath);
5717
+ const declared = new Set([
5718
+ "manifest.json",
5719
+ "extras",
5720
+ ".archive",
5721
+ ...manifest.skills,
5722
+ ...manifest.sidecars
5723
+ ]);
5724
+ const undeclared = snapshotEntries.filter((entry) => !declared.has(entry));
5725
+ if (undeclared.length > 0) throw new Error(`snapshot ${snapshotPath} declares no skills but contains undeclared entries (${undeclared.join(", ")}); refusing to clear the active tree`);
5670
5726
  }
5671
5727
  let rootEntries;
5672
5728
  try {
@@ -5686,17 +5742,16 @@ var SkillLibrary = class {
5686
5742
  if (await this.hasWriteLock(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
5687
5743
  }
5688
5744
  }
5689
- const restoresSuppressed = manifest !== null && manifest.sidecars.includes(".curator-suppressed.json");
5745
+ const restoresSuppressed = manifest.sidecars.includes(".curator-suppressed.json");
5690
5746
  for (const entry of rootEntries) {
5691
5747
  if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
5692
5748
  if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
5693
5749
  if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
5694
5750
  await this.io.remove(join(this.root, entry));
5695
5751
  }
5696
- if (manifest === null) throw new Error(`snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
5697
- else {
5698
- for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
5699
- for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
5752
+ for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
5753
+ for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
5754
+ {
5700
5755
  const archiveRoot = join(this.root, ".archive");
5701
5756
  if (manifest.hasArchive === true) {
5702
5757
  await this.io.remove(archiveRoot);
@@ -412,7 +412,9 @@ export declare class SkillLibrary {
412
412
  private sweepLockIfStranded;
413
413
  /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
414
414
  * only a single, non-traversing path component is safe. Dotfiles
415
- * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed. */
415
+ * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
416
+ * P2-4 (v19): a non-string entry (`skills: [123]`) is refused structurally
417
+ * instead of throwing `name.includes is not a function`. */
416
418
  private safeSnapshotEntryName;
417
419
  /** A1-7 (v18): a root-level lock whose holder is alive must refuse the
418
420
  * restore; a dead residue is swept so a crashed writer cannot block
@@ -2,15 +2,25 @@
2
2
  * Threat scanning for agent-authored memory and skill content.
3
3
  *
4
4
  * Ported as a small, dependency-free subset of Hermes Agent's
5
- * `tools/threat_patterns.py` + hermes-claw `threats.ts`. The policy is the
6
- * load-bearing part: ANY in-scope hit blocks. Severity and category are
7
- * metadata for diagnostics only.
5
+ * `tools/threat_patterns.py` + hermes-claw `threats.ts`.
6
+ *
7
+ * Policy (P1-1, v19): a finding either BLOCKS the write or only REPORTS.
8
+ * Blocking is reserved for shapes with no legitimate use in stored knowledge
9
+ * (prompt-injection phrasing, credential exfiltration, the invisible-character
10
+ * smuggling core). Typography and presentation characters that are legitimate
11
+ * in ordinary prose — variation selectors (❤️), zero-width non-joiner, soft
12
+ * hyphen from PDF paste, Arabic letter mark, Mongolian vowel separator — are
13
+ * REPORT findings: they stay visible to operators and tests but never reject a
14
+ * write. Blocking them turned every emoji into a security event.
8
15
  */
9
16
  export type ThreatScope = 'all' | 'context' | 'strict';
10
17
  export interface ThreatFinding {
11
18
  label: string;
12
19
  category: string;
13
20
  scope: ThreatScope;
21
+ /** P1-1 (v19): `block` (default) refuses the write; `report` is an audit
22
+ * trail entry only. Absent means `block`. */
23
+ severity?: 'block' | 'report';
14
24
  }
15
25
  /**
16
26
  * Optional scan controls. Default behavior (`options` omitted) is unchanged:
@@ -39,7 +49,9 @@ export declare const PATTERN_OVERLAP = 4096;
39
49
  * characters (skill files may run to 100,000) is no longer a blind zone.
40
50
  */
41
51
  export declare function scanThreats(text: string, scope?: ThreatScope, maxScanChars?: number, options?: ScanOptions): ThreatFinding[];
42
- /** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
52
+ /** Blocking policy (P1-1, v19): a finding blocks unless it is explicitly
53
+ * `report`-only. Pattern findings carry no severity and therefore block as
54
+ * before. */
43
55
  export declare function evaluateThreat(text: string, scope?: ThreatScope, maxScanChars?: number, options?: ScanOptions): {
44
56
  blocked: boolean;
45
57
  findings: ThreatFinding[];
@@ -52,7 +52,13 @@ export declare function loadUsage(root: string, io?: EvolutionIoLike): Promise<U
52
52
  * cannot interleave its RMW and lose a counter update. Callers keep their own
53
53
  * single-process serialize chain as the second layer.
54
54
  */
55
- export declare function mutateUsage(root: string, io: EvolutionIoLike, task: (map: UsageMap) => void | Promise<void>): Promise<void>;
55
+ export interface UsageMutateOptions {
56
+ /** P2-9 (v19): called when malformed entries had to be quarantined before the
57
+ * task could run. The guard preserves bytes AND keeps the facility working;
58
+ * this callback is how that stays observable. */
59
+ onQuarantine?: ((message: string) => void) | undefined;
60
+ }
61
+ export declare function mutateUsage(root: string, io: EvolutionIoLike, task: (map: UsageMap) => void | Promise<void>, options?: UsageMutateOptions): Promise<void>;
56
62
  /**
57
63
  * Curator-owned usage fields (rc.67 K-2): the curator writes ONLY this set —
58
64
  * lifecycle state, archive stamp, the six-factor quality pair, and the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-core",
3
3
  "description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
4
- "version": "0.3.64",
4
+ "version": "0.3.65",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },