@hasna/hooks 0.10.7 → 0.10.9

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.
@@ -1179,11 +1179,131 @@ var OUTPUT_REDIRECT = /(?:\d+|&)?(?:>>|>\||>&|<>|>)\s*([^\s;&|<>()\x60]*)/g;
1179
1179
  function withoutFdDuplication(segment) {
1180
1180
  return segment.replace(FD_DUPLICATION, " ");
1181
1181
  }
1182
+ function pipelineStages(segment) {
1183
+ const stages = [];
1184
+ let quote = null;
1185
+ let parens = 0;
1186
+ let braces = 0;
1187
+ let backtick = false;
1188
+ let start = 0;
1189
+ for (let i = 0;i < segment.length; i++) {
1190
+ const ch = segment[i];
1191
+ if (quote === "'") {
1192
+ if (ch === "'")
1193
+ quote = null;
1194
+ continue;
1195
+ }
1196
+ if (ch === "\\") {
1197
+ i++;
1198
+ continue;
1199
+ }
1200
+ if (ch === "(") {
1201
+ parens++;
1202
+ continue;
1203
+ }
1204
+ if (ch === ")") {
1205
+ if (--parens < 0)
1206
+ return [segment];
1207
+ continue;
1208
+ }
1209
+ if (ch === "$" && segment[i + 1] === "{") {
1210
+ braces++;
1211
+ i++;
1212
+ continue;
1213
+ }
1214
+ if (ch === "}" && braces > 0) {
1215
+ braces--;
1216
+ continue;
1217
+ }
1218
+ if (ch === "`") {
1219
+ backtick = !backtick;
1220
+ continue;
1221
+ }
1222
+ if (quote === '"') {
1223
+ if (ch === '"')
1224
+ quote = null;
1225
+ continue;
1226
+ }
1227
+ if (ch === "'" || ch === '"') {
1228
+ quote = ch;
1229
+ continue;
1230
+ }
1231
+ if (ch !== "|" || parens > 0 || braces > 0 || backtick || segment[i - 1] === ">")
1232
+ continue;
1233
+ stages.push(segment.slice(start, i));
1234
+ if (segment[i + 1] === "&")
1235
+ i++;
1236
+ start = i + 1;
1237
+ }
1238
+ if (quote || parens !== 0 || braces !== 0 || backtick)
1239
+ return [segment];
1240
+ stages.push(segment.slice(start));
1241
+ return stages;
1242
+ }
1243
+ function shellWords(command) {
1244
+ const words = [];
1245
+ let text = "";
1246
+ let raw = "";
1247
+ let quote = null;
1248
+ let inWord = false;
1249
+ const flush = () => {
1250
+ if (!inWord)
1251
+ return;
1252
+ const body = raw.replace(/^"?\$\{?HOME\}?"?(?=\/|$)/, "");
1253
+ const literalTilde = text.startsWith("~") && !/^~(?:\/|$)/.test(raw);
1254
+ words.push({ text: literalTilde ? `./${text}` : text, opaque: /[$\x60*?[]/.test(body) || /[()]/.test(body) });
1255
+ text = "";
1256
+ raw = "";
1257
+ inWord = false;
1258
+ };
1259
+ for (let i = 0;i < command.length; i++) {
1260
+ const ch = command[i];
1261
+ if (quote === "'") {
1262
+ if (ch === "'")
1263
+ quote = null;
1264
+ else
1265
+ text += ch;
1266
+ raw += ch;
1267
+ continue;
1268
+ }
1269
+ if (ch === "\\" && i + 1 < command.length) {
1270
+ const next = command[i + 1];
1271
+ text += quote === '"' && !/[$\x60"\\\n]/.test(next) ? ch + next : next;
1272
+ raw += ch + next;
1273
+ inWord = true;
1274
+ i++;
1275
+ continue;
1276
+ }
1277
+ if (quote === '"') {
1278
+ if (ch === '"')
1279
+ quote = null;
1280
+ else
1281
+ text += ch;
1282
+ raw += ch;
1283
+ continue;
1284
+ }
1285
+ if (/\s/.test(ch)) {
1286
+ flush();
1287
+ continue;
1288
+ }
1289
+ inWord = true;
1290
+ raw += ch;
1291
+ if (ch === "'" || ch === '"')
1292
+ quote = ch;
1293
+ else
1294
+ text += ch;
1295
+ }
1296
+ if (quote) {
1297
+ raw += "$";
1298
+ }
1299
+ flush();
1300
+ return words;
1301
+ }
1182
1302
  function segmentOperation(segment) {
1183
1303
  const trimmed = segment.trim();
1184
1304
  if (!trimmed)
1185
1305
  return "read";
1186
- if (/(?:^|\s)(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed))
1306
+ if (/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed))
1187
1307
  return "delete";
1188
1308
  if (/\bgit\b/.test(trimmed)) {
1189
1309
  if (/\bgit\b[^;&|]*\b(?:clean|rm)\b/.test(trimmed))
@@ -1192,7 +1312,7 @@ function segmentOperation(segment) {
1192
1312
  return "write";
1193
1313
  return "read";
1194
1314
  }
1195
- if (/(?:^|\s)(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed))
1315
+ if (/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed))
1196
1316
  return "write";
1197
1317
  if (/\b(?:curl|wget)\b/.test(trimmed)) {
1198
1318
  if (WRITE_FLAGS.test(trimmed))
@@ -1281,7 +1401,6 @@ function bashTargets(command, home, cwd) {
1281
1401
  for (let i = 0;i < opens; i++)
1282
1402
  subshells.push({ current: [...current], reachable: new Set(reachable), certain });
1283
1403
  const segment = unwrapSegment(rawSegment);
1284
- const op = segmentOperation(segment);
1285
1404
  const plainCd = segment.match(/^cd(?:\s+(?:-[A-Za-z@]+|--))*(?:\s+([^\s;&|<>()\x60]+))?$/);
1286
1405
  const embeddedCd = plainCd ? null : segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
1287
1406
  if (embeddedCd) {
@@ -1294,28 +1413,99 @@ function bashTargets(command, home, cwd) {
1294
1413
  const suffix = roots.map((root) => regexEscape(root.slice(home.length + 1))).join("|");
1295
1414
  const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/(?:${suffix})`);
1296
1415
  const re = new RegExp(`(${prefixRe.source})([^\\s"';&|<>()\`]*|$)`, "g");
1297
- let m;
1298
- let foundExplicit = false;
1299
- while ((m = re.exec(segment)) !== null) {
1300
- foundExplicit = true;
1301
- const expanded = expandHomeSpelling2(m[0], home);
1302
- targets.push({ path: normalize2(expanded).replace(/\/+$/, ""), op });
1303
- }
1304
- if (!foundExplicit && (op === "delete" || op === "write")) {
1416
+ const explicitTargets = (text) => {
1417
+ const found = [];
1418
+ let m;
1419
+ re.lastIndex = 0;
1420
+ while ((m = re.exec(text)) !== null)
1421
+ found.push(normalize2(expandHomeSpelling2(m[0], home)).replace(/\/+$/, ""));
1422
+ return found;
1423
+ };
1424
+ const stages = pipelineStages(segment).map((stage) => {
1305
1425
  const operands = [];
1306
- const commandPart = withoutFdDuplication(segment).replace(OUTPUT_REDIRECT, (_match, operand) => {
1426
+ let opaqueRedirect = false;
1427
+ const commandPart = withoutFdDuplication(stage).replace(OUTPUT_REDIRECT, (_match, operand) => {
1428
+ if (!operand || /[$\x60]/.test(operand))
1429
+ opaqueRedirect = true;
1307
1430
  if (operand)
1308
1431
  operands.push(operand);
1309
1432
  return " ";
1310
1433
  });
1311
1434
  const commandOp = segmentOperation(commandPart);
1312
- const relMatch = commandOp === "read" ? null : commandPart.match(REL_OPERAND);
1313
- for (const base of current.filter(underRoot)) {
1314
- for (const operand of operands) {
1435
+ return { commandPart, operands, op: opaqueRedirect && commandOp === "read" ? "write" : commandOp };
1436
+ });
1437
+ const pipelineOp = stages.some((stage) => stage.op === "delete") ? "delete" : stages.some((stage) => stage.op === "write") ? "write" : "read";
1438
+ const bases = current.filter(underRoot);
1439
+ for (const stage of stages) {
1440
+ let foundExplicit = false;
1441
+ for (const path of explicitTargets(stage.commandPart)) {
1442
+ foundExplicit = true;
1443
+ targets.push({ path, op: pipelineOp });
1444
+ }
1445
+ for (const operand of stage.operands) {
1446
+ const explicit = explicitTargets(operand);
1447
+ for (const path of explicit)
1448
+ targets.push({ path, op: "write" });
1449
+ if (explicit.length > 0)
1450
+ continue;
1451
+ for (const base of bases) {
1315
1452
  targets.push({ path: normalize2(resolve3(base, expandHomeSpelling2(operand, home))), op: "write" });
1316
1453
  }
1317
- if (relMatch)
1318
- targets.push({ path: normalize2(resolve3(base, relMatch[1])), op: commandOp });
1454
+ }
1455
+ if (foundExplicit || stage.op === "read")
1456
+ continue;
1457
+ const words = shellWords(stage.commandPart);
1458
+ let operandStart = 1;
1459
+ const tool = (words[0]?.text ?? "").split("/").pop();
1460
+ if (tool === "trash" && ["put", "rm", "delete", "remove"].includes(words[1]?.text ?? ""))
1461
+ operandStart = 2;
1462
+ if (tool === "git") {
1463
+ const sub = words.findIndex((word, index2) => index2 > 0 && (word.text === "rm" || word.text === "clean"));
1464
+ if (sub > 0)
1465
+ operandStart = sub + 1;
1466
+ }
1467
+ let endOfOptions = false;
1468
+ const nonFlag = words.slice(operandStart).flatMap((word) => {
1469
+ if (!endOfOptions && word.text === "--") {
1470
+ endOfOptions = true;
1471
+ return [];
1472
+ }
1473
+ if (!endOfOptions && word.text.startsWith("-"))
1474
+ return [];
1475
+ endOfOptions = true;
1476
+ return [word.opaque ? "." : word.text];
1477
+ });
1478
+ const everyOperand = stage.op === "delete" || /(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:mkdir|mkfile|touch|tee|truncate|install|mv)(?:\s|$)/.test(stage.commandPart) || /(?:^|\s)sed(?:\s|$)/.test(stage.commandPart) && /(?:^|\s)(?:-i\S*|--in-place\S*)(?:\s|$)/.test(stage.commandPart);
1479
+ const operandTargets = [];
1480
+ if (everyOperand) {
1481
+ for (const operand of nonFlag.length > 0 ? nonFlag : ["."])
1482
+ operandTargets.push({ operand, op: stage.op });
1483
+ } else {
1484
+ const relMatch = stage.commandPart.match(REL_OPERAND);
1485
+ const last = words.at(-1);
1486
+ operandTargets.push({ operand: relMatch ? relMatch[1] : !last || last.opaque ? "." : last.text, op: stage.op });
1487
+ if (/(?:^|\s)(?:cp|ln|install|mv)(?:\s|$)/.test(stage.commandPart)) {
1488
+ words.forEach((word, index2) => {
1489
+ let value;
1490
+ const short = word.text.match(/^-[A-Za-z]*?t(.*)$/);
1491
+ const long = word.text.match(/^(--t[a-z-]*)(?:=(.*))?$/);
1492
+ if (short && !word.text.startsWith("--")) {
1493
+ value = short[1] ? { text: short[1], opaque: word.opaque } : words[index2 + 1];
1494
+ } else if (long && "--target-directory".startsWith(long[1])) {
1495
+ value = long[2] !== undefined ? { text: long[2], opaque: word.opaque } : words[index2 + 1];
1496
+ }
1497
+ if (value)
1498
+ operandTargets.push({ operand: value.opaque || !value.text ? "." : value.text, op: stage.op });
1499
+ });
1500
+ }
1501
+ if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(stage.commandPart)) {
1502
+ operandTargets.push({ operand: ".", op: stage.op });
1503
+ }
1504
+ }
1505
+ for (const base of bases) {
1506
+ for (const { operand, op } of operandTargets) {
1507
+ targets.push({ path: normalize2(resolve3(base, expandHomeSpelling2(operand, home))), op });
1508
+ }
1319
1509
  }
1320
1510
  }
1321
1511
  if (plainCd) {
package/dist/index.js CHANGED
@@ -7941,7 +7941,7 @@ function writeHookSettingsPreimage(path, settings, before) {
7941
7941
  // src/lib/claude-skills-coordination.ts
7942
7942
  import { createHash as createHash5 } from "crypto";
7943
7943
 
7944
- // ../../node_modules/.bun/@hasna+skills@0.9.14+a00d3064559565bb/node_modules/@hasna/skills/dist/index.js
7944
+ // ../../node_modules/.bun/@hasna+skills@0.9.17+a00d3064559565bb/node_modules/@hasna/skills/dist/index.js
7945
7945
  import { existsSync as existsSync9 } from "fs";
7946
7946
  import { homedir as homedir4 } from "os";
7947
7947
  import { join as join8, resolve as resolve3 } from "path";
@@ -7954,6 +7954,7 @@ import { basename as basename2, isAbsolute as isAbsolute22, resolve as resolve22
7954
7954
  import { createHash as createHash16 } from "crypto";
7955
7955
  import { lstatSync as lstatSync20, readFileSync as readFileSync16, readdirSync as readdirSync9, realpathSync as realpathSync4 } from "fs";
7956
7956
  import { basename as basename8, dirname as dirname16, isAbsolute as isAbsolute14, join as join26, resolve as resolve14, sep as sep5 } from "path";
7957
+ import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
7957
7958
  import { dirname as dirname32, isAbsolute as isAbsolute3, join as join72, resolve as resolve32 } from "path";
7958
7959
  import { lstatSync as lstatSync3, readFileSync as readFileSync6, opendirSync } from "fs";
7959
7960
  import { createHash as createHash22 } from "crypto";
@@ -15434,8 +15435,8 @@ function assertAgentPolicyCollections(policy) {
15434
15435
  text(source.path);
15435
15436
  requireBound(source.sha256 === null || typeof source.sha256 === "string" && /^[a-f0-9]{64}$/.test(source.sha256));
15436
15437
  if (source.hashMode !== undefined)
15437
- requireBound(["bytes", "path-bytes", "claude-plugin-registry", "claude-marketplace-registry", "claude-settings-v1", "claude-settings-v2", "claude-marketplace-registry-v2"].includes(source.hashMode) && source.format === undefined && source.fields === undefined && (source.hashMode === "bytes" || source.sha256 !== null));
15438
- if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2")
15438
+ requireBound(["bytes", "path-bytes", "claude-plugin-registry", "claude-marketplace-registry", "claude-settings-v1", "claude-settings-v2", "claude-settings-v3", "claude-marketplace-registry-v2"].includes(source.hashMode) && source.format === undefined && source.fields === undefined && (source.hashMode === "bytes" || source.sha256 !== null));
15439
+ if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3")
15439
15440
  requireBound(agent === "claude" && value.agent === "claude" && value.method === "reviewed" && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute22(source.path) && resolve22(source.path) === source.path && basename2(source.path) === "settings.json");
15440
15441
  if (source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2")
15441
15442
  requireBound(agent === "claude" && value.agent === "claude" && value.method === "reviewed" && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute22(source.path) && resolve22(source.path) === source.path && basename2(source.path) === "known_marketplaces.json");
@@ -15930,7 +15931,7 @@ function hashDiscoveryPathFile(path, budget, changes) {
15930
15931
  }
15931
15932
  var package_default = {
15932
15933
  name: "@hasna/skills",
15933
- version: "0.9.14",
15934
+ version: "0.9.17",
15934
15935
  description: "Skills library for AI coding agents",
15935
15936
  type: "module",
15936
15937
  bin: {
@@ -16785,7 +16786,7 @@ function assertClaudeHookEventsReplacement(before, replacement, events) {
16785
16786
  need2(JSON.stringify(withoutOwnedEvents(original)) === JSON.stringify(withoutOwnedEvents(next)), `${label} update changed settings outside hooks.${label}`);
16786
16787
  }
16787
16788
  var PERSISTED_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh"]);
16788
- function inferencePreference(key, child) {
16789
+ function inferencePreference(key, child, version) {
16789
16790
  if (key === "skipDangerousModePermissionPrompt") {
16790
16791
  need2(child.kind === "literal" && (child.value === "true" || child.value === "false"), "contains an invalid dialog acknowledgment");
16791
16792
  return false;
@@ -16798,7 +16799,7 @@ function inferencePreference(key, child) {
16798
16799
  return false;
16799
16800
  need2(child.kind === "object", "requires an object of model preferences");
16800
16801
  child.entries = child.entries.filter(([model, preferences]) => {
16801
- if (!BUILTIN_MODEL_SELECTIONS.has(model) || preferences.kind !== "object" || preferences.entries.length !== 1 || preferences.entries[0][0] !== "effortLevel")
16802
+ if (version === 2 && !BUILTIN_MODEL_SELECTIONS.has(model) || preferences.kind !== "object" || preferences.entries.length !== 1 || preferences.entries[0][0] !== "effortLevel")
16802
16803
  return true;
16803
16804
  const effort = preferences.entries[0][1];
16804
16805
  need2(effort.kind === "string" && PERSISTED_EFFORT_LEVELS.has(effort.value), "contains an invalid persisted model effort level");
@@ -16809,7 +16810,7 @@ function inferencePreference(key, child) {
16809
16810
  function settingsDigest(text2, version = 1) {
16810
16811
  const value = parse3(text2);
16811
16812
  value.entries = value.entries.filter(([key, child]) => {
16812
- if (version === 2 && inferencePreference(key, child))
16813
+ if (version !== 1 && inferencePreference(key, child, version))
16813
16814
  return false;
16814
16815
  if (key === "model") {
16815
16816
  need2(child.kind === "string", "contains an invalid model selection");
@@ -16847,6 +16848,16 @@ function hashClaudeSettingsReplacementV2(text2, budget) {
16847
16848
  budget.remaining -= bytes;
16848
16849
  return settingsDigest(text2, 2);
16849
16850
  }
16851
+ function captureClaudeSettingsV3(path, budget = { remaining: 256 * 1024 * 1024 }) {
16852
+ return { path, hashMode: "claude-settings-v3", sha256: settingsDigest(readSettings(path, budget), 3) };
16853
+ }
16854
+ function hashClaudeSettingsReplacementV3(text2, budget) {
16855
+ need2(typeof text2 === "string", "requires settings text");
16856
+ const bytes = Buffer.byteLength(text2);
16857
+ need2(bytes <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes && Number.isSafeInteger(budget.remaining) && budget.remaining >= bytes, "exceeds its byte limit");
16858
+ budget.remaining -= bytes;
16859
+ return settingsDigest(text2, 3);
16860
+ }
16850
16861
  var sha = (data) => createHash10("sha256").update(data).digest("hex");
16851
16862
  var ROOTS = NATIVE_SKILL_ROOTS;
16852
16863
  function assertSafePath(path) {
@@ -17300,6 +17311,8 @@ function applyAgentIntegration(plan) {
17300
17311
  parseManagedSkillPolicy(change.after);
17301
17312
  const aliases = plan.rootAliases ?? [];
17302
17313
  recheckRootAliases(aliases);
17314
+ for (const agent of plan.retainedReviewChecks?.agents ?? [])
17315
+ assertManagedAgentBridge(agent, { ...plan.retainedReviewChecks, dataDir: plan.dataDir });
17303
17316
  for (const agent of plan.managedAgentChecks?.agents ?? [])
17304
17317
  assertManagedAgentBridge(agent, { home: plan.managedAgentChecks.home, dataDir: plan.dataDir, projectDir: plan.managedAgentChecks.home });
17305
17318
  for (const binding of plan.discoveryBefore ?? []) {
@@ -18313,13 +18326,17 @@ function unchanged2(before) {
18313
18326
  const after = snapshot(before.file, before.privateFile, before.readOnlyPackage ? { readOnlyPackage: true } : {});
18314
18327
  need4(sameStat(before.stat, after.stat) && after.sha256 === before.sha256, "INPUT_CHANGED");
18315
18328
  }
18316
- var sha2 = (text2) => createHash15("sha256").update(text2).digest("hex");
18329
+ var events = { UserPromptSubmit: ["userPromptSubmit", "user_prompt_submit"], SessionStart: ["sessionStart", "session_start"], SubagentStart: ["subagentStart", "subagent_start"] };
18330
+ var matchedEvents = { ...events, PreToolUse: ["preToolUse", "pre_tool_use"] };
18317
18331
  var proofs = new WeakMap;
18332
+ var sha2 = (text2) => createHash15("sha256").update(text2).digest("hex");
18333
+ var proofs2 = new WeakMap;
18318
18334
  function assertCodexHookDiscoveryRecovery(binding, witness) {
18319
- const proof = proofs.get(witness);
18335
+ const proof = proofs2.get(witness);
18320
18336
  need4(!!proof && binding.agent === "codex" && witness.discoverySha256 === sha2(JSON.stringify(binding)), "RECONCILE_DISCOVERY_RECOVERY_CHANGED");
18321
18337
  unchanged2(proof.before);
18322
18338
  unchanged2(proof.current);
18339
+ proof.recheckAdditions();
18323
18340
  }
18324
18341
  function verifiesCodexHookDiscoverySource(binding, source, currentDigest, witness) {
18325
18342
  if (!witness || source.path !== witness.configPath || source.hashMode !== "bytes")
@@ -18361,13 +18378,13 @@ function read2(path, changes) {
18361
18378
  return readFileSync16(path, "utf8");
18362
18379
  }
18363
18380
  function projected(source, changes, budget = discoveryByteBudget()) {
18364
- if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2") {
18381
+ if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3") {
18365
18382
  if (source.format !== undefined || source.fields !== undefined || source.managedPlugins !== undefined || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256))
18366
18383
  throw new Error("Claude settings witnesses require exact typed metadata");
18367
- const current2 = (source.hashMode === "claude-settings-v2" ? captureClaudeSettingsV2 : captureClaudeSettings)(source.path, budget).sha256;
18384
+ const current2 = (source.hashMode === "claude-settings-v3" ? captureClaudeSettingsV3 : source.hashMode === "claude-settings-v2" ? captureClaudeSettingsV2 : captureClaudeSettings)(source.path, budget).sha256;
18368
18385
  if (current2 !== source.sha256)
18369
18386
  throw new Error(`Native discovery input changed; run skills hook install with a fresh discovery review: ${source.path}`);
18370
- return changes?.has(source.path) ? (source.hashMode === "claude-settings-v2" ? hashClaudeSettingsReplacementV2 : hashClaudeSettingsReplacement)(changes.get(source.path), budget) : current2;
18387
+ return changes?.has(source.path) ? (source.hashMode === "claude-settings-v3" ? hashClaudeSettingsReplacementV3 : source.hashMode === "claude-settings-v2" ? hashClaudeSettingsReplacementV2 : hashClaudeSettingsReplacement)(changes.get(source.path), budget) : current2;
18371
18388
  }
18372
18389
  if (source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2") {
18373
18390
  if (source.format !== undefined || source.fields !== undefined || source.managedPlugins !== undefined || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256) || changes?.has(source.path))
@@ -18429,7 +18446,7 @@ function verifyAgentDiscovery(binding, codexRecovery) {
18429
18446
  safe4(root2);
18430
18447
  }
18431
18448
  function assertMarketplaceBinding(binding, source) {
18432
- if ((source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2") && (binding.agent !== "claude" || binding.method !== "reviewed" || basename8(source.path) !== "settings.json"))
18449
+ if ((source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3") && (binding.agent !== "claude" || binding.method !== "reviewed" || basename8(source.path) !== "settings.json"))
18433
18450
  throw new Error("Claude settings witnesses require explicit reviewed Claude configuration");
18434
18451
  if ((source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2") && (binding.agent !== "claude" || binding.method !== "reviewed"))
18435
18452
  throw new Error("Claude marketplace witnesses require explicit reviewed Claude discovery");
@@ -18438,7 +18455,10 @@ function rebindAgentDiscovery(binding, changes) {
18438
18455
  const budget = discoveryByteBudget();
18439
18456
  return { ...binding, sources: binding.sources.map((source) => {
18440
18457
  assertMarketplaceBinding(binding, source);
18441
- return { ...source, sha256: projected(source, changes, budget) };
18458
+ const sha2562 = projected(source, changes, budget);
18459
+ if (!changes.has(source.path) && sha2562 !== source.sha256)
18460
+ throw new Error(`Native discovery input changed; run skills hook install with a fresh discovery review: ${source.path}`);
18461
+ return { ...source, sha256: sha2562 };
18442
18462
  }) };
18443
18463
  }
18444
18464
  function assertProjectDiscovery(agent, directories, home, canonical4 = resolve14, reviewed) {
@@ -18550,6 +18570,19 @@ function resolveAgentDiscovery(options) {
18550
18570
  }
18551
18571
  }
18552
18572
  }
18573
+ if (options.reviewed === undefined && options.retainedReview !== undefined) {
18574
+ const retained = options.retainedReview;
18575
+ if (retained.agent !== agent || retained.method !== "reviewed")
18576
+ unresolved("invalid retained review");
18577
+ verifyAgentDiscovery(retained);
18578
+ if (!retained.sources.some((source) => source.path === canonical4(configPath) && source.format === undefined && source.fields === undefined))
18579
+ unresolved("retained review is missing the full agent configuration source");
18580
+ if (retained.sources.some((source) => (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3") && source.path !== canonical4(configPath)))
18581
+ unresolved("retained settings witness names another configuration source");
18582
+ if (sources.some((source) => !retained.sources.some((saved) => isDeepStrictEqual3(saved, source))) || [...roots].some((root2) => !retained.roots.includes(root2)) || agent === "gemini" && JSON.stringify(retained.builtinNames) !== JSON.stringify(builtinNames))
18583
+ unresolved("retained review is missing current runtime discovery coverage");
18584
+ return retained;
18585
+ }
18553
18586
  if (options.reviewed && (options.reviewed.version !== 1 || !Array.isArray(options.reviewed.agents) || options.reviewed.agents.some((item) => !item || typeof item !== "object") || new Set(options.reviewed.agents.map((item) => item.agent)).size !== options.reviewed.agents.length))
18554
18587
  throw new Error("Invalid --discovery-inputs version or agents");
18555
18588
  const review = options.reviewed?.agents.find((item) => item.agent === agent);
@@ -18560,7 +18593,7 @@ function resolveAgentDiscovery(options) {
18560
18593
  if (review.sources.some((source) => source.format !== undefined || source.fields !== undefined))
18561
18594
  throw new Error("Explicit discovery reviews require full source-file hashes");
18562
18595
  verifyAgentDiscovery(supplied);
18563
- if (review.sources.some((source) => (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2") && source.path !== canonical4(configPath)))
18596
+ if (review.sources.some((source) => (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3") && source.path !== canonical4(configPath)))
18564
18597
  throw new Error("Semantic settings witnesses must name the configured Claude configuration source");
18565
18598
  if (!review.sources.some((source) => source.path === canonical4(configPath)))
18566
18599
  throw new Error("Discovery review must include the agent configuration source");
@@ -39070,8 +39103,8 @@ var SESSION_RECONCILIATION_PLAN_TTL_MS = 5 * 60 * 1000;
39070
39103
  // src/lib/claude-skills-coordination.ts
39071
39104
  function coordinateClaudeSettings(before, replacement, options) {
39072
39105
  const prior = JSON.parse(before ?? "{}"), next = JSON.parse(replacement);
39073
- const events = new Set([...Object.keys(prior.hooks ?? {}), ...Object.keys(next.hooks ?? {})]);
39074
- const changed = [...events].filter((event) => JSON.stringify(prior.hooks?.[event]) !== JSON.stringify(next.hooks?.[event]));
39106
+ const events2 = new Set([...Object.keys(prior.hooks ?? {}), ...Object.keys(next.hooks ?? {})]);
39107
+ const changed = [...events2].filter((event) => JSON.stringify(prior.hooks?.[event]) !== JSON.stringify(next.hooks?.[event]));
39075
39108
  const selected = changed.length ? changed : ["PreToolUse"];
39076
39109
  const plan = planClaudeHookEventsUpdate({
39077
39110
  ...options,
@@ -39359,7 +39392,7 @@ function detectConflict(name, scope, target) {
39359
39392
  const meta3 = isNativeSafetyName(name) ? getHook(name) : resolveHookMeta(name);
39360
39393
  if (!meta3 || !meta3.matcher)
39361
39394
  return;
39362
- const events = new Set(getHookEvents(meta3));
39395
+ const events2 = new Set(getHookEvents(meta3));
39363
39396
  const registered = getRegisteredHooksForTarget(scope, target);
39364
39397
  for (const existingName of registered) {
39365
39398
  if (existingName === name)
@@ -39367,7 +39400,7 @@ function detectConflict(name, scope, target) {
39367
39400
  const existing = getHook(existingName);
39368
39401
  if (!existing || !existing.matcher)
39369
39402
  continue;
39370
- if (!getHookEvents(existing).some((event) => events.has(event)))
39403
+ if (!getHookEvents(existing).some((event) => events2.has(event)))
39371
39404
  continue;
39372
39405
  if (matchersOverlap(meta3.matcher.toLowerCase(), existing.matcher.toLowerCase())) {
39373
39406
  return `conflicts with '${existingName}' (same event ${meta3.event}, overlapping matcher '${existing.matcher}')`;
@@ -12,5 +12,9 @@ export declare function isNativeSafetyName(name: string): name is NativeSafetyNa
12
12
  export declare function buildNativeSafetyCommand(binding: NativeSafetyBinding): string;
13
13
  export declare function nativeSafetyRegistration(command: string): NativeSafetyName | undefined;
14
14
  export declare function nativeSafetyCommandBinding(command: string): NativeSafetyBinding | undefined;
15
+ /** Whether a saved supervisor command's pinned runtime and worker still verify
16
+ * on disk. `undefined` means the command is not a native-safety binding at all.
17
+ * Read-only: it never registers, rewrites or executes anything. */
18
+ export declare function nativeSafetyBindingVerified(command: string): boolean | undefined;
15
19
  /** Built-in capability only: no registry, custom source, profile or app store. */
16
20
  export declare function installedNativeSafetyCommand(name: NativeSafetyName): string;
@@ -43,6 +43,12 @@ export declare function readNativeSafetyRegistration(home?: string): {
43
43
  sha256: string;
44
44
  path: string;
45
45
  };
46
+ /** Bounded, non-leaking explanation for a refused binding: which integrity
47
+ * property the saved record fails. A binding that a third party could rewrite
48
+ * can point the supervisor at a different worker, so its owner and mode are part
49
+ * of the trust decision, not decoration. Read-only — it never repairs, rewrites
50
+ * or executes anything, and returns undefined when the record looks intact. */
51
+ export declare function nativeSafetyBindingIntegrityReason(home?: string): string | undefined;
46
52
  /** Register only the bundled composite guard. Updates preserve the exact prior
47
53
  * record and require its digest. A lock coordinates package-owned writers; an
48
54
  * unexpected preimage or a retained interrupted lock requires reconciliation. */
@@ -210,6 +210,23 @@ function readNativeSafetyRegistration(home = homedir2()) {
210
210
  throw new NativeSafetyError(error?.code === "ENOENT" ? "binding_missing" : "binding_invalid");
211
211
  }
212
212
  }
213
+ function nativeSafetyBindingIntegrityReason(home = homedir2()) {
214
+ try {
215
+ const path = join2(canonicalHome(home), ".hasna/hooks/native", filename);
216
+ const stat = lstatSync2(path);
217
+ if (!stat.isFile())
218
+ return "the saved binding is not a regular file";
219
+ if (!owner(stat.uid))
220
+ return "the saved binding is not owned by the current user";
221
+ if (stat.mode & 63)
222
+ return `the saved binding is accessible by group or others (mode ${(stat.mode & 511).toString(8).padStart(3, "0")})`;
223
+ if (stat.size > 32768)
224
+ return "the saved binding exceeds its size bound";
225
+ return;
226
+ } catch (error) {
227
+ return error?.code === "ENOENT" ? "the saved binding is missing" : undefined;
228
+ }
229
+ }
213
230
  function registerNativeSafety(options = {}) {
214
231
  const home = canonicalHome(options.home ?? homedir2());
215
232
  const record = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command: options.command ?? installedNativeSafetyCommand("trash-guard") };
@@ -395,6 +412,7 @@ export {
395
412
  verifyNativeSafetyCommand,
396
413
  registerNativeSafety,
397
414
  readNativeSafetyRegistration,
415
+ nativeSafetyBindingIntegrityReason,
398
416
  evaluateNativeSafetyForExecution,
399
417
  evaluateNativeSafety,
400
418
  NativeSafetyError
@@ -45,6 +45,21 @@ a newline or `||` the `cd` may have failed, so the earlier directories stay
45
45
  in scope. A `cd` inside `( ... )`, a pipeline or the background does not move
46
46
  it, and neither does a `cd` whose target cannot be resolved statically.
47
47
  Descriptor duplication (`2>&1`, `>&2`, `1>&-`) is not a write.
48
+ Each pipeline stage is classified on its own, with its output redirections
49
+ split off: `ls <root> 2>/dev/null | head` reads `<root>`, and the trailing
50
+ word of a later read-only stage (`| head -60`) is never taken as the write
51
+ target of an earlier stage. Pipes inside quotes or command substitutions are
52
+ not stage boundaries.
53
+ A protected path named anywhere in a pipeline takes the strongest operation
54
+ of any stage (`find <root>/x | xargs rm -rf` is a delete). Deletes,
55
+ `mkdir`/`touch`, `tee`, `truncate`, `install`, `mv` and `sed -i` check every
56
+ operand (options end at `--` or at the first operand); other writers check
57
+ their last one, plus any `-t`/`--target-directory` value. A delete tool's
58
+ subcommand word is not an operand (`trash put <path>`, `git rm <path>`).
59
+ An operand or redirect target that cannot be read statically (a variable
60
+ other than `$HOME`, `$( )`, backticks, a glob) fails closed to the directory
61
+ the command runs in, so `rm -rf "$TMPDIR/x"` run from a protected checkout is
62
+ refused. Use a literal absolute path for deletes outside the roots.
48
63
 
49
64
  ## Configuration
50
65