@lmzhen/dsh-evolution-core 0.3.64 → 0.3.66
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 +304 -91
- package/lib/types/io.d.ts +31 -6
- package/lib/types/skill-store.d.ts +14 -1
- package/lib/types/threats.d.ts +25 -13
- package/lib/types/usage.d.ts +7 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -79,6 +79,18 @@ const RENAME_RETRY_MAX_ATTEMPTS = 6;
|
|
|
79
79
|
* rethrows with a pointer at the usual causes instead of a bare errno.
|
|
80
80
|
* `fn` is the rename primitive, injectable for deterministic tests.
|
|
81
81
|
*
|
|
82
|
+
* v22 (LOCK-5) documented platform limit: POSIX rename-over-existing is
|
|
83
|
+
* atomic for concurrent READERS, but Windows MoveFileExW(REPLACE_EXISTING)
|
|
84
|
+
* can make the target briefly invisible (ENOENT) while the replacement is in
|
|
85
|
+
* flight. The retry budget above covers the WRITE side only — a LOCKLESS
|
|
86
|
+
* reader (`readText`/`readJson` on a path it does not hold the lock for) can
|
|
87
|
+
* observe that window and see "missing" for a file that was just committed.
|
|
88
|
+
* Verified harmless today: every lockless-read consumer treats the transient
|
|
89
|
+
* miss as self-healing state (no consumer persists a read of null into a
|
|
90
|
+
* write), and distinguishing that ENOENT from a genuinely absent file in
|
|
91
|
+
* `readText` would tax every ordinary missing-file probe. Revisit only if a
|
|
92
|
+
* consumer appears that must never transiently miss.
|
|
93
|
+
*
|
|
82
94
|
* @param tmp - the source path to rename.
|
|
83
95
|
* @param target - the destination path.
|
|
84
96
|
* @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
|
|
@@ -189,8 +201,18 @@ function isProcessAlive(pid) {
|
|
|
189
201
|
const LOCK_SUFFIX = ".lock";
|
|
190
202
|
/** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
|
|
191
203
|
* torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
|
|
192
|
-
* the pid part is intact, which is what the takeover probe needs.
|
|
193
|
-
|
|
204
|
+
* the pid part is intact, which is what the takeover probe needs. The capture
|
|
205
|
+
* group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
|
|
206
|
+
* helper instead of re-inlining a third regex copy — F-17 single-source). */
|
|
207
|
+
const LOCK_BODY_RE = /^(\d+):[0-9a-f]*$/;
|
|
208
|
+
/** Parse a writer-lock body into its holder pid. `null` when the body does
|
|
209
|
+
* not have the `pid:token` shape at all (e.g. a user support file named
|
|
210
|
+
* `*.lock`) — callers leave such files alone. A shape-matching body always
|
|
211
|
+
* yields a number (possibly `0`, which `isProcessAlive` treats as dead). */
|
|
212
|
+
function parseLockBody(body) {
|
|
213
|
+
const match = LOCK_BODY_RE.exec(body.trim());
|
|
214
|
+
return match === null ? null : Number(match[1]);
|
|
215
|
+
}
|
|
194
216
|
/**
|
|
195
217
|
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
196
218
|
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
@@ -309,7 +331,18 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
309
331
|
} catch {
|
|
310
332
|
continue;
|
|
311
333
|
}
|
|
312
|
-
const
|
|
334
|
+
const verifyRead = await readFile(lock, "utf8").then((body) => ({
|
|
335
|
+
ok: true,
|
|
336
|
+
body
|
|
337
|
+
}), () => ({
|
|
338
|
+
ok: false,
|
|
339
|
+
body: ""
|
|
340
|
+
}));
|
|
341
|
+
if (!verifyRead.ok) {
|
|
342
|
+
await rm(ticket, { force: true }).catch(() => {});
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const verify = verifyRead.body;
|
|
313
346
|
if (verify === holderContent) {
|
|
314
347
|
const verifyHolder = Number(verify.split(":")[0] ?? "");
|
|
315
348
|
if (!(Number.isInteger(verifyHolder) && verifyHolder > 0 && isAlive(verifyHolder))) await rm(lock, { force: true }).catch(() => {});
|
|
@@ -393,7 +426,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
393
426
|
} catch {}
|
|
394
427
|
continue;
|
|
395
428
|
}
|
|
396
|
-
if (
|
|
429
|
+
if (/\.corrupt(\.\d+)?$/.test(name)) {
|
|
397
430
|
const corruptPath = join(dir, name);
|
|
398
431
|
try {
|
|
399
432
|
const st = await stat(corruptPath);
|
|
@@ -576,29 +609,47 @@ function parseUsage(raw) {
|
|
|
576
609
|
async function loadUsage(root, io = nodeEvolutionIo()) {
|
|
577
610
|
return parseUsage(await io.readText(usageFile(root)));
|
|
578
611
|
}
|
|
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) {
|
|
612
|
+
async function mutateUsage(root, io, task, options = {}) {
|
|
587
613
|
await transactIo(io, usageFile(root), async (current) => {
|
|
588
614
|
let shapePreserved = false;
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
615
|
+
let recovered = null;
|
|
616
|
+
const quarantineCopy = async () => {
|
|
617
|
+
const corruptPath = `${usageFile(root)}.corrupt`;
|
|
618
|
+
return io.writeText(corruptPath, current).then(() => true, () => false);
|
|
619
|
+
};
|
|
620
|
+
if (current !== null) {
|
|
621
|
+
let parsed = null;
|
|
622
|
+
try {
|
|
623
|
+
const probe = JSON.parse(current);
|
|
624
|
+
if (probe !== null && typeof probe === "object" && !Array.isArray(probe)) parsed = probe;
|
|
625
|
+
} catch {
|
|
626
|
+
parsed = null;
|
|
627
|
+
}
|
|
628
|
+
if (parsed === null) {
|
|
629
|
+
if (!await quarantineCopy()) {
|
|
630
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} is unreadable; the .corrupt copy could not be written — refusing this write so the original bytes stay recoverable (telemetry is frozen until the file is recovered manually)`);
|
|
631
|
+
return current;
|
|
632
|
+
}
|
|
633
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} is unreadable (unparsable JSON or wrong top-level shape); the original bytes were copied to ${usageFile(root)}.corrupt and the sidecar restarts empty`);
|
|
634
|
+
} else {
|
|
635
|
+
const record = parsed;
|
|
636
|
+
const isMalformed = (value) => value === null || typeof value !== "object" || Array.isArray(value);
|
|
637
|
+
if (typeof record.version === "number" && record.version > 1) {
|
|
638
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} carries schema version ${String(record.version)} (> this runtime) — writes stay frozen and the bytes preserved until the runtime is upgraded`);
|
|
639
|
+
shapePreserved = true;
|
|
640
|
+
} else if (Object.values(record).some(isMalformed)) {
|
|
641
|
+
const bad = Object.keys(record).filter((key) => isMalformed(record[key]));
|
|
642
|
+
if (!await quarantineCopy()) {
|
|
643
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the .corrupt copy could not be written — refusing this write so the original bytes stay recoverable`);
|
|
644
|
+
return current;
|
|
645
|
+
}
|
|
646
|
+
recovered = JSON.stringify(Object.fromEntries(Object.entries(record).filter(([, value]) => !isMalformed(value))));
|
|
647
|
+
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 ${usageFile(root)}.corrupt and the remaining entries continue to be served`);
|
|
648
|
+
}
|
|
596
649
|
}
|
|
597
|
-
} catch {
|
|
598
|
-
return current;
|
|
599
650
|
}
|
|
600
651
|
if (shapePreserved) return current;
|
|
601
|
-
const map = parseUsage(current);
|
|
652
|
+
const map = parseUsage(recovered ?? current);
|
|
602
653
|
await task(map);
|
|
603
654
|
return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
|
|
604
655
|
});
|
|
@@ -1314,7 +1365,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
|
|
|
1314
1365
|
}
|
|
1315
1366
|
const events = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
|
|
1316
1367
|
let maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
|
|
1317
|
-
|
|
1368
|
+
for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
|
|
1318
1369
|
const record = {
|
|
1319
1370
|
...event,
|
|
1320
1371
|
seq: maxSeq + 1,
|
|
@@ -1976,9 +2027,16 @@ function clampedNumber(value, fallback, opts) {
|
|
|
1976
2027
|
* Threat scanning for agent-authored memory and skill content.
|
|
1977
2028
|
*
|
|
1978
2029
|
* Ported as a small, dependency-free subset of Hermes Agent's
|
|
1979
|
-
* `tools/threat_patterns.py` + hermes-claw `threats.ts`.
|
|
1980
|
-
*
|
|
1981
|
-
*
|
|
2030
|
+
* `tools/threat_patterns.py` + hermes-claw `threats.ts`.
|
|
2031
|
+
*
|
|
2032
|
+
* Policy (P1-1, v19): a finding either BLOCKS the write or only REPORTS.
|
|
2033
|
+
* Blocking is reserved for shapes with no legitimate use in stored knowledge
|
|
2034
|
+
* (prompt-injection phrasing, credential exfiltration, the invisible-character
|
|
2035
|
+
* smuggling core). Typography and presentation characters that are legitimate
|
|
2036
|
+
* in ordinary prose — variation selectors (❤️), zero-width non-joiner, soft
|
|
2037
|
+
* hyphen from PDF paste, Arabic letter mark, Mongolian vowel separator — are
|
|
2038
|
+
* REPORT findings: they stay visible to operators and tests but never reject a
|
|
2039
|
+
* write. Blocking them turned every emoji into a security event.
|
|
1982
2040
|
*/
|
|
1983
2041
|
const FILLER = String.raw`(?:\w+\s+){0,8}`;
|
|
1984
2042
|
const PATTERNS = [
|
|
@@ -2064,19 +2122,19 @@ const PATTERNS = [
|
|
|
2064
2122
|
label: "send_to_url",
|
|
2065
2123
|
category: "exfiltration",
|
|
2066
2124
|
scope: "strict",
|
|
2067
|
-
regex: /(?:send|post|upload|transmit)\s+[
|
|
2125
|
+
regex: /(?:send|post|upload|transmit)\s+[\s\S]{0,512}?\s+(?:to|at)\s+https?:\/\//i
|
|
2068
2126
|
},
|
|
2069
2127
|
{
|
|
2070
2128
|
label: "exfil_curl",
|
|
2071
2129
|
category: "exfiltration",
|
|
2072
2130
|
scope: "all",
|
|
2073
|
-
regex: /\bcurl\s+[
|
|
2131
|
+
regex: /\bcurl\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
|
|
2074
2132
|
},
|
|
2075
2133
|
{
|
|
2076
2134
|
label: "exfil_wget",
|
|
2077
2135
|
category: "exfiltration",
|
|
2078
2136
|
scope: "all",
|
|
2079
|
-
regex: /\bwget\s+[
|
|
2137
|
+
regex: /\bwget\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
|
|
2080
2138
|
},
|
|
2081
2139
|
{
|
|
2082
2140
|
label: "read_secrets",
|
|
@@ -2142,10 +2200,42 @@ const PATTERNS = [
|
|
|
2142
2200
|
label: "private_key_block",
|
|
2143
2201
|
category: "hardcoded_secrets",
|
|
2144
2202
|
scope: "all",
|
|
2145
|
-
regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY
|
|
2203
|
+
regex: /-----BEGIN\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\s+)?PRIVATE\s+KEY(?:\s+BLOCK)?-----/
|
|
2204
|
+
},
|
|
2205
|
+
{
|
|
2206
|
+
label: "prompt_injection_ignore_zh",
|
|
2207
|
+
category: "prompt_injection",
|
|
2208
|
+
scope: "all",
|
|
2209
|
+
regex: /(?:忽略|无视|抛开)[\s\S]{0,8}(?:之前|以上|上面|先前|以前)?[\s\S]{0,8}(?:所有|全部|任何)?[\s\S]{0,4}(?:指令|规则|设定|约束)/
|
|
2210
|
+
},
|
|
2211
|
+
{
|
|
2212
|
+
label: "deception_hide_zh",
|
|
2213
|
+
category: "deception",
|
|
2214
|
+
scope: "all",
|
|
2215
|
+
regex: /(?:不要|别|勿)(?:告诉|告知|透露)(?:给)?[\s\S]{0,6}(?:用户|任何人|主人|开发者)/
|
|
2216
|
+
},
|
|
2217
|
+
{
|
|
2218
|
+
label: "system_prompt_leak_zh",
|
|
2219
|
+
category: "deception",
|
|
2220
|
+
scope: "context",
|
|
2221
|
+
regex: /(?:泄露|输出|打印|透露|导出)[\s\S]{0,10}系统提示|系统提示[\s\S]{0,10}(?:泄露|透露|发送|导出)/
|
|
2222
|
+
},
|
|
2223
|
+
{
|
|
2224
|
+
label: "context_exfil_zh",
|
|
2225
|
+
category: "exfiltration",
|
|
2226
|
+
scope: "strict",
|
|
2227
|
+
regex: /(?:对话记录|聊天记录|全部上下文|完整上下文)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至|给)/
|
|
2228
|
+
},
|
|
2229
|
+
{
|
|
2230
|
+
label: "secret_exfil_zh",
|
|
2231
|
+
category: "exfiltration",
|
|
2232
|
+
scope: "strict",
|
|
2233
|
+
regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/
|
|
2146
2234
|
}
|
|
2147
2235
|
];
|
|
2148
|
-
const ZERO_WIDTH_CHARS = new RegExp(`[\\
|
|
2236
|
+
const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
|
|
2237
|
+
const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
|
|
2238
|
+
const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
|
|
2149
2239
|
const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
|
|
2150
2240
|
const SCOPE_ORDER = {
|
|
2151
2241
|
all: 1,
|
|
@@ -2172,22 +2262,30 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
|
|
|
2172
2262
|
const windowSize = clampedNumber(maxScanChars, 65536, { min: 4097 });
|
|
2173
2263
|
const findings = [];
|
|
2174
2264
|
const excluded = new Set(options.excludeLabels ?? []);
|
|
2175
|
-
if (ZERO_WIDTH_CHARS.test(text) && !excluded.has("unicode_zero_width")) findings.push({
|
|
2265
|
+
if ((ZERO_WIDTH_CHARS.test(text) || ZWJ_OUTSIDE_EMOJI.test(text)) && !excluded.has("unicode_zero_width")) findings.push({
|
|
2176
2266
|
label: "unicode_zero_width",
|
|
2177
2267
|
category: "unicode_obfuscation",
|
|
2178
2268
|
scope: "all"
|
|
2179
2269
|
});
|
|
2270
|
+
if (TYPOGRAPHY_CHARS.test(text) && !excluded.has("unicode_typography")) findings.push({
|
|
2271
|
+
label: "unicode_typography",
|
|
2272
|
+
category: "unicode_obfuscation",
|
|
2273
|
+
scope: "all",
|
|
2274
|
+
severity: "report"
|
|
2275
|
+
});
|
|
2180
2276
|
if (BIDI_CHARS.test(text) && !excluded.has("unicode_bidi_override")) findings.push({
|
|
2181
2277
|
label: "unicode_bidi_override",
|
|
2182
2278
|
category: "unicode_obfuscation",
|
|
2183
2279
|
scope: "all"
|
|
2184
2280
|
});
|
|
2185
2281
|
const normalized = text.normalize("NFKC");
|
|
2282
|
+
const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
|
|
2283
|
+
const patternTexts = [normalized.replace(SPACE_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "")];
|
|
2186
2284
|
const windows = [];
|
|
2187
|
-
if (
|
|
2285
|
+
for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
|
|
2188
2286
|
else {
|
|
2189
2287
|
const step = Math.max(Math.floor(windowSize / 2), 1);
|
|
2190
|
-
for (let start = 0; start <
|
|
2288
|
+
for (let start = 0; start < patternText.length; start += step) windows.push(patternText.slice(start, start + windowSize));
|
|
2191
2289
|
}
|
|
2192
2290
|
const seen = /* @__PURE__ */ new Set();
|
|
2193
2291
|
for (const window of windows) for (const pattern of PATTERNS) {
|
|
@@ -2205,11 +2303,13 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
|
|
|
2205
2303
|
}
|
|
2206
2304
|
return findings;
|
|
2207
2305
|
}
|
|
2208
|
-
/** Blocking policy:
|
|
2306
|
+
/** Blocking policy (P1-1, v19): a finding blocks unless it is explicitly
|
|
2307
|
+
* `report`-only. Pattern findings carry no severity and therefore block as
|
|
2308
|
+
* before. */
|
|
2209
2309
|
function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
2210
2310
|
const findings = scanThreats(text, scope, maxScanChars, options);
|
|
2211
2311
|
return {
|
|
2212
|
-
blocked: findings.
|
|
2312
|
+
blocked: findings.some((finding) => finding.severity !== "report"),
|
|
2213
2313
|
findings
|
|
2214
2314
|
};
|
|
2215
2315
|
}
|
|
@@ -2218,27 +2318,26 @@ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS
|
|
|
2218
2318
|
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
2219
2319
|
if (!blocked) return null;
|
|
2220
2320
|
const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
return `Blocked by security scan: invisible or potentially malicious Unicode detected.${exemptionHint}`;
|
|
2321
|
+
if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.${THREAT_EXEMPTION_HINT}`;
|
|
2322
|
+
return `Blocked by security scan: invisible or potentially malicious Unicode detected.${THREAT_EXEMPTION_HINT}`;
|
|
2224
2323
|
}
|
|
2225
2324
|
/** User-facing block message for skill content writes. */
|
|
2226
2325
|
function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
2227
2326
|
const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
|
|
2228
2327
|
if (!blocked) return null;
|
|
2229
|
-
return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions
|
|
2328
|
+
return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.${THREAT_EXEMPTION_HINT}`;
|
|
2230
2329
|
}
|
|
2231
2330
|
/**
|
|
2232
|
-
*
|
|
2233
|
-
*
|
|
2234
|
-
*
|
|
2235
|
-
*
|
|
2236
|
-
*
|
|
2237
|
-
*
|
|
2238
|
-
*
|
|
2239
|
-
* message verbatim.
|
|
2331
|
+
* WD2 (0.3.56): the shared tail of every user-facing threat block — names the
|
|
2332
|
+
* deployable self-heal path so the model (or operator) can allowlist a
|
|
2333
|
+
* known-benign label. v20 (B-2) single source: the scan builders embed this
|
|
2334
|
+
* constant verbatim; do NOT re-word it per call site (the three former copies
|
|
2335
|
+
* — memory inline, content inline, and the dead `THREAT_EXEMPT_HINT` export,
|
|
2336
|
+
* whose docblock still claimed a store-side append that memory-store/skill-store
|
|
2337
|
+
* had already removed — had drifted apart). The evolution-threat guard channel
|
|
2338
|
+
* returns the scan message verbatim, so the hint rides along there too.
|
|
2240
2339
|
*/
|
|
2241
|
-
const
|
|
2340
|
+
const THREAT_EXEMPTION_HINT = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
|
|
2242
2341
|
//#endregion
|
|
2243
2342
|
//#region lib/types/memory-store.js
|
|
2244
2343
|
/**
|
|
@@ -2937,7 +3036,7 @@ function injectCatalogDescriptionCap(composition) {
|
|
|
2937
3036
|
const next = lines[j] ?? "";
|
|
2938
3037
|
if (next.trim() === "") break;
|
|
2939
3038
|
if (!/^\s/.test(next)) break;
|
|
2940
|
-
if (
|
|
3039
|
+
if (/^ {2}config:(\s|$)/.test(next)) hasConfig = true;
|
|
2941
3040
|
end = j;
|
|
2942
3041
|
}
|
|
2943
3042
|
if (hasConfig) continue;
|
|
@@ -3138,9 +3237,14 @@ const SECRET_PATTERNS = [
|
|
|
3138
3237
|
["gitlab token", /glpat-[A-Za-z0-9_-]{16,}/g],
|
|
3139
3238
|
["slack token", /xox[baprs]-[A-Za-z0-9-]{10,}/g],
|
|
3140
3239
|
["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
3240
|
+
["npm token", /npm_[A-Za-z0-9]{20,}/g],
|
|
3241
|
+
["stripe key", /[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g],
|
|
3242
|
+
["github fine-grained token", /github_pat_[A-Za-z0-9_]{20,}/g],
|
|
3243
|
+
["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
|
|
3141
3244
|
["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
|
|
3142
3245
|
];
|
|
3143
|
-
const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\
|
|
3246
|
+
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");
|
|
3247
|
+
const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
|
|
3144
3248
|
/**
|
|
3145
3249
|
* Mask credential-shaped text before it crosses a session boundary.
|
|
3146
3250
|
* @param text - the text about to be sent to a model outside this session.
|
|
@@ -3149,7 +3253,12 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]
|
|
|
3149
3253
|
function redactSecrets(text) {
|
|
3150
3254
|
let out = text;
|
|
3151
3255
|
for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
|
|
3256
|
+
out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
|
|
3152
3257
|
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
|
|
3258
|
+
out = out.split("\n").map((line) => {
|
|
3259
|
+
if (!/<redacted>/.test(line) && !/\baws\b|\bAKIA\b|\bsecret\b/i.test(line)) return line;
|
|
3260
|
+
return line.replace(/(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, "<redacted>");
|
|
3261
|
+
}).join("\n");
|
|
3153
3262
|
return out;
|
|
3154
3263
|
}
|
|
3155
3264
|
//#endregion
|
|
@@ -3598,6 +3707,8 @@ const MARKER_LOCK_NAMES = [
|
|
|
3598
3707
|
`.pinned${LOCK_SUFFIX}`,
|
|
3599
3708
|
`.hermes-managed${LOCK_SUFFIX}`
|
|
3600
3709
|
];
|
|
3710
|
+
/** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
|
|
3711
|
+
const ARCHIVE_RETENTION_DAYS = 365;
|
|
3601
3712
|
function markerPath(dir, marker) {
|
|
3602
3713
|
return join(dir, markerEntryName(marker));
|
|
3603
3714
|
}
|
|
@@ -3836,6 +3947,13 @@ function authoringFeedback(frontmatter) {
|
|
|
3836
3947
|
lines
|
|
3837
3948
|
};
|
|
3838
3949
|
}
|
|
3950
|
+
/** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
|
|
3951
|
+
* the rename landed and only the directory fsync failed. Every single-file
|
|
3952
|
+
* writer must treat that as "written, durability unconfirmed" — never as a
|
|
3953
|
+
* plain failure (which a caller would retry, or a two-phase caller roll back). */
|
|
3954
|
+
function isCommittedOnly(error) {
|
|
3955
|
+
return error?.committed === true;
|
|
3956
|
+
}
|
|
3839
3957
|
async function listNames(root, io) {
|
|
3840
3958
|
const entries = await io.list(root);
|
|
3841
3959
|
const names = [];
|
|
@@ -4392,6 +4510,7 @@ var SkillLibrary = class {
|
|
|
4392
4510
|
ok: false,
|
|
4393
4511
|
message: "Only the foreground (user or the main agent) may pin or unpin skills."
|
|
4394
4512
|
};
|
|
4513
|
+
let durabilityWarning = "";
|
|
4395
4514
|
const dir = this.dirOf(normalized);
|
|
4396
4515
|
const marker = markerPath(dir, "pinned");
|
|
4397
4516
|
const existing = await this.io.exists(marker);
|
|
@@ -4409,12 +4528,26 @@ var SkillLibrary = class {
|
|
|
4409
4528
|
ok: false,
|
|
4410
4529
|
message: `Skill "${normalized}" not found.`
|
|
4411
4530
|
};
|
|
4412
|
-
if (pinned)
|
|
4413
|
-
|
|
4531
|
+
if (pinned) {
|
|
4532
|
+
try {
|
|
4533
|
+
await this.io.writeText(marker, "");
|
|
4534
|
+
} catch (error) {
|
|
4535
|
+
if (!isCommittedOnly(error)) throw error;
|
|
4536
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4537
|
+
}
|
|
4538
|
+
if (!await this.io.exists(join(dir, "SKILL.md"))) {
|
|
4539
|
+
await this.io.remove(marker).catch(() => {});
|
|
4540
|
+
if ((await this.io.list(dir).catch(() => [])).length === 0) await this.io.remove(dir).catch(() => {});
|
|
4541
|
+
return {
|
|
4542
|
+
ok: false,
|
|
4543
|
+
message: `Skill "${normalized}" was archived concurrently while pinning; the partial marker was removed — retry after the mover settles.`
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4546
|
+
} else await this.io.remove(marker);
|
|
4414
4547
|
await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
|
|
4415
4548
|
return {
|
|
4416
4549
|
ok: true,
|
|
4417
|
-
message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned
|
|
4550
|
+
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
4551
|
path: dir
|
|
4419
4552
|
};
|
|
4420
4553
|
}
|
|
@@ -4456,6 +4589,10 @@ var SkillLibrary = class {
|
|
|
4456
4589
|
ok: false,
|
|
4457
4590
|
message: `Skill "${normalized}" already exists.`
|
|
4458
4591
|
};
|
|
4592
|
+
for (const entry of await this.io.list(this.root).catch(() => [])) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
|
|
4593
|
+
ok: false,
|
|
4594
|
+
message: `Skill "${normalized}" collides with the existing case-variant directory "${entry}" (skill names are lowercase-only); rename one of them.`
|
|
4595
|
+
};
|
|
4459
4596
|
const protection = await this.writeProtection(normalized, origin);
|
|
4460
4597
|
if (protection) return {
|
|
4461
4598
|
ok: false,
|
|
@@ -4465,20 +4602,31 @@ var SkillLibrary = class {
|
|
|
4465
4602
|
const createPath = join(dir, "SKILL.md");
|
|
4466
4603
|
let existsAtCommit = false;
|
|
4467
4604
|
let taskRan = false;
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4605
|
+
let createDurabilityWarning = "";
|
|
4606
|
+
if (this.transact) try {
|
|
4607
|
+
await this.transact(this.io, createPath, (current) => {
|
|
4608
|
+
taskRan = true;
|
|
4609
|
+
if (current !== null) {
|
|
4610
|
+
existsAtCommit = true;
|
|
4611
|
+
return current;
|
|
4612
|
+
}
|
|
4613
|
+
return onDisk;
|
|
4614
|
+
});
|
|
4615
|
+
} catch (error) {
|
|
4616
|
+
if (!isCommittedOnly(error)) throw error;
|
|
4617
|
+
createDurabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4618
|
+
}
|
|
4476
4619
|
else if (await this.io.exists(createPath)) {
|
|
4477
4620
|
taskRan = true;
|
|
4478
4621
|
existsAtCommit = true;
|
|
4479
4622
|
} else {
|
|
4480
4623
|
taskRan = true;
|
|
4481
|
-
|
|
4624
|
+
try {
|
|
4625
|
+
await this.io.writeText(createPath, onDisk);
|
|
4626
|
+
} catch (error) {
|
|
4627
|
+
if (!isCommittedOnly(error)) throw error;
|
|
4628
|
+
createDurabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4629
|
+
}
|
|
4482
4630
|
}
|
|
4483
4631
|
if (!taskRan) return {
|
|
4484
4632
|
ok: false,
|
|
@@ -4488,7 +4636,17 @@ var SkillLibrary = class {
|
|
|
4488
4636
|
ok: false,
|
|
4489
4637
|
message: `Skill "${normalized}" already exists.`
|
|
4490
4638
|
};
|
|
4491
|
-
if (origin !== "foreground")
|
|
4639
|
+
if (origin !== "foreground") {
|
|
4640
|
+
await this.io.writeText(markerPath(dir, "hermes-managed"), "");
|
|
4641
|
+
if (!await this.io.exists(createPath)) {
|
|
4642
|
+
await this.io.remove(markerPath(dir, "hermes-managed")).catch(() => {});
|
|
4643
|
+
if ((await this.io.list(dir).catch(() => [])).length === 0) await this.io.remove(dir).catch(() => {});
|
|
4644
|
+
return {
|
|
4645
|
+
ok: false,
|
|
4646
|
+
message: `Skill "${normalized}" was archived concurrently while being created; the partial marker was removed — retry once the mover settles.`
|
|
4647
|
+
};
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4492
4650
|
await this.audit(normalized, "create", null, onDisk, "created");
|
|
4493
4651
|
this.notifyMutation({
|
|
4494
4652
|
action: "create",
|
|
@@ -4497,7 +4655,7 @@ var SkillLibrary = class {
|
|
|
4497
4655
|
});
|
|
4498
4656
|
return {
|
|
4499
4657
|
ok: true,
|
|
4500
|
-
message: `Skill "${normalized}" created
|
|
4658
|
+
message: `Skill "${normalized}" created.${createDurabilityWarning === "" ? "" : ` (warning: the write landed but the directory fsync failed — durability unconfirmed: ${createDurabilityWarning})`}`,
|
|
4501
4659
|
path: dir,
|
|
4502
4660
|
...norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}
|
|
4503
4661
|
};
|
|
@@ -4821,9 +4979,7 @@ var SkillLibrary = class {
|
|
|
4821
4979
|
* stolen by the sweep. A dead-pid residue would otherwise permanently
|
|
4822
4980
|
* refuse archive/restore. */
|
|
4823
4981
|
async deleteStrandedLocks(dir) {
|
|
4824
|
-
await this.sweepLockIfStranded(join(dir,
|
|
4825
|
-
await this.sweepLockIfStranded(join(dir, ".pinned.lock"));
|
|
4826
|
-
await this.sweepLockIfStranded(join(dir, ".hermes-managed.lock"));
|
|
4982
|
+
for (const markerLock of MARKER_LOCK_NAMES) await this.sweepLockIfStranded(join(dir, markerLock));
|
|
4827
4983
|
for (const supportDir of SUPPORT_DIRS) {
|
|
4828
4984
|
let entries = [];
|
|
4829
4985
|
try {
|
|
@@ -4840,27 +4996,33 @@ var SkillLibrary = class {
|
|
|
4840
4996
|
async sweepLockIfStranded(lockPath) {
|
|
4841
4997
|
const body = await this.io.readText(lockPath).catch(() => null);
|
|
4842
4998
|
if (body === null) return;
|
|
4843
|
-
const
|
|
4844
|
-
if (
|
|
4845
|
-
const pid = Number(match[1]);
|
|
4999
|
+
const pid = parseLockBody(body);
|
|
5000
|
+
if (pid === null) return;
|
|
4846
5001
|
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
|
|
4847
5002
|
await this.io.remove(lockPath).catch(() => {});
|
|
4848
5003
|
}
|
|
4849
5004
|
/** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
|
|
4850
5005
|
* only a single, non-traversing path component is safe. Dotfiles
|
|
4851
|
-
* (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
|
|
5006
|
+
* (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
|
|
5007
|
+
* P2-4 (v19): a non-string entry (`skills: [123]`) is refused structurally
|
|
5008
|
+
* instead of throwing `name.includes is not a function`. */
|
|
4852
5009
|
safeSnapshotEntryName(name) {
|
|
5010
|
+
if (typeof name !== "string") return false;
|
|
4853
5011
|
return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && basename(name) === name;
|
|
4854
5012
|
}
|
|
4855
5013
|
/** A1-7 (v18): a root-level lock whose holder is alive must refuse the
|
|
4856
5014
|
* restore; a dead residue is swept so a crashed writer cannot block
|
|
4857
5015
|
* recovery. A non-lock body shape is left alone (user file). */
|
|
4858
5016
|
async refuseLiveLockOrSweep(lockPath, label) {
|
|
4859
|
-
|
|
5017
|
+
let body;
|
|
5018
|
+
try {
|
|
5019
|
+
body = await this.io.readText(lockPath);
|
|
5020
|
+
} catch (error) {
|
|
5021
|
+
throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
|
|
5022
|
+
}
|
|
4860
5023
|
if (body === null) return;
|
|
4861
|
-
const
|
|
4862
|
-
if (
|
|
4863
|
-
const pid = Number(match[1]);
|
|
5024
|
+
const pid = parseLockBody(body);
|
|
5025
|
+
if (pid === null) return;
|
|
4864
5026
|
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
|
|
4865
5027
|
await this.io.remove(lockPath).catch(() => {});
|
|
4866
5028
|
}
|
|
@@ -5279,7 +5441,17 @@ var SkillLibrary = class {
|
|
|
5279
5441
|
try {
|
|
5280
5442
|
for (const entry of landing) {
|
|
5281
5443
|
try {
|
|
5282
|
-
|
|
5444
|
+
if (this.transact) {
|
|
5445
|
+
const drift = { seen: false };
|
|
5446
|
+
await this.transact(this.io, entry.target, (current) => {
|
|
5447
|
+
if (current !== entry.previous) {
|
|
5448
|
+
drift.seen = true;
|
|
5449
|
+
return current;
|
|
5450
|
+
}
|
|
5451
|
+
return entry.content;
|
|
5452
|
+
});
|
|
5453
|
+
if (drift.seen) throw new Error(`concurrent modification detected: ${entry.target} changed after the plan was computed (a concurrent writer won the race); no further writes were performed`);
|
|
5454
|
+
} else await this.io.writeText(entry.target, entry.content);
|
|
5283
5455
|
} catch (error) {
|
|
5284
5456
|
if (error?.committed !== true) throw error;
|
|
5285
5457
|
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
@@ -5494,12 +5666,41 @@ var SkillLibrary = class {
|
|
|
5494
5666
|
};
|
|
5495
5667
|
}
|
|
5496
5668
|
/**
|
|
5669
|
+
* v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
|
|
5670
|
+
* but nothing bounded their growth — the curator auto-archives idle skills,
|
|
5671
|
+
* consolidation and manual deletes take the same path, and every snapshot
|
|
5672
|
+
* copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
|
|
5673
|
+
* older than this many days are pruned at snapshot time. Generous by
|
|
5674
|
+
* design: a year-old auto-archive is effectively dead recoverability.
|
|
5675
|
+
* Backends without the mtime probe skip pruning (no false deletes on
|
|
5676
|
+
* unknown age).
|
|
5677
|
+
*/
|
|
5678
|
+
async pruneExpiredArchives() {
|
|
5679
|
+
const archiveRoot = join(this.root, ".archive");
|
|
5680
|
+
if (!this.io.mtime) return;
|
|
5681
|
+
let entries = [];
|
|
5682
|
+
try {
|
|
5683
|
+
entries = await this.io.list(archiveRoot);
|
|
5684
|
+
} catch {
|
|
5685
|
+
return;
|
|
5686
|
+
}
|
|
5687
|
+
const cutoff = Date.now() - ARCHIVE_RETENTION_DAYS * 864e5;
|
|
5688
|
+
for (const entry of entries) {
|
|
5689
|
+
const entryPath = join(archiveRoot, entry);
|
|
5690
|
+
const mtime = await this.io.mtime(entryPath).catch(() => null);
|
|
5691
|
+
if (mtime === null || mtime > cutoff) continue;
|
|
5692
|
+
await this.io.remove(entryPath).catch(() => {});
|
|
5693
|
+
console.warn(`skill-store: pruned archived skill "${entry}" (older than ${ARCHIVE_RETENTION_DAYS} days; recoverable from snapshots until they rotate)`);
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
/**
|
|
5497
5697
|
* Snapshot the recoverable skills state: active tree, usage/suppression
|
|
5498
5698
|
* sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
|
|
5499
5699
|
* side files the Snapshot owner cares about (curator state); they are
|
|
5500
5700
|
* listed in the manifest and only those names are ever read back.
|
|
5501
5701
|
*/
|
|
5502
5702
|
async snapshotAll(reason = "pre-mutation", extras = []) {
|
|
5703
|
+
await this.pruneExpiredArchives();
|
|
5503
5704
|
const backupRoot = join(this.root, ".backups");
|
|
5504
5705
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
5505
5706
|
let dest = join(backupRoot, `skills-${stamp}`);
|
|
@@ -5581,7 +5782,14 @@ var SkillLibrary = class {
|
|
|
5581
5782
|
for (const name of entries.sort().reverse()) {
|
|
5582
5783
|
if (!name.startsWith("skills-")) continue;
|
|
5583
5784
|
const manifest = await this.readSnapshotManifest(join(backupRoot, name));
|
|
5584
|
-
if (manifest === null)
|
|
5785
|
+
if (manifest === null) {
|
|
5786
|
+
out.push({
|
|
5787
|
+
path: join(backupRoot, name),
|
|
5788
|
+
createdAt: "",
|
|
5789
|
+
reason: "unreadable or missing manifest (orphan snapshot)"
|
|
5790
|
+
});
|
|
5791
|
+
continue;
|
|
5792
|
+
}
|
|
5585
5793
|
out.push({
|
|
5586
5794
|
path: join(backupRoot, name),
|
|
5587
5795
|
createdAt: manifest.createdAt,
|
|
@@ -5660,13 +5868,19 @@ var SkillLibrary = class {
|
|
|
5660
5868
|
*/
|
|
5661
5869
|
async restoreSnapshotIntoRoot(snapshotPath) {
|
|
5662
5870
|
const manifest = await this.readSnapshotManifest(snapshotPath);
|
|
5663
|
-
if (manifest === null) {
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5871
|
+
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`);
|
|
5872
|
+
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`);
|
|
5873
|
+
if (manifest.skills.length === 0) {
|
|
5874
|
+
const snapshotEntries = await this.io.list(snapshotPath);
|
|
5875
|
+
const declared = new Set([
|
|
5876
|
+
"manifest.json",
|
|
5877
|
+
"extras",
|
|
5878
|
+
".archive",
|
|
5879
|
+
...manifest.skills,
|
|
5880
|
+
...manifest.sidecars
|
|
5881
|
+
]);
|
|
5882
|
+
const undeclared = snapshotEntries.filter((entry) => !declared.has(entry));
|
|
5883
|
+
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
5884
|
}
|
|
5671
5885
|
let rootEntries;
|
|
5672
5886
|
try {
|
|
@@ -5686,17 +5900,16 @@ var SkillLibrary = class {
|
|
|
5686
5900
|
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
5901
|
}
|
|
5688
5902
|
}
|
|
5689
|
-
const restoresSuppressed = manifest
|
|
5903
|
+
const restoresSuppressed = manifest.sidecars.includes(".curator-suppressed.json");
|
|
5690
5904
|
for (const entry of rootEntries) {
|
|
5691
5905
|
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
|
|
5692
5906
|
if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
|
|
5693
5907
|
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
|
|
5694
5908
|
await this.io.remove(join(this.root, entry));
|
|
5695
5909
|
}
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
|
|
5910
|
+
for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
|
|
5911
|
+
for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
|
|
5912
|
+
{
|
|
5700
5913
|
const archiveRoot = join(this.root, ".archive");
|
|
5701
5914
|
if (manifest.hasArchive === true) {
|
|
5702
5915
|
await this.io.remove(archiveRoot);
|
|
@@ -5710,4 +5923,4 @@ var SkillLibrary = class {
|
|
|
5710
5923
|
}
|
|
5711
5924
|
};
|
|
5712
5925
|
//#endregion
|
|
5713
|
-
export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOCK_BODY_RE, LOCK_SUFFIX, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary,
|
|
5926
|
+
export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOCK_BODY_RE, LOCK_SUFFIX, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPTION_HINT, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
|
package/lib/types/io.d.ts
CHANGED
|
@@ -45,11 +45,17 @@ export interface EvolutionIoLike {
|
|
|
45
45
|
* milliseconds since epoch, or `null` when unknown (unsupported backend,
|
|
46
46
|
* missing path, stat failure). Intended as a cheap invalidation stamp for a
|
|
47
47
|
* cached directory listing; a backend without it keeps event-driven
|
|
48
|
-
* invalidation only.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
48
|
+
* invalidation only. v20 correction: the former "NO in-tree consumer" note
|
|
49
|
+
* (V9-07, 0.3.51) went stale — there are now FOUR in-tree consumers, and a
|
|
50
|
+
* custom backend that omits `mtime` degrades them silently (every call
|
|
51
|
+
* site is optional-call + null-fallback, so omission stays legal):
|
|
52
|
+
* - evolution-skill-catalog: root-mtime stamp on the summaries cache —
|
|
53
|
+
* the second, out-of-band invalidation signal next to the
|
|
54
|
+
* `evolution/skill-mutated` / `evolution/skills-refresh` events;
|
|
55
|
+
* - evolution-curator: run-report recency ordering (2 call sites);
|
|
56
|
+
* - evolution-commands: `.bak` freshness probe in the preset installer.
|
|
57
|
+
* With `mtime` absent, catalog invalidation degrades to purely
|
|
58
|
+
* event-driven. Register new consumers here (the seam contract).
|
|
53
59
|
*/
|
|
54
60
|
mtime?(this: void, path: string): Promise<number | null>;
|
|
55
61
|
}
|
|
@@ -84,6 +90,18 @@ export declare const pendingSelfCleanup: Map<string, string>;
|
|
|
84
90
|
* rethrows with a pointer at the usual causes instead of a bare errno.
|
|
85
91
|
* `fn` is the rename primitive, injectable for deterministic tests.
|
|
86
92
|
*
|
|
93
|
+
* v22 (LOCK-5) documented platform limit: POSIX rename-over-existing is
|
|
94
|
+
* atomic for concurrent READERS, but Windows MoveFileExW(REPLACE_EXISTING)
|
|
95
|
+
* can make the target briefly invisible (ENOENT) while the replacement is in
|
|
96
|
+
* flight. The retry budget above covers the WRITE side only — a LOCKLESS
|
|
97
|
+
* reader (`readText`/`readJson` on a path it does not hold the lock for) can
|
|
98
|
+
* observe that window and see "missing" for a file that was just committed.
|
|
99
|
+
* Verified harmless today: every lockless-read consumer treats the transient
|
|
100
|
+
* miss as self-healing state (no consumer persists a read of null into a
|
|
101
|
+
* write), and distinguishing that ENOENT from a genuinely absent file in
|
|
102
|
+
* `readText` would tax every ordinary missing-file probe. Revisit only if a
|
|
103
|
+
* consumer appears that must never transiently miss.
|
|
104
|
+
*
|
|
87
105
|
* @param tmp - the source path to rename.
|
|
88
106
|
* @param target - the destination path.
|
|
89
107
|
* @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
|
|
@@ -119,8 +137,15 @@ export declare function isProcessAlive(pid: number): boolean;
|
|
|
119
137
|
export declare const LOCK_SUFFIX = ".lock";
|
|
120
138
|
/** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
|
|
121
139
|
* torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
|
|
122
|
-
* the pid part is intact, which is what the takeover probe needs.
|
|
140
|
+
* the pid part is intact, which is what the takeover probe needs. The capture
|
|
141
|
+
* group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
|
|
142
|
+
* helper instead of re-inlining a third regex copy — F-17 single-source). */
|
|
123
143
|
export declare const LOCK_BODY_RE: RegExp;
|
|
144
|
+
/** Parse a writer-lock body into its holder pid. `null` when the body does
|
|
145
|
+
* not have the `pid:token` shape at all (e.g. a user support file named
|
|
146
|
+
* `*.lock`) — callers leave such files alone. A shape-matching body always
|
|
147
|
+
* yields a number (possibly `0`, which `isProcessAlive` treats as dead). */
|
|
148
|
+
export declare function parseLockBody(body: string): number | null;
|
|
124
149
|
/**
|
|
125
150
|
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
126
151
|
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
@@ -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
|
|
@@ -472,6 +474,17 @@ export declare class SkillLibrary {
|
|
|
472
474
|
private writeSupportFileCore;
|
|
473
475
|
removeSupportFile(rawName: string, filePath: string, origin?: WriteOrigin): Promise<SkillActionResult>;
|
|
474
476
|
private removeSupportFileCore;
|
|
477
|
+
/**
|
|
478
|
+
* v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
|
|
479
|
+
* but nothing bounded their growth — the curator auto-archives idle skills,
|
|
480
|
+
* consolidation and manual deletes take the same path, and every snapshot
|
|
481
|
+
* copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
|
|
482
|
+
* older than this many days are pruned at snapshot time. Generous by
|
|
483
|
+
* design: a year-old auto-archive is effectively dead recoverability.
|
|
484
|
+
* Backends without the mtime probe skip pruning (no false deletes on
|
|
485
|
+
* unknown age).
|
|
486
|
+
*/
|
|
487
|
+
private pruneExpiredArchives;
|
|
475
488
|
/**
|
|
476
489
|
* Snapshot the recoverable skills state: active tree, usage/suppression
|
|
477
490
|
* sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
|
package/lib/types/threats.d.ts
CHANGED
|
@@ -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`.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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:
|
|
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[];
|
|
@@ -49,14 +61,14 @@ export declare function scanMemoryThreats(text: string, maxScanChars?: number, o
|
|
|
49
61
|
/** User-facing block message for skill content writes. */
|
|
50
62
|
export declare function scanContentThreats(text: string, maxScanChars?: number, options?: ScanOptions): string | null;
|
|
51
63
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* message verbatim.
|
|
64
|
+
* WD2 (0.3.56): the shared tail of every user-facing threat block — names the
|
|
65
|
+
* deployable self-heal path so the model (or operator) can allowlist a
|
|
66
|
+
* known-benign label. v20 (B-2) single source: the scan builders embed this
|
|
67
|
+
* constant verbatim; do NOT re-word it per call site (the three former copies
|
|
68
|
+
* — memory inline, content inline, and the dead `THREAT_EXEMPT_HINT` export,
|
|
69
|
+
* whose docblock still claimed a store-side append that memory-store/skill-store
|
|
70
|
+
* had already removed — had drifted apart). The evolution-threat guard channel
|
|
71
|
+
* returns the scan message verbatim, so the hint rides along there too.
|
|
60
72
|
*/
|
|
61
|
-
export declare const
|
|
73
|
+
export declare const THREAT_EXEMPTION_HINT = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
|
|
62
74
|
//# sourceMappingURL=threats.d.ts.map
|
package/lib/types/usage.d.ts
CHANGED
|
@@ -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
|
|
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