@polygraphso/litmus 0.8.1 → 0.9.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.
package/README.md CHANGED
@@ -151,6 +151,58 @@ machine.
151
151
  - **`verify_attestation` says `lookup_failed`:** the grade index or RPC was
152
152
  unreachable — that's *unknown*, not *no grade*. Retry; check `POLYGRAPH_API_URL`.
153
153
 
154
+ ## Grade a skill
155
+
156
+ Claude Code / Agent **Skills** (a `SKILL.md` plus an optional bundle) are graded by a
157
+ separate static litmus (`litmus-skill-v1`). It scans the skill's bytes — **S-01**
158
+ prompt injection in the body, **S-03** data-exfiltration instructions, **S-04**
159
+ dangerous commands in bundled executable scripts — and content-hashes the whole
160
+ directory. The letter is **A/B/D/F**.
161
+
162
+ This is a **static** scan: it does not execute the skill or its scripts, so an `A`
163
+ means the static checks were clean, not that the skill is behaviorally safe. A
164
+ command the skill builds or fetches at runtime is not visible to it.
165
+
166
+ ### CLI
167
+
168
+ ```bash
169
+ polygraphso-litmus-skill <path-to-skill-dir> # grade a local skill folder (must contain SKILL.md)
170
+ polygraphso-litmus-skill --json <path-to-skill-dir> # machine-readable safety + quality bundles
171
+ ```
172
+
173
+ It also prints a separate, advisory **quality** signal (`well-formed` / `issues` /
174
+ `malformed`) — never an A–F letter, never minted. Its deterministic checks
175
+ (frontmatter + bundled-link resolution) always run; the optional LLM-judged axes
176
+ (honesty, coherence) run only when a judge is available:
177
+
178
+ - **Inside an agent** (the MCP tool below): the host agent's own model judges via MCP
179
+ sampling — no key, any provider.
180
+ - **Standalone:** bring your own key for any OpenAI-compatible endpoint:
181
+
182
+ ```bash
183
+ export LITMUS_LLM_API_KEY=… # your key (any OpenAI-compatible endpoint)
184
+ export LITMUS_LLM_MODEL=gpt-4o # a model the endpoint serves
185
+ export LITMUS_LLM_BASE_URL=https://api.openai.com/v1 # optional; defaults to OpenAI
186
+ # Other providers via their OpenAI-compatible endpoint, e.g.:
187
+ # Claude: LITMUS_LLM_BASE_URL=https://api.anthropic.com/v1 LITMUS_LLM_MODEL=claude-sonnet-4-6
188
+ # Gemini: LITMUS_LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai LITMUS_LLM_MODEL=gemini-2.5-flash
189
+ ```
190
+
191
+ - With neither, the judged axes are skipped — the grade and deterministic quality
192
+ still run. The core never needs a key.
193
+
194
+ ### From an AI agent (MCP)
195
+
196
+ The same `polygraphso-litmus-mcp` server exposes two skill tools (plus `grade-skill` /
197
+ `check-skill` prompts):
198
+
199
+ - **`run_skill_litmus`** — grade a local skill directory now (static; uses the host
200
+ model via sampling for the quality axes, no key).
201
+ - **`verify_skill_attestation`** — read a skill's *already-published* grade by its
202
+ `skill_ref` (`source/owner/repo#path`, e.g. `github/anthropics/skills#skills/pdf`). It
203
+ returns the attested `contentHash`; recompute the skill's hash and require equality
204
+ before installing — the content hash, not the version, is the trust anchor.
205
+
154
206
  ## Library
155
207
 
156
208
  ```ts
@@ -158,6 +210,12 @@ import { runLitmus, gateDecision, liveFingerprint, readAttestation } from "@poly
158
210
 
159
211
  const bundle = await runLitmus("npm/@modelcontextprotocol/server-filesystem");
160
212
  console.log(bundle.grade, bundle.gradeRationale);
213
+
214
+ // Skills: static safety grade + a separate advisory quality bundle.
215
+ import { runSkillLitmus, runSkillQuality } from "@polygraphso/litmus";
216
+
217
+ const skill = runSkillLitmus("./skills/my-skill");
218
+ console.log(skill.grade, skill.contentHash);
161
219
  ```
162
220
 
163
221
  ## License
@@ -82,6 +82,69 @@ function serverKey(parts) {
82
82
  return parts.owner ? `${parts.registry}/${parts.owner}/${parts.name}` : `${parts.registry}/${parts.name}`;
83
83
  }
84
84
 
85
+ // ../core/src/skill-identity.ts
86
+ var SOURCES = /* @__PURE__ */ new Set(["github", "marketplace", "npm"]);
87
+ var OWNER_RE2 = /^@?[A-Za-z0-9][A-Za-z0-9._-]*$/;
88
+ var NAME_RE2 = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
89
+ var REF_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
90
+ var PATH_SEG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
91
+ var SkillRefParseError = class extends Error {
92
+ constructor(ref, reason) {
93
+ super(`Invalid skill ref "${ref}": ${reason}`);
94
+ this.name = "SkillRefParseError";
95
+ }
96
+ };
97
+ function parseSkillRef(ref) {
98
+ const firstSlash = ref.indexOf("/");
99
+ if (firstSlash === -1) throw new SkillRefParseError(ref, "expected `{source}/...`");
100
+ const source = ref.slice(0, firstSlash);
101
+ if (!SOURCES.has(source)) {
102
+ throw new SkillRefParseError(ref, `unknown source "${source}" (expected one of: ${[...SOURCES].join(", ")})`);
103
+ }
104
+ let rest = ref.slice(firstSlash + 1);
105
+ let pin = null;
106
+ const at = rest.lastIndexOf("@");
107
+ if (at > 0) {
108
+ pin = rest.slice(at + 1);
109
+ rest = rest.slice(0, at);
110
+ if (!pin) throw new SkillRefParseError(ref, "empty ref after `@`");
111
+ if (!REF_RE.test(pin)) throw new SkillRefParseError(ref, "ref contains disallowed characters");
112
+ }
113
+ let path = null;
114
+ const hash = rest.indexOf("#");
115
+ if (hash >= 0) {
116
+ path = rest.slice(hash + 1);
117
+ rest = rest.slice(0, hash);
118
+ if (!path) throw new SkillRefParseError(ref, "empty path after `#`");
119
+ for (const seg of path.split("/")) {
120
+ if (!PATH_SEG_RE.test(seg)) throw new SkillRefParseError(ref, "path contains disallowed characters");
121
+ }
122
+ }
123
+ const lastSlash = rest.lastIndexOf("/");
124
+ let owner;
125
+ let name;
126
+ if (lastSlash === -1) {
127
+ owner = null;
128
+ name = rest;
129
+ } else {
130
+ owner = rest.slice(0, lastSlash);
131
+ name = rest.slice(lastSlash + 1);
132
+ }
133
+ if (!name) throw new SkillRefParseError(ref, "empty name segment");
134
+ if (owner !== null && !OWNER_RE2.test(owner)) throw new SkillRefParseError(ref, "owner contains disallowed characters");
135
+ if (!NAME_RE2.test(name)) throw new SkillRefParseError(ref, "name contains disallowed characters");
136
+ return { source, owner, name, path, ref: pin };
137
+ }
138
+ function formatSkillRef(p) {
139
+ let base = p.owner ? `${p.source}/${p.owner}/${p.name}` : `${p.source}/${p.name}`;
140
+ if (p.path) base += `#${p.path}`;
141
+ return p.ref ? `${base}@${p.ref}` : base;
142
+ }
143
+ function skillKey(p) {
144
+ const base = p.owner ? `${p.source}/${p.owner}/${p.name}` : `${p.source}/${p.name}`;
145
+ return p.path ? `${base}#${p.path}` : base;
146
+ }
147
+
85
148
  // ../core/src/canonical.ts
86
149
  function canonicalStringify(value) {
87
150
  return JSON.stringify(sortDeep(value));
@@ -116,5 +179,9 @@ export {
116
179
  parseServerRef,
117
180
  formatServerRef,
118
181
  serverKey,
182
+ SkillRefParseError,
183
+ parseSkillRef,
184
+ formatSkillRef,
185
+ skillKey,
119
186
  canonicalStringify
120
187
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  canonicalStringify
3
- } from "./chunk-ZR6XRGMQ.js";
3
+ } from "./chunk-44R4ZYOE.js";
4
4
 
5
5
  // ../cli/src/litmus.ts
6
6
  import { existsSync } from "fs";
@@ -44,7 +44,7 @@ async function runLitmusCli(args) {
44
44
  );
45
45
  return 2;
46
46
  }
47
- const { runLitmus } = await import("./src-RSTPCEYU.js");
47
+ const { runLitmus } = await import("./src-TMJOIVGB.js");
48
48
  const input = resolveTarget(target);
49
49
  try {
50
50
  const bundle = await runLitmus(input, { headers, allowStateChanging });