@aarmos/cli 0.1.3 → 0.2.1

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.
Files changed (2) hide show
  1. package/dist/index.js +740 -28
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command6 } from "commander";
4
+ import { Command as Command9 } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import { Command } from "commander";
8
- import { mkdirSync, writeFileSync, existsSync } from "fs";
9
- import { join } from "path";
8
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, cpSync, mkdtempSync, rmSync } from "fs";
9
+ import { join, dirname, relative } from "path";
10
+ import { tmpdir } from "os";
11
+ import { spawnSync } from "child_process";
10
12
 
11
13
  // src/lib/keys.ts
12
14
  import { generateKeyPairSync } from "crypto";
@@ -53,13 +55,28 @@ var GITIGNORE_TEMPLATE = `# Aarmos local state
53
55
  .aarmos/keys/
54
56
  .aarmos/avar/
55
57
  `;
58
+ var IMPORT_ALLOWLIST = [
59
+ "policy.aarmos.toml",
60
+ "avar.config.json",
61
+ "asp.yaml",
62
+ "asp.yml",
63
+ "policies",
64
+ "playbooks",
65
+ ".aarmos/packs"
66
+ ];
56
67
  function initCommand() {
57
- return new Command("init").description("Scaffold policy.aarmos.toml, avar.config.json, and local signing key").option("--force", "Overwrite existing files", false).action(async (opts) => {
68
+ return new Command("init").description("Scaffold policy.aarmos.toml, avar.config.json, and local signing key").option("--force", "Overwrite existing files", false).option(
69
+ "--from <repo>",
70
+ "Seed policies/playbooks from a GitHub repo (owner/repo[#ref][/subdir]) or tarball URL. Only Aarmos config files are copied; source code and keys are never imported."
71
+ ).action(async (opts) => {
58
72
  const cwd = process.cwd();
59
73
  const keyDir = join(cwd, ".aarmos", "keys");
60
74
  const chainDir = join(cwd, ".aarmos", "avar");
61
75
  mkdirSync(keyDir, { recursive: true });
62
76
  mkdirSync(chainDir, { recursive: true });
77
+ if (opts.from) {
78
+ await seedFromRepo(opts.from, cwd, opts.force);
79
+ }
63
80
  writeIfMissing(join(cwd, "policy.aarmos.toml"), POLICY_TEMPLATE, opts.force);
64
81
  writeIfMissing(join(cwd, "avar.config.json"), AVAR_CONFIG_TEMPLATE, opts.force);
65
82
  const gitignorePath = join(cwd, ".aarmos", ".gitignore");
@@ -78,16 +95,111 @@ function writeIfMissing(path, content, force) {
78
95
  console.log(`\xB7 skipped (exists): ${path}`);
79
96
  return;
80
97
  }
98
+ mkdirSync(dirname(path), { recursive: true });
81
99
  writeFileSync(path, content);
82
100
  console.log(`\u2713 wrote: ${path}`);
83
101
  }
102
+ function parseFrom(from) {
103
+ if (from.startsWith("http://") || from.startsWith("https://")) {
104
+ return { tarballUrl: from, subdir: "", label: from };
105
+ }
106
+ const hashIdx = from.indexOf("#");
107
+ const [pathPart, refPart] = hashIdx >= 0 ? [from.slice(0, hashIdx), from.slice(hashIdx + 1)] : [from, "main"];
108
+ const segs = pathPart.split("/").filter(Boolean);
109
+ if (segs.length < 2) throw new Error(`--from: expected owner/repo, got "${from}"`);
110
+ const [owner, repo, ...rest] = segs;
111
+ const subdir = rest.join("/");
112
+ return {
113
+ tarballUrl: `https://codeload.github.com/${owner}/${repo}/tar.gz/refs/heads/${refPart}`,
114
+ subdir,
115
+ label: `${owner}/${repo}${refPart !== "main" ? `#${refPart}` : ""}${subdir ? `/${subdir}` : ""}`
116
+ };
117
+ }
118
+ async function seedFromRepo(from, cwd, force) {
119
+ const parsed = parseFrom(from);
120
+ console.log(`\u25B8 seeding from ${parsed.label}`);
121
+ const staging = mkdtempSync(join(tmpdir(), "aarmos-init-"));
122
+ try {
123
+ const res = await fetch(parsed.tarballUrl);
124
+ if (!res.ok) throw new Error(`fetch ${parsed.tarballUrl}: ${res.status}`);
125
+ const buf = Buffer.from(await res.arrayBuffer());
126
+ const tarPath = join(staging, "src.tar.gz");
127
+ writeFileSync(tarPath, buf);
128
+ const extractDir = join(staging, "extract");
129
+ mkdirSync(extractDir);
130
+ const tar = spawnSync("tar", ["-xzf", tarPath, "-C", extractDir, "--strip-components=1"], {
131
+ stdio: ["ignore", "inherit", "inherit"]
132
+ });
133
+ if (tar.status !== 0) throw new Error("tar extract failed (is `tar` on PATH?)");
134
+ const root = parsed.subdir ? join(extractDir, parsed.subdir) : extractDir;
135
+ if (!existsSync(root)) throw new Error(`subdir not found in repo: ${parsed.subdir}`);
136
+ let copied = 0;
137
+ for (const rel of IMPORT_ALLOWLIST) {
138
+ const src = join(root, rel);
139
+ if (!existsSync(src)) continue;
140
+ const dest = join(cwd, rel);
141
+ if (existsSync(dest) && !force) {
142
+ console.log(`\xB7 skipped (exists): ${rel} \u2014 pass --force to overwrite`);
143
+ continue;
144
+ }
145
+ if (statSync(src).isDirectory()) {
146
+ const violations = findKeyLikeFiles(src);
147
+ if (violations.length > 0) {
148
+ console.log(`\u2717 refusing to copy ${rel}: contains key-like files: ${violations.slice(0, 3).join(", ")}`);
149
+ continue;
150
+ }
151
+ mkdirSync(dirname(dest), { recursive: true });
152
+ cpSync(src, dest, { recursive: true, force });
153
+ } else {
154
+ mkdirSync(dirname(dest), { recursive: true });
155
+ cpSync(src, dest, { force });
156
+ }
157
+ console.log(`\u2713 imported: ${rel}`);
158
+ copied++;
159
+ }
160
+ if (copied === 0) {
161
+ console.log("\xB7 no Aarmos config artifacts found in that repo; falling back to defaults");
162
+ }
163
+ } finally {
164
+ try {
165
+ rmSync(staging, { recursive: true, force: true });
166
+ } catch {
167
+ }
168
+ }
169
+ }
170
+ function findKeyLikeFiles(dir) {
171
+ const out = [];
172
+ const walk = (d) => {
173
+ for (const name of readdirSync(d)) {
174
+ const full = join(d, name);
175
+ const st = statSync(full);
176
+ if (st.isDirectory()) {
177
+ walk(full);
178
+ continue;
179
+ }
180
+ if (/\.(key|pem|p12|pfx)$/i.test(name) || /signing\.key$/i.test(name)) {
181
+ out.push(relative(dir, full));
182
+ continue;
183
+ }
184
+ if (st.size < 32e3) {
185
+ try {
186
+ const head = readFileSync(full, "utf8").slice(0, 200);
187
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(head)) out.push(relative(dir, full));
188
+ } catch {
189
+ }
190
+ }
191
+ }
192
+ };
193
+ walk(dir);
194
+ return out;
195
+ }
84
196
 
85
197
  // src/commands/run.ts
86
198
  import { Command as Command2 } from "commander";
87
199
  import { spawn } from "child_process";
88
200
 
89
201
  // src/lib/avar.ts
90
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, readFileSync } from "fs";
202
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
91
203
  import { join as join2 } from "path";
92
204
  import { createHash } from "crypto";
93
205
  var CHAIN_DIR = ".aarmos/avar";
@@ -131,10 +243,10 @@ function serialize(receipt) {
131
243
  }
132
244
 
133
245
  // src/lib/policy.ts
134
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
246
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
135
247
  import { createHash as createHash2 } from "crypto";
136
248
  function loadPolicy(path) {
137
- const raw = existsSync3(path) ? readFileSync2(path, "utf8") : "";
249
+ const raw = existsSync3(path) ? readFileSync3(path, "utf8") : "";
138
250
  const digest = createHash2("sha256").update(raw).digest("hex");
139
251
  return { path, raw, digest };
140
252
  }
@@ -242,7 +354,7 @@ function stripHopByHop(headers) {
242
354
 
243
355
  // src/commands/daemon.ts
244
356
  import { Command as Command4 } from "commander";
245
- import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync3 } from "fs";
357
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync3 } from "fs";
246
358
  import { join as join3 } from "path";
247
359
  import { spawn as spawn2 } from "child_process";
248
360
  var PID_FILE = join3(process.cwd(), ".aarmos", "daemon.pid");
@@ -267,7 +379,7 @@ function daemonCommand() {
267
379
  console.log("\xB7 no daemon running");
268
380
  return;
269
381
  }
270
- const pid = Number(readFileSync3(PID_FILE, "utf8"));
382
+ const pid = Number(readFileSync4(PID_FILE, "utf8"));
271
383
  try {
272
384
  process.kill(pid);
273
385
  } catch {
@@ -280,14 +392,14 @@ function daemonCommand() {
280
392
  console.log("stopped");
281
393
  return;
282
394
  }
283
- const pid = Number(readFileSync3(PID_FILE, "utf8"));
395
+ const pid = Number(readFileSync4(PID_FILE, "utf8"));
284
396
  console.log(`running (pid ${pid})`);
285
397
  });
286
398
  return cmd;
287
399
  }
288
400
  function isRunning() {
289
401
  if (!existsSync4(PID_FILE)) return false;
290
- const pid = Number(readFileSync3(PID_FILE, "utf8"));
402
+ const pid = Number(readFileSync4(PID_FILE, "utf8"));
291
403
  try {
292
404
  process.kill(pid, 0);
293
405
  return true;
@@ -298,7 +410,7 @@ function isRunning() {
298
410
 
299
411
  // src/commands/verify.ts
300
412
  import { Command as Command5 } from "commander";
301
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
413
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
302
414
  import { unzipSync, strFromU8 } from "fflate";
303
415
 
304
416
  // ../avar-core/src/canonicalize.ts
@@ -401,8 +513,8 @@ function getSubtle2() {
401
513
  return c.subtle;
402
514
  }
403
515
  function b64uDecode(s) {
404
- const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
405
- const bin = typeof atob === "function" ? atob(b64) : globalThis.Buffer.from(b64, "base64").toString("binary");
516
+ const b642 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
517
+ const bin = typeof atob === "function" ? atob(b642) : globalThis.Buffer.from(b642, "base64").toString("binary");
406
518
  const out = new Uint8Array(bin.length);
407
519
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
408
520
  return out;
@@ -597,15 +709,155 @@ function computeChainHead(entries) {
597
709
  return { entryHash: "", index: -1 };
598
710
  }
599
711
 
712
+ // ../avar-core/src/errors.ts
713
+ var SPEC_URL = "https://aarmos.io/docs/avar-spec";
714
+ var VERIFY_URL = "https://aarmos.io/trust/verify";
715
+ var QUICKSTART_URL = "https://aarmos.io/docs/quickstart";
716
+ var AARMOS_ERROR_TEMPLATES = {
717
+ BUNDLE_OVERSIZED: {
718
+ message: "That bundle is larger than the 25 MB browser limit.",
719
+ hint: "Run `aarmos verify <bundle>` locally instead \u2014 the CLI has no size cap.",
720
+ docsUrl: QUICKSTART_URL
721
+ },
722
+ BUNDLE_NOT_ZIP: {
723
+ message: "That file is not a valid .avar.zip.",
724
+ hint: "Bundles are zip archives. Rename or re-export the file, then try again.",
725
+ docsUrl: SPEC_URL
726
+ },
727
+ BUNDLE_MISSING_FILES: {
728
+ message: "Bundle is missing one or more required files.",
729
+ hint: "A valid AVAR bundle contains SPEC-VERSION, manifest.json, entries.ndjson, and pubkeys.json.",
730
+ docsUrl: SPEC_URL
731
+ },
732
+ BUNDLE_INVALID_JSON: {
733
+ message: "manifest.json or pubkeys.json is not valid JSON.",
734
+ hint: "The file exists but cannot be parsed. Re-export from the CLI to regenerate a clean bundle.",
735
+ docsUrl: SPEC_URL
736
+ },
737
+ BUNDLE_INVALID_NDJSON: {
738
+ message: "entries.ndjson contains a malformed line.",
739
+ hint: "Each line must be a self-contained JSON entry. One corrupt line usually means the file was edited by hand.",
740
+ docsUrl: SPEC_URL
741
+ },
742
+ BUNDLE_SPEC_UNSUPPORTED: {
743
+ message: "This bundle was produced by a spec version this verifier does not support.",
744
+ hint: "Update to the latest verifier (`npm i -g @aarmos/avar-core`) or re-export with a matching spec version.",
745
+ docsUrl: SPEC_URL
746
+ },
747
+ BUNDLE_EMPTY: {
748
+ message: "Bundle contains no entries.",
749
+ hint: "An empty ledger is unusual \u2014 check that the run actually produced receipts before exporting.",
750
+ docsUrl: SPEC_URL
751
+ },
752
+ SPEC_VERSION_MISMATCH: {
753
+ message: "This bundle uses a spec version this verifier does not recognize.",
754
+ hint: "Update the verifier (`npm i -g @aarmos/avar-core`) or re-export the bundle with matching spec version.",
755
+ docsUrl: SPEC_URL
756
+ },
757
+ ENTRIES_SHA256_MISMATCH: {
758
+ message: "entries.ndjson has been modified since the bundle was signed.",
759
+ hint: "The envelope hash in manifest.json no longer matches the file. Do not trust this bundle \u2014 re-export from the CLI.",
760
+ docsUrl: VERIFY_URL
761
+ },
762
+ SIGNATURE_MISMATCH: {
763
+ message: "One or more entry signatures do not match the embedded public keys.",
764
+ hint: "The bundle was modified after signing, or the wrong pubkeys.json is bundled.",
765
+ docsUrl: VERIFY_URL
766
+ },
767
+ FINGERPRINT_MISMATCH: {
768
+ message: "Device fingerprint on one or more entries does not match its public key.",
769
+ hint: "The entry claims a device whose public key does not hash to the declared fingerprint. Treat this bundle as untrusted.",
770
+ docsUrl: VERIFY_URL
771
+ },
772
+ CHAIN_BROKEN: {
773
+ message: "The per-entry hash chain is broken.",
774
+ hint: "An entry was inserted, removed, or edited after signing. The break index is shown in the issues list.",
775
+ docsUrl: VERIFY_URL
776
+ },
777
+ STEP_CHAIN_BROKEN: {
778
+ message: "The per-step hash chain inside one or more entries is broken.",
779
+ hint: "A decision or tool step was tampered with. The affected entry index is in the issues list.",
780
+ docsUrl: VERIFY_URL
781
+ },
782
+ MANIFEST_INVALID: {
783
+ message: "manifest.json is missing required fields or uses an unsupported format.",
784
+ hint: "Re-export the bundle from the CLI \u2014 do not edit manifest.json by hand.",
785
+ docsUrl: SPEC_URL
786
+ },
787
+ FILE_NOT_FOUND: {
788
+ message: "Receipt not found at the given path.",
789
+ hint: "Check the path \u2014 a common cause is running from a different working directory than expected.",
790
+ docsUrl: QUICKSTART_URL
791
+ },
792
+ FILE_UNREADABLE: {
793
+ message: "Receipt exists but could not be read.",
794
+ hint: "Check filesystem permissions on the file.",
795
+ docsUrl: QUICKSTART_URL
796
+ },
797
+ UNKNOWN: {
798
+ message: "Something went wrong while verifying that bundle.",
799
+ hint: "Try again with a fresh export. If the error persists, run `aarmos verify` locally to see the raw error.",
800
+ docsUrl: QUICKSTART_URL
801
+ }
802
+ };
803
+ function aarmosError(code, overrides = {}) {
804
+ const base = AARMOS_ERROR_TEMPLATES[code];
805
+ return {
806
+ code,
807
+ message: overrides.message ?? base.message,
808
+ hint: overrides.hint ?? base.hint,
809
+ docsUrl: overrides.docsUrl ?? base.docsUrl
810
+ };
811
+ }
812
+ function formatAarmosErrorLine(err) {
813
+ return `\u2717 ${err.code}: ${err.message}
814
+ Next: ${err.hint}
815
+ Docs: ${err.docsUrl}`;
816
+ }
817
+ function classifyReport(report) {
818
+ if (report.verdict !== "invalid") return null;
819
+ if (!report.entriesSha256Ok) return aarmosError("ENTRIES_SHA256_MISMATCH");
820
+ if (!report.signaturesOk) return aarmosError("SIGNATURE_MISMATCH");
821
+ if (!report.fingerprintsOk) return aarmosError("FINGERPRINT_MISMATCH");
822
+ if (!report.chainOk) return aarmosError("CHAIN_BROKEN");
823
+ if (!report.perStepChainOk) return aarmosError("STEP_CHAIN_BROKEN");
824
+ if (!report.formatOk) {
825
+ const specIssue = report.issues.find((i) => i.kind === "spec-version-mismatch");
826
+ if (specIssue) {
827
+ return aarmosError("SPEC_VERSION_MISMATCH", {
828
+ message: specIssue.detail ? `Spec mismatch \u2014 ${specIssue.detail}` : void 0
829
+ });
830
+ }
831
+ return aarmosError("MANIFEST_INVALID");
832
+ }
833
+ return aarmosError("UNKNOWN");
834
+ }
835
+
600
836
  // src/commands/verify.ts
601
837
  async function runVerify(opts) {
602
838
  const { file, json = false, quiet = false } = opts;
839
+ const emitError = (err, rc) => {
840
+ if (json) {
841
+ process.stdout.write(JSON.stringify({ verdict: "invalid", error: err }) + "\n");
842
+ } else if (!quiet) {
843
+ process.stderr.write(formatAarmosErrorLine(err) + "\n");
844
+ }
845
+ return rc;
846
+ };
603
847
  if (!existsSync5(file)) {
604
- if (!quiet) process.stderr.write(`\u2717 receipt not found: ${file}
605
- `);
606
- return 2;
848
+ return emitError(aarmosError("FILE_NOT_FOUND", { message: `Receipt not found: ${file}` }), 2);
849
+ }
850
+ let bytes;
851
+ try {
852
+ bytes = readFileSync5(file);
853
+ } catch (err) {
854
+ return emitError(
855
+ aarmosError("FILE_UNREADABLE", {
856
+ message: err instanceof Error ? err.message : String(err)
857
+ }),
858
+ 1
859
+ );
607
860
  }
608
- const bytes = readFileSync4(file);
609
861
  const isZip = looksLikeZip(bytes);
610
862
  if (!isZip) {
611
863
  try {
@@ -633,23 +885,40 @@ async function runVerify(opts) {
633
885
  }
634
886
  return 0;
635
887
  } catch (err) {
636
- if (!quiet) process.stderr.write(`\u2717 invalid JSON: ${String(err)}
637
- `);
638
- return 1;
888
+ return emitError(
889
+ aarmosError("BUNDLE_INVALID_JSON", {
890
+ message: `invalid JSON receipt: ${err instanceof Error ? err.message : String(err)}`
891
+ }),
892
+ 1
893
+ );
639
894
  }
640
895
  }
641
896
  let bundle;
642
897
  try {
643
898
  bundle = parseBundleBytes(bytes);
644
899
  } catch (err) {
645
- if (!quiet) process.stderr.write(`\u2717 malformed .avar.zip: ${String(err)}
646
- `);
647
- return 1;
900
+ const raw = err instanceof Error ? err.message : String(err);
901
+ let classified2;
902
+ if (/missing /i.test(raw)) {
903
+ classified2 = aarmosError("BUNDLE_MISSING_FILES", { message: raw });
904
+ } else if (/invalid zip|central directory|not a zip/i.test(raw)) {
905
+ classified2 = aarmosError("BUNDLE_NOT_ZIP", { message: raw });
906
+ } else if (/ndjson|entries\.ndjson/i.test(raw)) {
907
+ classified2 = aarmosError("BUNDLE_INVALID_NDJSON", { message: raw });
908
+ } else if (/JSON|Unexpected token/i.test(raw)) {
909
+ classified2 = aarmosError("BUNDLE_INVALID_JSON", { message: raw });
910
+ } else {
911
+ classified2 = aarmosError("UNKNOWN", { message: `malformed .avar.zip: ${raw}` });
912
+ }
913
+ return emitError(classified2, 1);
648
914
  }
649
915
  const report = await verifyBundle(bundle);
650
916
  const failed = report.verdict === "invalid";
917
+ const classified = classifyReport(report);
651
918
  if (json) {
652
- process.stdout.write(JSON.stringify(report) + "\n");
919
+ process.stdout.write(
920
+ JSON.stringify(classified ? { ...report, error: classified } : report) + "\n"
921
+ );
653
922
  } else if (!quiet) {
654
923
  const mark = failed ? "\u2717" : "\u2713";
655
924
  process.stdout.write(`${mark} verdict: ${report.verdict}
@@ -666,11 +935,15 @@ async function runVerify(opts) {
666
935
  process.stdout.write(` issues:
667
936
  `);
668
937
  for (const issue of report.issues) {
669
- process.stdout.write(` - [${issue.kind}] ${issue.detail}
938
+ process.stdout.write(` - [${issue.kind}] ${issue.detail ?? ""}
670
939
  `);
671
940
  }
672
941
  }
673
- process.stdout.write("\nverified locally \u2014 no Aarmos service was contacted.\n");
942
+ if (classified) {
943
+ process.stderr.write("\n" + formatAarmosErrorLine(classified) + "\n");
944
+ } else {
945
+ process.stdout.write("\nverified locally \u2014 no Aarmos service was contacted.\n");
946
+ }
674
947
  }
675
948
  return failed ? 1 : 0;
676
949
  }
@@ -700,8 +973,444 @@ function verifyCommand() {
700
973
  });
701
974
  }
702
975
 
976
+ // src/commands/lint.ts
977
+ import { Command as Command6 } from "commander";
978
+ import { existsSync as existsSync6, readFileSync as readFileSync6, statSync as statSync2, readdirSync as readdirSync2 } from "fs";
979
+ import { join as join4, extname } from "path";
980
+
981
+ // src/lib/asp-compile.ts
982
+ import { parse as parseYaml } from "yaml";
983
+ import { createHash as createHash3, createPrivateKey, sign as edSign, generateKeyPairSync as generateKeyPairSync2 } from "crypto";
984
+ function canonicalize2(value) {
985
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
986
+ if (Array.isArray(value)) return "[" + value.map(canonicalize2).join(",") + "]";
987
+ const obj = value;
988
+ const keys = Object.keys(obj).sort();
989
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize2(obj[k])).join(",") + "}";
990
+ }
991
+ function parseAspYaml(source) {
992
+ const doc = parseYaml(source);
993
+ if (!doc || typeof doc !== "object") throw new Error("ASP YAML must be a mapping");
994
+ return doc;
995
+ }
996
+ function compileAsp(doc) {
997
+ const errors = [];
998
+ const v = doc.version ?? 1;
999
+ if (v !== 1) errors.push(`version must be 1 (got ${v})`);
1000
+ const name = doc.metadata?.issuer?.name;
1001
+ const pubKey = doc.metadata?.issuer?.pubKey;
1002
+ if (!name) errors.push("metadata.issuer.name is required");
1003
+ if (!pubKey) errors.push("metadata.issuer.pubKey is required (base64 raw 32B ed25519)");
1004
+ const issuedAt = doc.metadata?.issuedAt;
1005
+ const expiresAt = doc.metadata?.expiresAt;
1006
+ if (!issuedAt) errors.push("metadata.issuedAt is required (ISO 8601)");
1007
+ if (!expiresAt) errors.push("metadata.expiresAt is required (ISO 8601)");
1008
+ if (issuedAt && Number.isNaN(Date.parse(issuedAt)))
1009
+ errors.push(`metadata.issuedAt is not a valid ISO date: ${issuedAt}`);
1010
+ if (expiresAt && Number.isNaN(Date.parse(expiresAt)))
1011
+ errors.push(`metadata.expiresAt is not a valid ISO date: ${expiresAt}`);
1012
+ if (issuedAt && expiresAt && Date.parse(expiresAt) <= Date.parse(issuedAt))
1013
+ errors.push("metadata.expiresAt must be after metadata.issuedAt");
1014
+ if (errors.length) return { ok: false, errors };
1015
+ const policy = {};
1016
+ if (doc.allow?.servers?.length) policy.allowedServers = [...doc.allow.servers];
1017
+ if (doc.allow?.tools?.length) policy.allowedTools = [...doc.allow.tools];
1018
+ if (doc.deny?.tools?.length) policy.deniedTools = [...doc.deny.tools];
1019
+ if (doc.scopes && Object.keys(doc.scopes).length) policy.allowedScopes = { ...doc.scopes };
1020
+ if (typeof doc.kill_switch?.default === "boolean")
1021
+ policy.killSwitchDefault = doc.kill_switch.default;
1022
+ if (doc.redaction?.length) policy.redactionRules = doc.redaction.map((r) => ({ ...r }));
1023
+ if (doc.autonomous_scopes?.length)
1024
+ policy.autonomousScopes = doc.autonomous_scopes.map((s) => ({
1025
+ agentId: s.agent_id,
1026
+ tools: [...s.tools],
1027
+ maxUsdPerDay: s.max_usd_per_day,
1028
+ maxStepsPerRun: s.max_steps_per_run,
1029
+ expiresAt: s.expires_at
1030
+ }));
1031
+ const body = {
1032
+ version: 1,
1033
+ issuer: { name, pubKey },
1034
+ issuedAt,
1035
+ expiresAt,
1036
+ policy
1037
+ };
1038
+ return { ok: true, body, canonical: canonicalize2(body) };
1039
+ }
1040
+ function compileAspYamlToCanonicalJson(source) {
1041
+ let doc;
1042
+ try {
1043
+ doc = parseAspYaml(source);
1044
+ } catch (err) {
1045
+ return { ok: false, errors: [`YAML parse error: ${err.message}`] };
1046
+ }
1047
+ return compileAsp(doc);
1048
+ }
1049
+ var fromB64u = (s) => {
1050
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
1051
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
1052
+ };
1053
+ var b64 = (buf) => Buffer.from(buf).toString("base64");
1054
+ function privateKeyFromSeedB64u(seedB64u) {
1055
+ const seed = fromB64u(seedB64u);
1056
+ if (seed.length !== 32) throw new Error("Seed must be 32 bytes (base64url)");
1057
+ const pkcs8 = Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]);
1058
+ return createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" });
1059
+ }
1060
+ function signBody(body, seedB64u) {
1061
+ const priv = privateKeyFromSeedB64u(seedB64u);
1062
+ const canon = canonicalize2(body);
1063
+ const sig = edSign(null, Buffer.from(canon), priv);
1064
+ return { ...body, sig: b64(sig) };
1065
+ }
1066
+ function keygen() {
1067
+ const { publicKey, privateKey } = generateKeyPairSync2("ed25519");
1068
+ const pubJwk = publicKey.export({ format: "jwk" });
1069
+ const privJwk = privateKey.export({ format: "jwk" });
1070
+ return { pubKeyB64: b64(fromB64u(pubJwk.x)), seedB64u: privJwk.d };
1071
+ }
1072
+
1073
+ // src/commands/lint.ts
1074
+ function collectFiles(target) {
1075
+ if (!existsSync6(target)) return [];
1076
+ const st = statSync2(target);
1077
+ if (st.isFile()) return [target];
1078
+ const out = [];
1079
+ for (const name of readdirSync2(target)) {
1080
+ const full = join4(target, name);
1081
+ const s = statSync2(full);
1082
+ if (s.isDirectory()) {
1083
+ if (name === "tests" || name === "node_modules" || name.startsWith(".")) continue;
1084
+ out.push(...collectFiles(full));
1085
+ } else if (extname(name) === ".yaml" || extname(name) === ".yml") {
1086
+ if (name.endsWith(".asp.yaml") || name === "asp.yaml" || name === "asp.yml") {
1087
+ out.push(full);
1088
+ }
1089
+ }
1090
+ }
1091
+ return out;
1092
+ }
1093
+ function lintPath(target) {
1094
+ const files = collectFiles(target);
1095
+ const issues = [];
1096
+ if (files.length === 0) {
1097
+ issues.push({ severity: "error", file: target, message: "no ASP files found (asp.yaml or *.asp.yaml)" });
1098
+ return issues;
1099
+ }
1100
+ for (const file of files) {
1101
+ const src = readFileSync6(file, "utf8");
1102
+ const result = compileAspYamlToCanonicalJson(src);
1103
+ if (!result.ok) {
1104
+ for (const m of result.errors) issues.push({ severity: "error", file, message: m });
1105
+ continue;
1106
+ }
1107
+ const body = result.body;
1108
+ const dangerous = /(delete|drop|admin|payment|transfer)/i;
1109
+ const allow = body.policy.allowedTools ?? [];
1110
+ const hasDangerous = allow.some((t) => dangerous.test(t));
1111
+ if (hasDangerous) {
1112
+ issues.push({
1113
+ severity: "warning",
1114
+ file,
1115
+ message: "allow.tools includes admin/delete-capable patterns \u2014 declare human_oversight.require_approval_for or move to deny."
1116
+ });
1117
+ }
1118
+ const deny = body.policy.deniedTools ?? [];
1119
+ for (const d of deny) {
1120
+ if (allow.includes(d)) {
1121
+ issues.push({ severity: "warning", file, message: `rule "${d}" is in both allow.tools and deny.tools (deny wins; remove from allow).` });
1122
+ }
1123
+ }
1124
+ const soon = Date.parse(body.expiresAt) - Date.now() < 7 * 24 * 3600 * 1e3;
1125
+ if (soon) {
1126
+ issues.push({ severity: "warning", file, message: `metadata.expiresAt is within 7 days (${body.expiresAt}).` });
1127
+ }
1128
+ }
1129
+ return issues;
1130
+ }
1131
+ function lintCommand() {
1132
+ return new Command6("lint").description("Validate ASP YAML policy files. Zero network.").argument("[path]", "File or directory to lint", ".").option("--strict", "Treat warnings as errors").option("--json", "Emit JSON report").action((path, opts) => {
1133
+ const issues = lintPath(path);
1134
+ const errors = issues.filter((i) => i.severity === "error");
1135
+ const warnings = issues.filter((i) => i.severity === "warning");
1136
+ if (opts.json) {
1137
+ process.stdout.write(JSON.stringify({ errors, warnings }, null, 2) + "\n");
1138
+ } else {
1139
+ for (const i of issues) {
1140
+ const mark = i.severity === "error" ? "\u2717" : "\u26A0";
1141
+ process.stdout.write(`${mark} [${i.severity}] ${i.file}: ${i.message}
1142
+ `);
1143
+ }
1144
+ if (errors.length === 0 && warnings.length === 0) {
1145
+ process.stdout.write("\u2713 ASP clean.\n");
1146
+ }
1147
+ }
1148
+ if (errors.length > 0) process.exit(1);
1149
+ if (opts.strict && warnings.length > 0) process.exit(2);
1150
+ process.exit(0);
1151
+ });
1152
+ }
1153
+
1154
+ // src/commands/test.ts
1155
+ import { Command as Command7 } from "commander";
1156
+ import { existsSync as existsSync7, readFileSync as readFileSync7, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1157
+ import { join as join5, extname as extname2, dirname as dirname2 } from "path";
1158
+ import { parse as parseYaml2 } from "yaml";
1159
+
1160
+ // src/lib/asp-eval.ts
1161
+ function globMatch(pattern, value) {
1162
+ const re = new RegExp(
1163
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
1164
+ );
1165
+ return re.test(value);
1166
+ }
1167
+ function evaluate(body, call) {
1168
+ const p = body.policy;
1169
+ if (p.deniedTools?.some((g) => globMatch(g, call.tool))) {
1170
+ return { decision: "deny", reason: `matched deny.tools glob for "${call.tool}"` };
1171
+ }
1172
+ if (call.server && p.allowedServers && p.allowedServers.length > 0) {
1173
+ const serverOk = p.allowedServers.some((s) => s === call.server || globMatch(s, call.server));
1174
+ if (!serverOk) return { decision: "deny", reason: `server "${call.server}" not in allow.servers` };
1175
+ }
1176
+ if (p.allowedTools && p.allowedTools.length > 0) {
1177
+ const toolOk = p.allowedTools.some((g) => globMatch(g, call.tool));
1178
+ if (!toolOk) return { decision: "deny", reason: `tool "${call.tool}" not in allow.tools` };
1179
+ }
1180
+ if (call.scopes && p.allowedScopes) {
1181
+ for (const [svc, requested] of Object.entries(call.scopes)) {
1182
+ const allowed = p.allowedScopes[svc] ?? [];
1183
+ for (const scope of requested) {
1184
+ if (!allowed.includes(scope)) {
1185
+ return { decision: "deny", reason: `scope "${scope}" not allowed for service "${svc}"` };
1186
+ }
1187
+ }
1188
+ }
1189
+ }
1190
+ return { decision: "allow", reason: "matched allow rules" };
1191
+ }
1192
+
1193
+ // src/commands/test.ts
1194
+ function findAspYaml(startDir) {
1195
+ let d = startDir;
1196
+ for (let i = 0; i < 6; i++) {
1197
+ for (const cand of ["asp.yaml", "asp.yml"]) {
1198
+ const p = join5(d, cand);
1199
+ if (existsSync7(p)) return p;
1200
+ }
1201
+ const parent = dirname2(d);
1202
+ if (parent === d) break;
1203
+ d = parent;
1204
+ }
1205
+ return null;
1206
+ }
1207
+ function collectFixtures(target) {
1208
+ if (!existsSync7(target)) return [];
1209
+ const st = statSync3(target);
1210
+ if (st.isFile()) return [target];
1211
+ const out = [];
1212
+ for (const name of readdirSync3(target)) {
1213
+ const full = join5(target, name);
1214
+ const s = statSync3(full);
1215
+ if (s.isDirectory()) out.push(...collectFixtures(full));
1216
+ else if ((extname2(name) === ".yaml" || extname2(name) === ".yml") && !name.endsWith(".asp.yaml"))
1217
+ out.push(full);
1218
+ }
1219
+ return out;
1220
+ }
1221
+ function runFixtures(target) {
1222
+ const dir = existsSync7(target) && statSync3(target).isDirectory() ? target : "policies/tests";
1223
+ const fixtures = collectFixtures(existsSync7(dir) ? dir : target);
1224
+ const results = [];
1225
+ for (const file of fixtures) {
1226
+ let fx;
1227
+ try {
1228
+ fx = parseYaml2(readFileSync7(file, "utf8"));
1229
+ } catch (err) {
1230
+ results.push({ file, name: "<parse>", ok: false, reason: err.message });
1231
+ continue;
1232
+ }
1233
+ const policyPath = fx.policy ? join5(dirname2(file), fx.policy) : findAspYaml(dirname2(file));
1234
+ if (!policyPath || !existsSync7(policyPath)) {
1235
+ results.push({ file, name: "<policy>", ok: false, reason: "no asp.yaml found" });
1236
+ continue;
1237
+ }
1238
+ const compiled = compileAspYamlToCanonicalJson(readFileSync7(policyPath, "utf8"));
1239
+ if (!compiled.ok) {
1240
+ results.push({ file, name: "<compile>", ok: false, reason: compiled.errors.join("; ") });
1241
+ continue;
1242
+ }
1243
+ for (const c of fx.cases ?? []) {
1244
+ const r = evaluate(compiled.body, c.call);
1245
+ const ok = r.decision === c.expect;
1246
+ results.push({
1247
+ file,
1248
+ name: c.name,
1249
+ ok,
1250
+ reason: ok ? r.reason : `expected ${c.expect}, got ${r.decision} (${r.reason})`
1251
+ });
1252
+ }
1253
+ }
1254
+ return results;
1255
+ }
1256
+ function testCommand() {
1257
+ return new Command7("test").description("Run ASP policy golden tests against fixtures. Deterministic, zero network.").argument("[path]", "Fixtures dir", "policies/tests").option("--json", "Emit JSON report").action((path, opts) => {
1258
+ const results = runFixtures(path);
1259
+ const failed = results.filter((r) => !r.ok);
1260
+ if (opts.json) {
1261
+ process.stdout.write(JSON.stringify({ results }, null, 2) + "\n");
1262
+ } else {
1263
+ for (const r of results) {
1264
+ process.stdout.write(`${r.ok ? "\u2713" : "\u2717"} ${r.file} :: ${r.name} \u2014 ${r.reason}
1265
+ `);
1266
+ }
1267
+ process.stdout.write(`
1268
+ ${results.length - failed.length}/${results.length} passed.
1269
+ `);
1270
+ }
1271
+ process.exit(failed.length > 0 ? 1 : 0);
1272
+ });
1273
+ }
1274
+
1275
+ // src/commands/policy.ts
1276
+ import { Command as Command8 } from "commander";
1277
+ import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync4, watch as fsWatch, statSync as statSync4 } from "fs";
1278
+ import { dirname as dirname3, resolve } from "path";
1279
+ function readAspOrDie(path) {
1280
+ if (!existsSync8(path)) {
1281
+ process.stderr.write(`\u2717 file not found: ${path}
1282
+ `);
1283
+ process.exit(1);
1284
+ }
1285
+ return readFileSync8(path, "utf8");
1286
+ }
1287
+ function runPolicyOnce(policyPath, testsDir, opts = {}) {
1288
+ const started = Date.now();
1289
+ let output = `
1290
+ \u25B8 ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} ${opts.label ?? policyPath}
1291
+ `;
1292
+ const src = readFileSync8(policyPath, "utf8");
1293
+ const compiled = compileAspYamlToCanonicalJson(src);
1294
+ if (!compiled.ok) {
1295
+ for (const m of compiled.errors) output += ` \u2717 compile: ${m}
1296
+ `;
1297
+ return { ok: false, output, passed: 0, total: 0 };
1298
+ }
1299
+ const toolCount = (compiled.body.policy.allowedTools?.length ?? 0) + (compiled.body.policy.deniedTools?.length ?? 0);
1300
+ output += ` \u2713 compiled (${toolCount} tool rule[s], issuer=${compiled.body.issuer?.pubKey ?? "(unsigned)"})
1301
+ `;
1302
+ if (!existsSync8(testsDir)) {
1303
+ output += ` \xB7 no fixtures at ${opts.testsLabel ?? testsDir} \u2014 skipping tests
1304
+ `;
1305
+ return { ok: true, output, passed: 0, total: 0 };
1306
+ }
1307
+ const results = runFixtures(testsDir);
1308
+ const failed = results.filter((r) => !r.ok);
1309
+ for (const r of failed) output += ` \u2717 ${r.file} :: ${r.name} \u2014 ${r.reason}
1310
+ `;
1311
+ const passed = results.length - failed.length;
1312
+ output += ` ${failed.length === 0 ? "\u2713" : "\u2717"} ${passed}/${results.length} passed (${Date.now() - started}ms)
1313
+ `;
1314
+ return { ok: failed.length === 0, output, passed, total: results.length };
1315
+ }
1316
+ function policyCommand() {
1317
+ const cmd = new Command8("policy").description("Compile / sign ASP YAML policies.");
1318
+ cmd.command("compile <file>").description("Compile ASP YAML \u2192 canonical JSON bundle body (unsigned).").option("--out <file>", "Write to file instead of stdout").action((file, opts) => {
1319
+ const result = compileAspYamlToCanonicalJson(readAspOrDie(file));
1320
+ if (!result.ok) {
1321
+ for (const m of result.errors) process.stderr.write(`\u2717 ${m}
1322
+ `);
1323
+ process.exit(1);
1324
+ }
1325
+ const out = JSON.stringify(result.body, null, 2) + "\n";
1326
+ if (opts.out) {
1327
+ writeFileSync4(opts.out, out);
1328
+ process.stderr.write(`\u2713 wrote ${opts.out}
1329
+ `);
1330
+ } else {
1331
+ process.stdout.write(out);
1332
+ }
1333
+ });
1334
+ cmd.command("sign <file>").description(
1335
+ "Compile + Ed25519-sign ASP YAML into a policy bundle the Aarmos runtime consumes. Reads AARMOS_POLICY_PRIVATE_KEY (base64url 32B seed)."
1336
+ ).option("--out <file>", "Write signed bundle here (default: stdout)").action((file, opts) => {
1337
+ const seed = process.env.AARMOS_POLICY_PRIVATE_KEY;
1338
+ if (!seed) {
1339
+ process.stderr.write("\u2717 AARMOS_POLICY_PRIVATE_KEY not set (base64url 32B ed25519 seed)\n");
1340
+ process.exit(1);
1341
+ }
1342
+ const result = compileAspYamlToCanonicalJson(readAspOrDie(file));
1343
+ if (!result.ok) {
1344
+ for (const m of result.errors) process.stderr.write(`\u2717 ${m}
1345
+ `);
1346
+ process.exit(1);
1347
+ }
1348
+ const signed = signBody(result.body, seed);
1349
+ const out = JSON.stringify(signed, null, 2) + "\n";
1350
+ if (opts.out) {
1351
+ writeFileSync4(opts.out, out);
1352
+ process.stderr.write(`\u2713 signed \u2192 ${opts.out}
1353
+ `);
1354
+ } else {
1355
+ process.stdout.write(out);
1356
+ }
1357
+ });
1358
+ cmd.command("keygen").description("Generate a new Ed25519 keypair for signing ASP policies.").action(() => {
1359
+ const k = keygen();
1360
+ process.stdout.write(
1361
+ `issuer.pubKey (paste into metadata.issuer.pubKey):
1362
+ ${k.pubKeyB64}
1363
+
1364
+ AARMOS_POLICY_PRIVATE_KEY (keep secret):
1365
+ ${k.seedB64u}
1366
+ `
1367
+ );
1368
+ });
1369
+ cmd.command("watch <file>").description(
1370
+ "Recompile ASP YAML on save and re-run fixtures. Zero network, zero side effects \u2014 the tightest feedback loop for authoring policy."
1371
+ ).option("--tests <dir>", "Fixtures directory to re-run on each change", "policies/tests").option("--debounce <ms>", "Debounce interval in ms", "150").action((file, opts) => {
1372
+ const policyPath = resolve(file);
1373
+ if (!existsSync8(policyPath)) {
1374
+ process.stderr.write(`\u2717 file not found: ${policyPath}
1375
+ `);
1376
+ process.exit(1);
1377
+ }
1378
+ const testsDir = resolve(opts.tests);
1379
+ const debounceMs = Math.max(0, Number(opts.debounce) || 150);
1380
+ const runOnce = () => {
1381
+ const { output } = runPolicyOnce(policyPath, testsDir, { label: file, testsLabel: opts.tests });
1382
+ process.stdout.write(output);
1383
+ };
1384
+ process.stdout.write(`aarmos policy watch
1385
+ policy : ${policyPath}
1386
+ tests : ${testsDir}
1387
+ `);
1388
+ runOnce();
1389
+ let timer = null;
1390
+ const schedule = () => {
1391
+ if (timer) clearTimeout(timer);
1392
+ timer = setTimeout(runOnce, debounceMs);
1393
+ };
1394
+ const watchTarget = (p) => {
1395
+ try {
1396
+ const st = statSync4(p);
1397
+ const opts2 = st.isDirectory() ? { recursive: true } : void 0;
1398
+ fsWatch(p, opts2, () => schedule());
1399
+ } catch {
1400
+ }
1401
+ };
1402
+ watchTarget(policyPath);
1403
+ watchTarget(dirname3(policyPath));
1404
+ if (existsSync8(testsDir)) watchTarget(testsDir);
1405
+ process.stdout.write("\nwatching for changes \u2014 Ctrl+C to exit\n");
1406
+ setInterval(() => {
1407
+ }, 1 << 30);
1408
+ });
1409
+ return cmd;
1410
+ }
1411
+
703
1412
  // src/index.ts
704
- var program = new Command6();
1413
+ var program = new Command9();
705
1414
  program.name("aarmos").description(
706
1415
  "Run your agent locally, across any protocol, with a signed receipt \u2014 in 60 seconds."
707
1416
  ).version("0.1.0");
@@ -710,6 +1419,9 @@ program.addCommand(runCommand());
710
1419
  program.addCommand(proxyCommand());
711
1420
  program.addCommand(daemonCommand());
712
1421
  program.addCommand(verifyCommand());
1422
+ program.addCommand(lintCommand());
1423
+ program.addCommand(testCommand());
1424
+ program.addCommand(policyCommand());
713
1425
  program.parseAsync(process.argv).catch((err) => {
714
1426
  console.error(err instanceof Error ? err.message : String(err));
715
1427
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarmos/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Aarmos CLI — run any agent locally, across any protocol (MCP, OpenAPI, deep-link), with a signed AVAR receipt on every execution. Sovereign runtime, universal tool gateway, verifiable governance.",
5
5
  "keywords": [
6
6
  "aarmos",
@@ -39,6 +39,7 @@
39
39
  "@aarmos/avar-core": "^1.0.0-rc.3",
40
40
  "commander": "^12.1.0",
41
41
  "fflate": "^0.8.3",
42
+ "yaml": "^2.9.0",
42
43
  "zod": "^3.23.8"
43
44
  },
44
45
  "devDependencies": {