@hasna/skills 0.5.4 → 0.5.5

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/bin/migrate.js CHANGED
@@ -7,7 +7,7 @@ import { join as join6 } from "path";
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "@hasna/skills",
10
- version: "0.5.4",
10
+ version: "0.5.5",
11
11
  description: "Skills library for AI coding agents",
12
12
  type: "module",
13
13
  bin: {
@@ -461,7 +461,40 @@ function publicPrincipal(partial = {}) {
461
461
  // src/server/rows.ts
462
462
  import { randomUUID } from "crypto";
463
463
 
464
+ // src/lib/skill-entry-path.ts
465
+ class SkillEntryPaths {
466
+ files = new Set;
467
+ directories = new Set;
468
+ add(path, maxBytes, invalid, limit) {
469
+ if (path.length > maxBytes)
470
+ limit();
471
+ const encoded = new TextEncoder().encode(path);
472
+ if (encoded.byteLength > maxBytes)
473
+ limit();
474
+ if (new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(encoded) !== path)
475
+ invalid("Invalid UTF-8 entry path");
476
+ if (!path || /[\\:\x00-\x1f\x7f]/u.test(path))
477
+ invalid("Unsafe entry path");
478
+ if (path.split("/").some((segment) => !segment || segment === "." || segment === ".."))
479
+ invalid("Unsafe entry path segment");
480
+ const key = path.normalize("NFC").toLowerCase().normalize("NFC");
481
+ if (this.files.has(key) || this.directories.has(key))
482
+ invalid("Duplicate or conflicting entry path");
483
+ const parents = key.split("/");
484
+ parents.pop();
485
+ while (parents.length) {
486
+ const parent = parents.join("/");
487
+ if (this.files.has(parent))
488
+ invalid("Conflicting entry file ancestor");
489
+ this.directories.add(parent);
490
+ parents.pop();
491
+ }
492
+ this.files.add(key);
493
+ }
494
+ }
495
+
464
496
  // src/lib/skill-bundle.ts
497
+ var BLOCK = 512;
465
498
  var ANY_SEGMENT_EXCLUDES = new Set([
466
499
  ".git",
467
500
  ".ds_store",
@@ -598,6 +631,136 @@ var SKILL_BUNDLE_INSPECTION_LIMITS = Object.freeze({
598
631
  timeoutMs: 5000
599
632
  });
600
633
 
634
+ class SkillBundleInspectionError extends Error {
635
+ code;
636
+ constructor(code, message) {
637
+ super(message);
638
+ this.code = code;
639
+ this.name = "SkillBundleInspectionError";
640
+ }
641
+ }
642
+ function invalidBundle(message) {
643
+ throw new SkillBundleInspectionError("BUNDLE_INVALID", message);
644
+ }
645
+ class BoundedTarReader {
646
+ limits;
647
+ check;
648
+ header = new Uint8Array(BLOCK);
649
+ headerOffset = 0;
650
+ pending;
651
+ bodyOffset = 0;
652
+ padding = 0;
653
+ zeroBlocks = 0;
654
+ entries = [];
655
+ paths = new SkillEntryPaths;
656
+ fileBytes = 0;
657
+ constructor(limits, check) {
658
+ this.limits = limits;
659
+ this.check = check;
660
+ }
661
+ push(chunk) {
662
+ let offset = 0;
663
+ while (offset < chunk.byteLength) {
664
+ this.check();
665
+ if (this.pending) {
666
+ const count = Math.min(this.pending.bytes.byteLength - this.bodyOffset, chunk.byteLength - offset);
667
+ this.pending.bytes.set(chunk.subarray(offset, offset + count), this.bodyOffset);
668
+ offset += count;
669
+ this.bodyOffset += count;
670
+ if (this.bodyOffset === this.pending.bytes.byteLength) {
671
+ this.entries.push(this.pending);
672
+ this.pending = undefined;
673
+ }
674
+ } else if (this.padding) {
675
+ const count = Math.min(this.padding, chunk.byteLength - offset);
676
+ if (chunk.subarray(offset, offset + count).some((byte) => byte !== 0))
677
+ invalidBundle("Nonzero tar body padding");
678
+ offset += count;
679
+ this.padding -= count;
680
+ } else {
681
+ const count = Math.min(BLOCK - this.headerOffset, chunk.byteLength - offset);
682
+ this.header.set(chunk.subarray(offset, offset + count), this.headerOffset);
683
+ offset += count;
684
+ this.headerOffset += count;
685
+ if (this.headerOffset === BLOCK) {
686
+ this.readHeader();
687
+ this.headerOffset = 0;
688
+ }
689
+ }
690
+ }
691
+ }
692
+ finish() {
693
+ this.check();
694
+ if (this.pending || this.padding || this.headerOffset || this.zeroBlocks < 2)
695
+ invalidBundle("Truncated tar bundle");
696
+ return this.entries;
697
+ }
698
+ readHeader() {
699
+ this.check();
700
+ const h = this.header;
701
+ if (h.every((byte) => byte === 0)) {
702
+ this.zeroBlocks++;
703
+ return;
704
+ }
705
+ if (this.zeroBlocks)
706
+ invalidBundle("Nonzero tar data after terminator");
707
+ if (this.entries.length >= this.limits.entries)
708
+ throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle entry limit exceeded");
709
+ let checksum = 0;
710
+ for (let i = 0;i < BLOCK; i++)
711
+ checksum += i >= 148 && i < 156 ? 32 : h[i];
712
+ if (tarOctal(h.subarray(148, 156)) !== checksum)
713
+ invalidBundle("Invalid tar header checksum");
714
+ if (new TextDecoder().decode(h.subarray(257, 265)) !== "ustar\x00" + "00")
715
+ invalidBundle("Unsupported tar format");
716
+ if (h[156] !== 48 && h[156] !== 0 || h.subarray(157, 257).some((b) => b !== 0) || h.subarray(345).some((b) => b !== 0))
717
+ invalidBundle("Unsupported tar entry or path prefix");
718
+ const mode = tarOctal(h.subarray(100, 108));
719
+ if (mode > 511)
720
+ invalidBundle("Unsupported tar permission bits");
721
+ tarOctal(h.subarray(108, 116));
722
+ tarOctal(h.subarray(116, 124));
723
+ tarOctal(h.subarray(136, 148));
724
+ const size = tarOctal(h.subarray(124, 136));
725
+ if (size > this.limits.fileBytes || this.fileBytes + size > this.limits.decompressedBytes) {
726
+ throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle file byte limit exceeded");
727
+ }
728
+ const name = h.subarray(0, 100);
729
+ const end = name.indexOf(0);
730
+ if (end !== -1 && name.subarray(end).some((b) => b !== 0))
731
+ invalidBundle("Invalid tar path padding");
732
+ const raw = end === -1 ? name : name.subarray(0, end);
733
+ if (raw.byteLength > this.limits.pathBytes)
734
+ throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle path byte limit exceeded");
735
+ let path;
736
+ try {
737
+ path = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
738
+ } catch {
739
+ return invalidBundle("Invalid UTF-8 bundle path");
740
+ }
741
+ this.paths.add(path, this.limits.pathBytes, invalidBundle, () => {
742
+ throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle path byte limit exceeded");
743
+ });
744
+ this.fileBytes += size;
745
+ this.pending = { path, mode, bytes: new Uint8Array(new ArrayBuffer(size)) };
746
+ this.bodyOffset = 0;
747
+ this.padding = (BLOCK - size % BLOCK) % BLOCK;
748
+ if (!size) {
749
+ this.entries.push(this.pending);
750
+ this.pending = undefined;
751
+ }
752
+ }
753
+ }
754
+ function tarOctal(field) {
755
+ const text = new TextDecoder().decode(field);
756
+ if (!/^[0-7]+[\0 ]*$/.test(text))
757
+ invalidBundle("Invalid tar octal field");
758
+ const value = Number.parseInt(text, 8);
759
+ if (!Number.isSafeInteger(value))
760
+ invalidBundle("Tar integer is out of range");
761
+ return value;
762
+ }
763
+
601
764
  // src/server/rows.ts
602
765
  function nowIso() {
603
766
  return new Date().toISOString();
@@ -1083,10 +1246,16 @@ class SqliteSkillsStore {
1083
1246
  const orgId = input.principal.orgId;
1084
1247
  const now = nowIso();
1085
1248
  return this.db.transaction(() => {
1086
- const previous = this.get("SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
1249
+ const previous = this.get("SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at, source FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
1087
1250
  const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
1088
1251
  const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
1089
1252
  const tombstoned = previous?.tombstoned_at != null;
1253
+ if (input.seedBundledOnly && input.expectedRevisionId && !previous) {
1254
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, null);
1255
+ }
1256
+ if (input.seedBundledOnly && previous && (tombstoned || previous.source !== "bundled")) {
1257
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
1258
+ }
1090
1259
  const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
1091
1260
  if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
1092
1261
  throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
@@ -1160,8 +1329,9 @@ class SqliteSkillsStore {
1160
1329
  tombstoned_at = NULL,
1161
1330
  tombstone_purge_after = NULL,
1162
1331
  updated_at = excluded.updated_at
1163
- WHERE skills_registry.tombstoned_at IS NOT NULL
1164
- OR skills_registry.revision_id = ?
1332
+ WHERE (skills_registry.tombstoned_at IS NOT NULL OR skills_registry.revision_id = ?)
1333
+ AND (? = 0 OR (skills_registry.tombstoned_at IS NULL AND skills_registry.source = 'bundled'
1334
+ AND skills_registry.revision_id = ?))
1165
1335
  RETURNING *`, [
1166
1336
  orgId,
1167
1337
  input.slug,
@@ -1179,6 +1349,8 @@ class SqliteSkillsStore {
1179
1349
  revisionId,
1180
1350
  now,
1181
1351
  now,
1352
+ input.expectedRevisionId ?? NO_REVISION_SENTINEL,
1353
+ input.seedBundledOnly ? 1 : 0,
1182
1354
  input.expectedRevisionId ?? NO_REVISION_SENTINEL
1183
1355
  ]);
1184
1356
  if (!row) {