@hasna/skills 0.5.2 → 0.5.3

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.2",
10
+ version: "0.5.3",
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",
@@ -589,6 +622,144 @@ function ownBytes(view) {
589
622
  out.set(source);
590
623
  return out;
591
624
  }
625
+ var SKILL_BUNDLE_INSPECTION_LIMITS = Object.freeze({
626
+ compressedBytes: 16 * 1024 * 1024,
627
+ decompressedBytes: 64 * 1024 * 1024,
628
+ entries: 1024,
629
+ fileBytes: 16 * 1024 * 1024,
630
+ pathBytes: 100,
631
+ timeoutMs: 5000
632
+ });
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
+ }
592
763
 
593
764
  // src/server/rows.ts
594
765
  function nowIso() {
package/bin/server.js CHANGED
@@ -23100,7 +23100,7 @@ var init_dist_es9 = __esm(() => {
23100
23100
  // package.json
23101
23101
  var package_default = {
23102
23102
  name: "@hasna/skills",
23103
- version: "0.5.2",
23103
+ version: "0.5.3",
23104
23104
  description: "Skills library for AI coding agents",
23105
23105
  type: "module",
23106
23106
  bin: {
@@ -23232,6 +23232,40 @@ import { createHmac, timingSafeEqual } from "crypto";
23232
23232
  import { createHash } from "crypto";
23233
23233
  import { readFileSync, readdirSync, statSync } from "fs";
23234
23234
  import { join, relative } from "path";
23235
+
23236
+ // src/lib/skill-entry-path.ts
23237
+ class SkillEntryPaths {
23238
+ files = new Set;
23239
+ directories = new Set;
23240
+ add(path, maxBytes, invalid, limit) {
23241
+ if (path.length > maxBytes)
23242
+ limit();
23243
+ const encoded = new TextEncoder().encode(path);
23244
+ if (encoded.byteLength > maxBytes)
23245
+ limit();
23246
+ if (new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(encoded) !== path)
23247
+ invalid("Invalid UTF-8 entry path");
23248
+ if (!path || /[\\:\x00-\x1f\x7f]/u.test(path))
23249
+ invalid("Unsafe entry path");
23250
+ if (path.split("/").some((segment) => !segment || segment === "." || segment === ".."))
23251
+ invalid("Unsafe entry path segment");
23252
+ const key = path.normalize("NFC").toLowerCase().normalize("NFC");
23253
+ if (this.files.has(key) || this.directories.has(key))
23254
+ invalid("Duplicate or conflicting entry path");
23255
+ const parents = key.split("/");
23256
+ parents.pop();
23257
+ while (parents.length) {
23258
+ const parent = parents.join("/");
23259
+ if (this.files.has(parent))
23260
+ invalid("Conflicting entry file ancestor");
23261
+ this.directories.add(parent);
23262
+ parents.pop();
23263
+ }
23264
+ this.files.add(key);
23265
+ }
23266
+ }
23267
+
23268
+ // src/lib/skill-bundle.ts
23235
23269
  var BLOCK = 512;
23236
23270
  var ANY_SEGMENT_EXCLUDES = new Set([
23237
23271
  ".git",
@@ -23503,6 +23537,144 @@ function concat(chunks) {
23503
23537
  }
23504
23538
  return merged;
23505
23539
  }
23540
+ var SKILL_BUNDLE_INSPECTION_LIMITS = Object.freeze({
23541
+ compressedBytes: 16 * 1024 * 1024,
23542
+ decompressedBytes: 64 * 1024 * 1024,
23543
+ entries: 1024,
23544
+ fileBytes: 16 * 1024 * 1024,
23545
+ pathBytes: 100,
23546
+ timeoutMs: 5000
23547
+ });
23548
+
23549
+ class SkillBundleInspectionError extends Error {
23550
+ code;
23551
+ constructor(code, message) {
23552
+ super(message);
23553
+ this.code = code;
23554
+ this.name = "SkillBundleInspectionError";
23555
+ }
23556
+ }
23557
+ function invalidBundle(message) {
23558
+ throw new SkillBundleInspectionError("BUNDLE_INVALID", message);
23559
+ }
23560
+ class BoundedTarReader {
23561
+ limits;
23562
+ check;
23563
+ header = new Uint8Array(BLOCK);
23564
+ headerOffset = 0;
23565
+ pending;
23566
+ bodyOffset = 0;
23567
+ padding = 0;
23568
+ zeroBlocks = 0;
23569
+ entries = [];
23570
+ paths = new SkillEntryPaths;
23571
+ fileBytes = 0;
23572
+ constructor(limits, check) {
23573
+ this.limits = limits;
23574
+ this.check = check;
23575
+ }
23576
+ push(chunk) {
23577
+ let offset = 0;
23578
+ while (offset < chunk.byteLength) {
23579
+ this.check();
23580
+ if (this.pending) {
23581
+ const count = Math.min(this.pending.bytes.byteLength - this.bodyOffset, chunk.byteLength - offset);
23582
+ this.pending.bytes.set(chunk.subarray(offset, offset + count), this.bodyOffset);
23583
+ offset += count;
23584
+ this.bodyOffset += count;
23585
+ if (this.bodyOffset === this.pending.bytes.byteLength) {
23586
+ this.entries.push(this.pending);
23587
+ this.pending = undefined;
23588
+ }
23589
+ } else if (this.padding) {
23590
+ const count = Math.min(this.padding, chunk.byteLength - offset);
23591
+ if (chunk.subarray(offset, offset + count).some((byte) => byte !== 0))
23592
+ invalidBundle("Nonzero tar body padding");
23593
+ offset += count;
23594
+ this.padding -= count;
23595
+ } else {
23596
+ const count = Math.min(BLOCK - this.headerOffset, chunk.byteLength - offset);
23597
+ this.header.set(chunk.subarray(offset, offset + count), this.headerOffset);
23598
+ offset += count;
23599
+ this.headerOffset += count;
23600
+ if (this.headerOffset === BLOCK) {
23601
+ this.readHeader();
23602
+ this.headerOffset = 0;
23603
+ }
23604
+ }
23605
+ }
23606
+ }
23607
+ finish() {
23608
+ this.check();
23609
+ if (this.pending || this.padding || this.headerOffset || this.zeroBlocks < 2)
23610
+ invalidBundle("Truncated tar bundle");
23611
+ return this.entries;
23612
+ }
23613
+ readHeader() {
23614
+ this.check();
23615
+ const h = this.header;
23616
+ if (h.every((byte) => byte === 0)) {
23617
+ this.zeroBlocks++;
23618
+ return;
23619
+ }
23620
+ if (this.zeroBlocks)
23621
+ invalidBundle("Nonzero tar data after terminator");
23622
+ if (this.entries.length >= this.limits.entries)
23623
+ throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle entry limit exceeded");
23624
+ let checksum = 0;
23625
+ for (let i = 0;i < BLOCK; i++)
23626
+ checksum += i >= 148 && i < 156 ? 32 : h[i];
23627
+ if (tarOctal(h.subarray(148, 156)) !== checksum)
23628
+ invalidBundle("Invalid tar header checksum");
23629
+ if (new TextDecoder().decode(h.subarray(257, 265)) !== "ustar\x00" + "00")
23630
+ invalidBundle("Unsupported tar format");
23631
+ if (h[156] !== 48 && h[156] !== 0 || h.subarray(157, 257).some((b) => b !== 0) || h.subarray(345).some((b) => b !== 0))
23632
+ invalidBundle("Unsupported tar entry or path prefix");
23633
+ const mode = tarOctal(h.subarray(100, 108));
23634
+ if (mode > 511)
23635
+ invalidBundle("Unsupported tar permission bits");
23636
+ tarOctal(h.subarray(108, 116));
23637
+ tarOctal(h.subarray(116, 124));
23638
+ tarOctal(h.subarray(136, 148));
23639
+ const size = tarOctal(h.subarray(124, 136));
23640
+ if (size > this.limits.fileBytes || this.fileBytes + size > this.limits.decompressedBytes) {
23641
+ throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle file byte limit exceeded");
23642
+ }
23643
+ const name = h.subarray(0, 100);
23644
+ const end = name.indexOf(0);
23645
+ if (end !== -1 && name.subarray(end).some((b) => b !== 0))
23646
+ invalidBundle("Invalid tar path padding");
23647
+ const raw = end === -1 ? name : name.subarray(0, end);
23648
+ if (raw.byteLength > this.limits.pathBytes)
23649
+ throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle path byte limit exceeded");
23650
+ let path;
23651
+ try {
23652
+ path = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
23653
+ } catch {
23654
+ return invalidBundle("Invalid UTF-8 bundle path");
23655
+ }
23656
+ this.paths.add(path, this.limits.pathBytes, invalidBundle, () => {
23657
+ throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle path byte limit exceeded");
23658
+ });
23659
+ this.fileBytes += size;
23660
+ this.pending = { path, mode, bytes: new Uint8Array(new ArrayBuffer(size)) };
23661
+ this.bodyOffset = 0;
23662
+ this.padding = (BLOCK - size % BLOCK) % BLOCK;
23663
+ if (!size) {
23664
+ this.entries.push(this.pending);
23665
+ this.pending = undefined;
23666
+ }
23667
+ }
23668
+ }
23669
+ function tarOctal(field) {
23670
+ const text = new TextDecoder().decode(field);
23671
+ if (!/^[0-7]+[\0 ]*$/.test(text))
23672
+ invalidBundle("Invalid tar octal field");
23673
+ const value = Number.parseInt(text, 8);
23674
+ if (!Number.isSafeInteger(value))
23675
+ invalidBundle("Tar integer is out of range");
23676
+ return value;
23677
+ }
23506
23678
 
23507
23679
  // src/lib/skill-bundles.ts
23508
23680
  var SIGNATURE_PREFIX = "hmac-sha256:";
@@ -35781,6 +35953,20 @@ function parseSkillFrontmatter(content) {
35781
35953
 
35782
35954
  // src/lib/skill-hash.ts
35783
35955
  var HASH_EXCLUDE_DIRS = new Set([".git", "node_modules", "dist", "build", ".turbo"]);
35956
+ var CONTENT_HASH_LIMITS = Object.freeze({
35957
+ entries: 1024,
35958
+ rawBytes: 64 * 1024 * 1024,
35959
+ normalizedBytes: 64 * 1024 * 1024,
35960
+ fileBytes: 16 * 1024 * 1024,
35961
+ normalizedFileBytes: 16 * 1024 * 1024,
35962
+ pathBytes: 100,
35963
+ manifestBytes: 16 * 1024,
35964
+ manifestDepth: 64,
35965
+ timeoutMs: 5000
35966
+ });
35967
+ var typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
35968
+ var byteLengthOf = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteLength").get;
35969
+ var bufferOf = Object.getOwnPropertyDescriptor(typedArrayPrototype, "buffer").get;
35784
35970
 
35785
35971
  // src/lib/portable-skills-types.ts
35786
35972
  var PORTABLE_SKILL_STANDARD = "hasna.skill.v1";