toolstile 0.1.1 → 0.1.2

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
@@ -6,8 +6,6 @@ Check the MCP server configs your AI agents use, in one command:
6
6
  npx toolstile audit
7
7
  ```
8
8
 
9
- The same package is also published as `@toolstile/cli` (`npx @toolstile/cli audit`).
10
-
11
9
  It reads the configs of Claude Code, Claude Desktop, Cursor, Codex, VS Code,
12
10
  Windsurf, Gemini CLI and Cline and grades them for:
13
11
 
@@ -46,7 +44,9 @@ HIGH Plaintext secret in github (Cursor)
46
44
  - Makes no network requests and collects no telemetry.
47
45
  - Never prints, stores or sends secret values. Findings name the file and key,
48
46
  for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
49
- - About 1,100 lines of TypeScript with one runtime dependency
47
+ - Never quotes a config file: one that cannot be read or parsed is listed with a
48
+ reason and position only, for example `invalid TOML at line 3, column 12`.
49
+ - About 1,400 lines of TypeScript with one runtime dependency
50
50
  ([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
51
51
  config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
52
52
  to render `--card` as PNG). Read it before you run it.
@@ -66,6 +66,14 @@ HIGH Plaintext secret in github (Cursor)
66
66
  `--fail-on high` makes it usable in CI or a pre-commit hook for repositories that
67
67
  check in `.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json`.
68
68
 
69
+ Exit codes:
70
+
71
+ | Code | Meaning |
72
+ | --- | --- |
73
+ | 0 | The audit ran, and no finding reached the `--fail-on` level (or `--fail-on` was not set) |
74
+ | 1 | A finding is at or above the `--fail-on` level |
75
+ | 2 | Invalid options, or the audit itself failed |
76
+
69
77
  ## Scoring
70
78
 
71
79
  Each distinct problem has a weight: 10 for a leaked secret, plain-http remote or
@@ -117,7 +125,7 @@ Questions and reports: support@toolstile.com.
117
125
 
118
126
  ## Releases
119
127
 
120
- Both npm packages are published by [GitHub Actions](.github/workflows/publish.yml)
128
+ The npm package is published by [GitHub Actions](.github/workflows/publish.yml)
121
129
  with npm trusted publishing and provenance, so each release links back to the
122
130
  commit and workflow that built it. No npm token is stored anywhere.
123
131
 
package/dist/checks.d.ts CHANGED
@@ -4,6 +4,9 @@ interface Package {
4
4
  registry: "npm" | "pypi" | "docker";
5
5
  name: string;
6
6
  version: string | null;
7
+ /** Index of the package in the unwrapped words. */
8
+ wordIndex: number;
9
+ /** Index of the package in `args`, or -1 when it is inside `command`. */
7
10
  argIndex: number;
8
11
  }
9
12
  /** Finds the package a launcher such as npx, uvx or docker runs. */
package/dist/checks.js CHANGED
@@ -41,20 +41,67 @@ const LAUNCHERS = {
41
41
  pnpx: "npm",
42
42
  uvx: "pypi",
43
43
  };
44
- /** Splits `command` and `args` into words, handling configs that put the whole command in one string. */
44
+ const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
45
+ const split = (text) => text.trim().split(/\s+/).filter(Boolean);
46
+ /** WSL options that take a value. */
47
+ const WSL_VALUED = new Set(["-d", "--distribution", "--distribution-id", "-u", "--user", "--cd", "--shell-type"]);
48
+ /** Index of the first word after a `cmd /c` or `wsl` wrapper at `start`, or `start` when there is none. */
49
+ function afterWrapper(list, start) {
50
+ const name = base(list[start] ?? "").toLowerCase();
51
+ let j = start + 1;
52
+ if (name === "cmd") {
53
+ // cmd [/d] [/s] [/q] … /c|/k command
54
+ while (j < list.length && /^\/[a-z]$/i.test(list[j]) && !/^\/[ck]$/i.test(list[j]))
55
+ j++;
56
+ return /^\/[ck]$/i.test(list[j] ?? "") ? j + 1 : start;
57
+ }
58
+ if (name === "wsl") {
59
+ while (j < list.length) {
60
+ const a = list[j];
61
+ if (WSL_VALUED.has(a))
62
+ j += 2;
63
+ else if (a === "-e" || a === "--exec" || a === "--")
64
+ return j + 1;
65
+ else if (a.startsWith("-"))
66
+ j++;
67
+ else
68
+ break;
69
+ }
70
+ return j;
71
+ }
72
+ return start;
73
+ }
74
+ /**
75
+ * Splits `command` and `args` into words, handling configs that put the whole command in one string,
76
+ * and skips Windows wrappers (`cmd /c npx …`, `wsl npx …`) so the real launcher comes first.
77
+ */
45
78
  function words(s) {
46
79
  if (!s.command)
47
- return [];
48
- const command = s.args.length === 0 && /\s/.test(s.command.trim()) ? s.command.trim().split(/\s+/) : [s.command];
49
- return [...command, ...s.args];
80
+ return { list: [], arg: [] };
81
+ const oneString = s.args.length === 0 && /\s/.test(s.command.trim());
82
+ let list = oneString ? split(s.command) : [s.command, ...s.args];
83
+ let arg = oneString ? list.map(() => -1) : list.map((_, i) => i - 1);
84
+ for (;;) {
85
+ const start = afterWrapper(list, 0);
86
+ if (start === 0 || start >= list.length)
87
+ break;
88
+ list = list.slice(start);
89
+ arg = arg.slice(start);
90
+ // `cmd /c "npx -y pkg"` passes the inner command as one argument.
91
+ if (list.length === 1 && /\s/.test(list[0].trim())) {
92
+ const from = arg[0];
93
+ list = split(list[0]);
94
+ arg = list.map(() => from);
95
+ }
96
+ }
97
+ return { list, arg };
50
98
  }
51
- const base = (p) => win32.basename(posix.basename(p)).replace(/\.(cmd|exe)$/i, "");
52
99
  /** Finds the package a launcher such as npx, uvx or docker runs. */
53
100
  export function packageOf(s) {
54
- const w = words(s);
101
+ const { list: w, arg } = words(s);
55
102
  if (w.length === 0)
56
103
  return null;
57
- const cmd = base(w[0]);
104
+ const cmd = base(w[0]).toLowerCase();
58
105
  const sub = w.slice(1);
59
106
  let registry = LAUNCHERS[cmd];
60
107
  let skip = 0;
@@ -74,6 +121,7 @@ export function packageOf(s) {
74
121
  return null;
75
122
  const rest = sub.slice(skip);
76
123
  const offset = 1 + skip;
124
+ const at = (spec, i) => parsePackage(registry, spec, i, arg[i] ?? -1);
77
125
  // Flags that take a value; their value is not the package.
78
126
  const valued = new Set(["-p", "--package", "--from", "--with", "-e", "--env", "-v", "--volume", "--name", "--network", "--mount", "--env-file", "-w", "--workdir", "--entrypoint", "-u", "--user", "--python"]);
79
127
  for (let i = 0; i < rest.length; i++) {
@@ -83,41 +131,42 @@ export function packageOf(s) {
83
131
  if (a.startsWith("-")) {
84
132
  if (valued.has(a)) {
85
133
  if ((a === "--package" || a === "-p" || a === "--from") && registry !== "docker") {
86
- return parsePackage(registry, rest[i + 1] ?? "", offset + i + 1);
134
+ return at(rest[i + 1] ?? "", offset + i + 1);
87
135
  }
88
136
  i++;
89
137
  }
90
138
  continue;
91
139
  }
92
- return parsePackage(registry, a, offset + i);
140
+ return at(a, offset + i);
93
141
  }
94
142
  return null;
95
143
  }
96
- function parsePackage(registry, spec, argIndex) {
144
+ function parsePackage(registry, spec, wordIndex, argIndex) {
97
145
  if (!spec)
98
146
  return null;
147
+ const where = { wordIndex, argIndex };
99
148
  if (registry === "docker") {
100
149
  const at = spec.indexOf("@sha256:");
101
150
  if (at > 0)
102
- return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), argIndex };
151
+ return { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where };
103
152
  const slash = spec.lastIndexOf("/");
104
153
  const colon = spec.lastIndexOf(":");
105
154
  return colon > slash
106
- ? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), argIndex }
107
- : { registry, name: spec, version: null, argIndex };
155
+ ? { registry, name: spec.slice(0, colon), version: spec.slice(colon + 1), ...where }
156
+ : { registry, name: spec, version: null, ...where };
108
157
  }
109
158
  if (registry === "pypi") {
110
159
  const m = /^([A-Za-z0-9._\-[\],]+?)(?:(==|@|>=|~=)(.+))?$/.exec(spec);
111
160
  if (!m)
112
161
  return null;
113
- return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, argIndex };
162
+ return { registry, name: m[1], version: m[2] === "==" || m[2] === "@" ? m[3] : null, ...where };
114
163
  }
115
164
  if (/^(https?:|git\+|file:|\.|\/)/.test(spec))
116
165
  return null;
117
166
  const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
118
167
  return at > 0
119
- ? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), argIndex }
120
- : { registry, name: spec, version: null, argIndex };
168
+ ? { registry, name: spec.slice(0, at), version: spec.slice(at + 1), ...where }
169
+ : { registry, name: spec, version: null, ...where };
121
170
  }
122
171
  const isPinned = (p) => p.version !== null &&
123
172
  p.version !== "latest" &&
@@ -138,7 +187,7 @@ export function identityOf(s) {
138
187
  const pkg = packageOf(s);
139
188
  if (pkg)
140
189
  return `${pkg.registry}:${pkg.name.toLowerCase()}`;
141
- const w = words(s);
190
+ const w = words(s).list;
142
191
  return w.length ? `cmd:${w.join(" ")}` : null;
143
192
  }
144
193
  const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)$/i;
@@ -149,7 +198,7 @@ function broadRoots(s, home) {
149
198
  return [];
150
199
  const homes = new Set([home, home.replace(/\/+$/, ""), "~", "~/", "$HOME", "${HOME}", "%USERPROFILE%"]);
151
200
  return words(s)
152
- .slice(pkg.argIndex + 1)
201
+ .list.slice(pkg.wordIndex + 1)
153
202
  .filter((a) => a === "/" || /^[A-Za-z]:\\?$/.test(a) || homes.has(a) || homes.has(a.replace(/\/+$/, "")));
154
203
  }
155
204
  const SEVERITY_ORDER = { high: 0, medium: 1, low: 2, info: 3 };
@@ -250,11 +299,11 @@ export function runChecks(d, home) {
250
299
  fix: pkg.registry === "docker"
251
300
  ? `Pin an image digest or exact tag, for example ${pkg.name}:<version>.`
252
301
  : `Pin an exact version, for example ${pkg.name}${pkg.registry === "pypi" ? "==" : "@"}<version>.`,
253
- locations: [at(s, "args", String(Math.max(0, pkg.argIndex - 1)))],
302
+ locations: [pkg.argIndex >= 0 ? at(s, "args", String(pkg.argIndex)) : at(s, "command")],
254
303
  weight: 4,
255
304
  });
256
305
  }
257
- const w = words(s);
306
+ const w = words(s).list;
258
307
  if (pkg?.registry === "docker") {
259
308
  const risky = w.filter((a) => a === "--privileged" || /\/var\/run\/docker\.sock/.test(a) || a === "--net=host" || a === "--network=host");
260
309
  if (risky.length) {
package/dist/parse.d.ts CHANGED
@@ -1,3 +1,17 @@
1
- /** Parses JSON that may contain comments and trailing commas (VS Code style). */
1
+ /**
2
+ * A config file that could not be parsed. The message is fixed text plus a position: parser
3
+ * messages quote the offending source line, which can hold a secret, so they are never kept.
4
+ */
5
+ export declare class ConfigParseError extends Error {
6
+ readonly format: "JSON" | "TOML";
7
+ readonly line?: number | undefined;
8
+ readonly column?: number | undefined;
9
+ constructor(format: "JSON" | "TOML", line?: number | undefined, column?: number | undefined);
10
+ }
11
+ /**
12
+ * Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
13
+ * trailing commas become spaces so parser positions still match the original text.
14
+ */
2
15
  export declare function parseJsonc(text: string): unknown;
16
+ /** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
3
17
  export declare function parseConfig(path: string, text: string): unknown;
package/dist/parse.js CHANGED
@@ -1,5 +1,24 @@
1
- import { parse as parseToml } from "smol-toml";
2
- /** Parses JSON that may contain comments and trailing commas (VS Code style). */
1
+ import { parse as parseToml, TomlError } from "smol-toml";
2
+ /**
3
+ * A config file that could not be parsed. The message is fixed text plus a position: parser
4
+ * messages quote the offending source line, which can hold a secret, so they are never kept.
5
+ */
6
+ export class ConfigParseError extends Error {
7
+ format;
8
+ line;
9
+ column;
10
+ constructor(format, line, column) {
11
+ super(`invalid ${format}${line ? ` at line ${line}${column ? `, column ${column}` : ""}` : ""}`);
12
+ this.format = format;
13
+ this.line = line;
14
+ this.column = column;
15
+ this.name = "ConfigParseError";
16
+ }
17
+ }
18
+ /**
19
+ * Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
20
+ * trailing commas become spaces so parser positions still match the original text.
21
+ */
3
22
  export function parseJsonc(text) {
4
23
  let out = "";
5
24
  let inString = false;
@@ -22,15 +41,19 @@ export function parseJsonc(text) {
22
41
  out += ch;
23
42
  }
24
43
  else if (ch === "/" && next === "/") {
25
- while (i < text.length && text[i] !== "\n")
44
+ while (i < text.length && text[i] !== "\n") {
45
+ out += blank(text[i]);
26
46
  i++;
27
- out += "\n";
47
+ }
48
+ if (i < text.length)
49
+ out += "\n";
28
50
  }
29
51
  else if (ch === "/" && next === "*") {
30
- i += 2;
31
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
32
- i++;
33
- i++;
52
+ const end = text.indexOf("*/", i + 2);
53
+ const stop = end === -1 ? text.length : end + 2;
54
+ for (; i < stop; i++)
55
+ out += blank(text[i]);
56
+ i--;
34
57
  }
35
58
  else {
36
59
  out += ch;
@@ -38,6 +61,8 @@ export function parseJsonc(text) {
38
61
  }
39
62
  return JSON.parse(stripTrailingCommas(out));
40
63
  }
64
+ /** Keeps line breaks so line numbers survive; everything else becomes a space. */
65
+ const blank = (ch) => (ch === "\n" || ch === "\r" ? ch : " ");
41
66
  function stripTrailingCommas(text) {
42
67
  let out = "";
43
68
  let inString = false;
@@ -60,17 +85,51 @@ function stripTrailingCommas(text) {
60
85
  let j = i + 1;
61
86
  while (j < text.length && /\s/.test(text[j]))
62
87
  j++;
63
- if (text[j] === "}" || text[j] === "]")
88
+ if (text[j] === "}" || text[j] === "]") {
89
+ out += " ";
64
90
  continue;
91
+ }
65
92
  }
66
93
  out += ch;
67
94
  }
68
95
  return out;
69
96
  }
97
+ /** 1-based line and column of a character offset. */
98
+ function lineColumn(text, offset) {
99
+ const before = text.slice(0, Math.min(Math.max(offset, 0), text.length));
100
+ const lastBreak = before.lastIndexOf("\n");
101
+ return { line: before.split("\n").length, column: offset - lastBreak };
102
+ }
103
+ /** Reads only the position from a V8 JSON error; the rest of its message may quote the file. */
104
+ function jsonPosition(error, text) {
105
+ const message = error instanceof Error ? error.message : "";
106
+ const at = /\bposition (\d+)/.exec(message);
107
+ if (at)
108
+ return lineColumn(text, Number(at[1]));
109
+ if (/Unexpected end of JSON input/.test(message))
110
+ return lineColumn(text, text.length);
111
+ return {};
112
+ }
113
+ /** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
70
114
  export function parseConfig(path, text) {
71
- if (path.endsWith(".toml"))
72
- return parseToml(text);
73
- if (text.trim() === "")
115
+ const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
116
+ if (path.endsWith(".toml")) {
117
+ try {
118
+ return parseToml(body);
119
+ }
120
+ catch (error) {
121
+ if (error instanceof TomlError)
122
+ throw new ConfigParseError("TOML", error.line, error.column);
123
+ throw new ConfigParseError("TOML");
124
+ }
125
+ }
126
+ if (body.trim() === "")
74
127
  return {};
75
- return parseJsonc(text);
128
+ try {
129
+ return parseJsonc(body);
130
+ }
131
+ catch (error) {
132
+ const { line, column } = jsonPosition(error, body);
133
+ throw new ConfigParseError("JSON", line, column);
134
+ }
76
135
  }
package/dist/report.js CHANGED
@@ -60,7 +60,7 @@ export function renderReport(r, home, s, redacted = false) {
60
60
  const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
61
61
  lines.push(`Scanned ${plural(r.files.length, "file")} from ${clientList}: ${plural(r.servers, "MCP server")}.`);
62
62
  for (const u of r.unreadable) {
63
- lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): could not parse it.`));
63
+ lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): ${u.error}.`));
64
64
  }
65
65
  lines.push("");
66
66
  if (r.grade !== null) {
package/dist/secrets.js CHANGED
@@ -28,9 +28,9 @@ const PATTERNS = [
28
28
  [/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, "JWT"],
29
29
  [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, "password in connection string"],
30
30
  ];
31
- const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_)/i;
32
- /** Key suffixes that name configuration about a secret rather than the secret itself. */
33
- const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var)$/i;
31
+ const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_|(^|[_-])key$)/i;
32
+ /** Key suffixes that name configuration about a secret, or a public half, rather than the secret itself. */
33
+ const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var|public[_-]?key)$/i;
34
34
  const PLACEHOLDER = /^(<.*>|\{.*\}|\[.*\]|x{4,}|\*{3,}|\.{3,}|your[-_ ].*|change[-_ ]?me|todo|placeholder|example|redacted|none|null|undefined|true|false|test|secret|token)$/i;
35
35
  /** True for `${VAR}`, `${env:VAR}`, `$VAR`, `%VAR%` and `{env:VAR}` references. */
36
36
  export function isReference(value) {
package/dist/sources.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { parseConfig } from "./parse.js";
3
+ import { ConfigParseError, parseConfig } from "./parse.js";
4
4
  export const CLIENT_NAMES = {
5
5
  "claude-code": "Claude Code",
6
6
  "claude-desktop": "Claude Desktop",
@@ -149,6 +149,14 @@ function ancestorModes(path) {
149
149
  dir = parent;
150
150
  }
151
151
  }
152
+ /** A fixed description of a read failure; system messages are not passed through. */
153
+ function readError(code) {
154
+ if (code === "EACCES" || code === "EPERM")
155
+ return "permission denied";
156
+ if (code === "EISDIR")
157
+ return "is a directory";
158
+ return code && /^E[A-Z]+$/.test(code) ? `could not read it (${code})` : "could not read it";
159
+ }
152
160
  export function discover(opts) {
153
161
  const result = { files: [], unreadable: [], servers: [], approvals: [] };
154
162
  for (const c of candidates(opts)) {
@@ -161,7 +169,12 @@ export function discover(opts) {
161
169
  mode = opts.platform === "win32" ? undefined : stat.mode & 0o777;
162
170
  text = readFileSync(c.path, "utf8");
163
171
  }
164
- catch {
172
+ catch (error) {
173
+ const code = error.code;
174
+ // Absent files are normal; anything else means a config exists that we could not check.
175
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
176
+ result.unreadable.push({ client: c.client, path: c.path, error: readError(code) });
177
+ }
165
178
  continue;
166
179
  }
167
180
  let data;
@@ -169,7 +182,9 @@ export function discover(opts) {
169
182
  data = parseConfig(c.path, text);
170
183
  }
171
184
  catch (error) {
172
- result.unreadable.push({ client: c.client, path: c.path, error: error.message });
185
+ // parseConfig's messages are fixed text plus a position, never file contents.
186
+ const message = error instanceof ConfigParseError ? error.message : "could not parse it";
187
+ result.unreadable.push({ client: c.client, path: c.path, error: message });
173
188
  continue;
174
189
  }
175
190
  const exposure = mode === undefined ? undefined : exposureOf(mode, ancestorModes(c.path));
package/dist/types.d.ts CHANGED
@@ -55,7 +55,10 @@ export interface AuditResult {
55
55
  client: ClientId;
56
56
  path: string;
57
57
  }[];
58
- /** Files that exist but could not be parsed. */
58
+ /**
59
+ * Files that exist but could not be read or parsed. `error` is a fixed description with at most
60
+ * a line and column, for example `invalid TOML at line 3, column 12`; it never quotes the file.
61
+ */
59
62
  unreadable: {
60
63
  client: ClientId;
61
64
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolstile",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups. Local and read-only.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -37,8 +37,8 @@
37
37
  "scripts": {
38
38
  "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
39
39
  "typecheck": "tsc --noEmit",
40
- "test": "vitest run",
41
- "prepublishOnly": "pnpm typecheck && pnpm build && pnpm test"
40
+ "test": "pnpm build && vitest run",
41
+ "prepublishOnly": "pnpm typecheck && pnpm test"
42
42
  },
43
43
  "dependencies": {
44
44
  "smol-toml": "^1.9.1"