toolstile 0.1.1 → 0.2.0
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 +68 -6
- package/dist/agents.d.ts +43 -0
- package/dist/agents.js +192 -0
- package/dist/api.d.ts +64 -0
- package/dist/api.js +61 -0
- package/dist/checks.d.ts +3 -0
- package/dist/checks.js +71 -22
- package/dist/cli.js +137 -6
- package/dist/commands.d.ts +37 -0
- package/dist/commands.js +338 -0
- package/dist/parse.d.ts +15 -1
- package/dist/parse.js +72 -13
- package/dist/prompt.d.ts +10 -0
- package/dist/prompt.js +54 -0
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +235 -0
- package/dist/report.js +1 -1
- package/dist/secrets.js +3 -3
- package/dist/sources.d.ts +1 -0
- package/dist/sources.js +19 -4
- package/dist/state.d.ts +48 -0
- package/dist/state.js +51 -0
- package/dist/types.d.ts +4 -1
- package/package.json +7 -5
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
|
|
|
@@ -40,13 +38,16 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
40
38
|
Fix: Reference an environment variable instead (for example ${API_TOKEN}) …
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
##
|
|
41
|
+
## The audit only reads
|
|
44
42
|
|
|
45
43
|
- Reads config files on your machine. Writes nothing unless you ask for a card.
|
|
46
|
-
- Makes no network requests and collects no telemetry.
|
|
44
|
+
- Makes no network requests and collects no telemetry. (Only the separate
|
|
45
|
+
`auth` and `mcp` commands below talk to Toolstile, and only when you run them.)
|
|
47
46
|
- Never prints, stores or sends secret values. Findings name the file and key,
|
|
48
47
|
for example `mcpServers.github.env.GITHUB_TOKEN`, and the kind of secret.
|
|
49
|
-
-
|
|
48
|
+
- Never quotes a config file: one that cannot be read or parsed is listed with a
|
|
49
|
+
reason and position only, for example `invalid TOML at line 3, column 12`.
|
|
50
|
+
- About 2,500 lines of TypeScript with one runtime dependency
|
|
50
51
|
([smol-toml](https://github.com/squirrelchat/smol-toml), for Codex's TOML
|
|
51
52
|
config) and an optional one ([resvg-js](https://github.com/thx/resvg-js), only
|
|
52
53
|
to render `--card` as PNG). Read it before you run it.
|
|
@@ -66,6 +67,14 @@ HIGH Plaintext secret in github (Cursor)
|
|
|
66
67
|
`--fail-on high` makes it usable in CI or a pre-commit hook for repositories that
|
|
67
68
|
check in `.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json`.
|
|
68
69
|
|
|
70
|
+
Exit codes:
|
|
71
|
+
|
|
72
|
+
| Code | Meaning |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| 0 | The audit ran, and no finding reached the `--fail-on` level (or `--fail-on` was not set) |
|
|
75
|
+
| 1 | A finding is at or above the `--fail-on` level |
|
|
76
|
+
| 2 | Invalid options, or the audit itself failed |
|
|
77
|
+
|
|
69
78
|
## Scoring
|
|
70
79
|
|
|
71
80
|
Each distinct problem has a weight: 10 for a leaked secret, plain-http remote or
|
|
@@ -98,6 +107,59 @@ values) and prints a ready-to-post link. To post the text report instead, add
|
|
|
98
107
|
| Gemini CLI | `~/.gemini/settings.json` | `.gemini/settings.json` |
|
|
99
108
|
| Cline | `cline_mcp_settings.json` in VS Code or Cursor storage | |
|
|
100
109
|
|
|
110
|
+
## Connect your agents to Toolstile
|
|
111
|
+
|
|
112
|
+
If you use [Toolstile](https://toolstile.com), the CLI can also sign in and set
|
|
113
|
+
up the agents on a machine for you:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
npx toolstile auth login
|
|
117
|
+
npx toolstile mcp setup
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`auth login` prints a link and a code. Open the link on any device with a
|
|
121
|
+
browser (your laptop or phone), sign in and approve the code. Nothing has to
|
|
122
|
+
reach the machine you are signing in, so it works over SSH, in containers and on
|
|
123
|
+
servers without a browser.
|
|
124
|
+
|
|
125
|
+
`mcp setup` lists the agents it finds (Claude Code, Claude Desktop, Cursor,
|
|
126
|
+
Codex, VS Code, Windsurf, Gemini CLI, Cline) and lets you turn each one on or
|
|
127
|
+
off. Run it again any time to change the selection. Each enabled agent appears
|
|
128
|
+
under this machine in the Toolstile dashboard with its own access, so you can
|
|
129
|
+
grant, limit or revoke it there; then choose which MCP servers it can use.
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
Which agents on this machine should use Toolstile?
|
|
133
|
+
› ◉ Claude Code ~/.claude.json
|
|
134
|
+
○ Cursor ~/.cursor/mcp.json
|
|
135
|
+
◉ Codex ~/.codex/config.toml
|
|
136
|
+
↑/↓ move · space toggle · a all · enter save · esc cancel
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Without a terminal, pass the choice as flags:
|
|
140
|
+
`npx toolstile mcp setup --enable claude-code,codex --disable cursor`.
|
|
141
|
+
|
|
142
|
+
How it stays out of your configs: an enabled agent's config gets a `toolstile`
|
|
143
|
+
server that runs `npx -y toolstile@<version> mcp proxy --agent <agent>`. The proxy
|
|
144
|
+
relays the agent's MCP messages to Toolstile using that agent's own token, which
|
|
145
|
+
lives only in the CLI's credentials file (`~/.config/toolstile/credentials.json`,
|
|
146
|
+
readable by you alone; `%APPDATA%\toolstile` on Windows). No token is written
|
|
147
|
+
into the agent's config, so `toolstile audit` has nothing to flag. JSON configs
|
|
148
|
+
with comments are not rewritten; setup prints the entry to add yourself.
|
|
149
|
+
|
|
150
|
+
`npx toolstile auth logout` ends the CLI's sign-in. Agents you enabled keep
|
|
151
|
+
working until you turn them off with `mcp setup` or delete the device in the
|
|
152
|
+
dashboard.
|
|
153
|
+
|
|
154
|
+
| Option | Command | Meaning |
|
|
155
|
+
| --- | --- | --- |
|
|
156
|
+
| `--server <url>` | `auth login` | Toolstile address (default `https://toolstile.com`, or `TOOLSTILE_URL`) |
|
|
157
|
+
| `--no-browser` | `auth login` | Do not try to open a browser on this machine |
|
|
158
|
+
| `--enable <agents>` | `mcp setup` | Turn these on without prompting (comma-separated) |
|
|
159
|
+
| `--disable <agents>` | `mcp setup` | Turn these off without prompting |
|
|
160
|
+
| `--workspace <slug>` | `mcp setup` | Workspace the agents use (default: the last one, or personal) |
|
|
161
|
+
| `--device-name <name>` | `mcp setup` | Name for this machine in the dashboard (default: its hostname) |
|
|
162
|
+
|
|
101
163
|
## Use it from code
|
|
102
164
|
|
|
103
165
|
```ts
|
|
@@ -117,7 +179,7 @@ Questions and reports: support@toolstile.com.
|
|
|
117
179
|
|
|
118
180
|
## Releases
|
|
119
181
|
|
|
120
|
-
|
|
182
|
+
The npm package is published by [GitHub Actions](.github/workflows/publish.yml)
|
|
121
183
|
with npm trusted publishing and provenance, so each release links back to the
|
|
122
184
|
commit and workflow that built it. No npm token is stored anywhere.
|
|
123
185
|
|
package/dist/agents.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ClientId } from "./types.js";
|
|
2
|
+
/** The server name `toolstile mcp setup` adds to each agent's config. */
|
|
3
|
+
export declare const ENTRY = "toolstile";
|
|
4
|
+
type Format = "mcpServers" | "vscode" | "codex";
|
|
5
|
+
export interface Target {
|
|
6
|
+
client: ClientId;
|
|
7
|
+
name: string;
|
|
8
|
+
/** User-level config file that gets the entry. */
|
|
9
|
+
path: string;
|
|
10
|
+
format: Format;
|
|
11
|
+
/** Whether the app looks installed: its config file or folder exists. */
|
|
12
|
+
detected: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface TargetEnv {
|
|
15
|
+
home: string;
|
|
16
|
+
platform: NodeJS.Platform;
|
|
17
|
+
env: Record<string, string | undefined>;
|
|
18
|
+
}
|
|
19
|
+
export declare function targets(opts: TargetEnv): Target[];
|
|
20
|
+
/** How the agent starts the proxy. Pinned, so the config runs the version that wrote it. */
|
|
21
|
+
export declare function proxyCommand(client: ClientId, version: string, platform: NodeJS.Platform): {
|
|
22
|
+
command: string;
|
|
23
|
+
args: string[];
|
|
24
|
+
};
|
|
25
|
+
/** A config entry for a different, hand-written `toolstile` server is left alone. */
|
|
26
|
+
export declare function isProxyEntry(value: unknown): boolean;
|
|
27
|
+
export declare class ManualEditError extends Error {
|
|
28
|
+
readonly target: Target;
|
|
29
|
+
readonly snippet: string;
|
|
30
|
+
constructor(target: Target, reason: string, snippet: string);
|
|
31
|
+
}
|
|
32
|
+
/** The current entry, if the agent's config has one. */
|
|
33
|
+
export declare function readEntry(target: Target): unknown;
|
|
34
|
+
/**
|
|
35
|
+
* Adds (or, with `command` null, removes) the `toolstile` entry. JSON files with comments are
|
|
36
|
+
* not rewritten, since that would drop the comments; the caller shows the snippet instead.
|
|
37
|
+
*/
|
|
38
|
+
export type WriteResult = "added" | "updated" | "replaced" | "removed" | "unchanged";
|
|
39
|
+
export declare function writeEntry(target: Target, command: {
|
|
40
|
+
command: string;
|
|
41
|
+
args: string[];
|
|
42
|
+
} | null): WriteResult;
|
|
43
|
+
export {};
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { parse as parseToml } from "smol-toml";
|
|
4
|
+
import { parseJsonc } from "./parse.js";
|
|
5
|
+
import { appData, CLIENT_NAMES } from "./sources.js";
|
|
6
|
+
/** The server name `toolstile mcp setup` adds to each agent's config. */
|
|
7
|
+
export const ENTRY = "toolstile";
|
|
8
|
+
export function targets(opts) {
|
|
9
|
+
const { home, env } = opts;
|
|
10
|
+
const data = appData(opts);
|
|
11
|
+
const codexHome = env.CODEX_HOME ?? join(home, ".codex");
|
|
12
|
+
const code = ["Code", "Code - Insiders"].map((edition) => join(data, edition, "User")).find((dir) => existsSync(dir)) ??
|
|
13
|
+
join(data, "Code", "User");
|
|
14
|
+
const cline = join(code, "globalStorage", "saoudrizwan.claude-dev");
|
|
15
|
+
const list = [
|
|
16
|
+
["claude-code", join(home, ".claude.json"), "mcpServers", join(home, ".claude")],
|
|
17
|
+
["claude-desktop", join(data, "Claude", "claude_desktop_config.json"), "mcpServers", join(data, "Claude")],
|
|
18
|
+
["cursor", join(home, ".cursor", "mcp.json"), "mcpServers", join(home, ".cursor")],
|
|
19
|
+
["codex", join(codexHome, "config.toml"), "codex", codexHome],
|
|
20
|
+
["vscode", join(code, "mcp.json"), "vscode", code],
|
|
21
|
+
["windsurf", join(home, ".codeium", "windsurf", "mcp_config.json"), "mcpServers", join(home, ".codeium", "windsurf")],
|
|
22
|
+
["gemini", join(home, ".gemini", "settings.json"), "mcpServers", join(home, ".gemini")],
|
|
23
|
+
["cline", join(cline, "settings", "cline_mcp_settings.json"), "mcpServers", cline],
|
|
24
|
+
];
|
|
25
|
+
return list.map(([client, path, format, folder]) => ({
|
|
26
|
+
client,
|
|
27
|
+
name: CLIENT_NAMES[client],
|
|
28
|
+
path,
|
|
29
|
+
format,
|
|
30
|
+
detected: existsSync(path) || existsSync(folder),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/** How the agent starts the proxy. Pinned, so the config runs the version that wrote it. */
|
|
34
|
+
export function proxyCommand(client, version, platform) {
|
|
35
|
+
const args = ["-y", `toolstile@${version}`, "mcp", "proxy", "--agent", client];
|
|
36
|
+
// Windows apps start commands without a shell, and npx is a .cmd script there.
|
|
37
|
+
return platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...args] } : { command: "npx", args };
|
|
38
|
+
}
|
|
39
|
+
/** A config entry for a different, hand-written `toolstile` server is left alone. */
|
|
40
|
+
export function isProxyEntry(value) {
|
|
41
|
+
if (typeof value !== "object" || value === null)
|
|
42
|
+
return false;
|
|
43
|
+
const args = value.args;
|
|
44
|
+
return Array.isArray(args) && args.some((a) => typeof a === "string" && /^toolstile(@|$)/.test(a)) &&
|
|
45
|
+
args.includes("proxy");
|
|
46
|
+
}
|
|
47
|
+
export class ManualEditError extends Error {
|
|
48
|
+
target;
|
|
49
|
+
snippet;
|
|
50
|
+
constructor(target, reason, snippet) {
|
|
51
|
+
super(reason);
|
|
52
|
+
this.target = target;
|
|
53
|
+
this.snippet = snippet;
|
|
54
|
+
this.name = "ManualEditError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function readText(path) {
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(path, "utf8");
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code === "ENOENT")
|
|
63
|
+
return null;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Replaces the file in one step and keeps its permission bits; a symlink keeps pointing at it. */
|
|
68
|
+
function writeText(link, text) {
|
|
69
|
+
let path = link;
|
|
70
|
+
let mode = 0o600;
|
|
71
|
+
try {
|
|
72
|
+
path = realpathSync(link);
|
|
73
|
+
mode = statSync(path).mode & 0o777;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
const temp = `${path}.toolstile-${process.pid}.tmp`;
|
|
79
|
+
try {
|
|
80
|
+
writeFileSync(temp, text, { mode });
|
|
81
|
+
chmodSync(temp, mode);
|
|
82
|
+
renameSync(temp, path);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(temp, { force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const serversKey = (format) => (format === "vscode" ? "servers" : "mcpServers");
|
|
89
|
+
function jsonEntry(target, command) {
|
|
90
|
+
return target.format === "vscode" || target.client === "claude-code" ? { type: "stdio", ...command } : command;
|
|
91
|
+
}
|
|
92
|
+
function tomlEntry(command) {
|
|
93
|
+
return `[mcp_servers.${ENTRY}]\ncommand = ${JSON.stringify(command.command)}\nargs = [${command.args.map((a) => JSON.stringify(a)).join(", ")}]\n`;
|
|
94
|
+
}
|
|
95
|
+
/** Drops `[mcp_servers.toolstile]` and its sub-tables, keeping every other line and comment. */
|
|
96
|
+
function withoutTomlEntry(text) {
|
|
97
|
+
const lines = text.split("\n");
|
|
98
|
+
const out = [];
|
|
99
|
+
let skipping = false;
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
const header = line.match(/^\s*\[\s*([^\]]+?)\s*\]\s*(#.*)?$/)?.[1];
|
|
102
|
+
if (header !== undefined) {
|
|
103
|
+
const name = header.replace(/\s+/g, "").replace(/"/g, "");
|
|
104
|
+
skipping = name === `mcp_servers.${ENTRY}` || name.startsWith(`mcp_servers.${ENTRY}.`);
|
|
105
|
+
}
|
|
106
|
+
if (!skipping)
|
|
107
|
+
out.push(line);
|
|
108
|
+
}
|
|
109
|
+
return out.join("\n").replace(/\n{3,}$/, "\n\n");
|
|
110
|
+
}
|
|
111
|
+
/** The current entry, if the agent's config has one. */
|
|
112
|
+
export function readEntry(target) {
|
|
113
|
+
const text = readText(target.path);
|
|
114
|
+
if (!text?.trim())
|
|
115
|
+
return undefined;
|
|
116
|
+
try {
|
|
117
|
+
if (target.format === "codex") {
|
|
118
|
+
const servers = parseToml(text).mcp_servers;
|
|
119
|
+
return servers?.[ENTRY];
|
|
120
|
+
}
|
|
121
|
+
const data = parseJsonc(text);
|
|
122
|
+
return data?.[serversKey(target.format)]?.[ENTRY];
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** `replaced` means a hand-written `toolstile` server (not the proxy) was swapped out. */
|
|
129
|
+
const outcome = (command, existing) => !command ? "removed" : existing === undefined ? "added" : isProxyEntry(existing) ? "updated" : "replaced";
|
|
130
|
+
export function writeEntry(target, command) {
|
|
131
|
+
const text = readText(target.path) ?? "";
|
|
132
|
+
if (target.format === "codex") {
|
|
133
|
+
let current;
|
|
134
|
+
try {
|
|
135
|
+
current = parseToml(text).mcp_servers;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new ManualEditError(target, "its TOML could not be parsed", command ? tomlEntry(command) : "");
|
|
139
|
+
}
|
|
140
|
+
const existing = current?.[ENTRY];
|
|
141
|
+
if (!command && (existing === undefined || !isProxyEntry(existing)))
|
|
142
|
+
return "unchanged";
|
|
143
|
+
let next = withoutTomlEntry(text);
|
|
144
|
+
if (command)
|
|
145
|
+
next = `${next.replace(/\s*$/, "")}${next.trim() ? "\n\n" : ""}${tomlEntry(command)}`;
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = parseToml(next).mcp_servers?.[ENTRY];
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
parsed = null;
|
|
152
|
+
}
|
|
153
|
+
if (command ? !isProxyEntry(parsed) : parsed !== undefined)
|
|
154
|
+
throw new ManualEditError(target, "its toolstile server is written in a form setup cannot edit", command ? tomlEntry(command) : "");
|
|
155
|
+
if (next === text)
|
|
156
|
+
return "unchanged";
|
|
157
|
+
writeText(target.path, next);
|
|
158
|
+
return outcome(command, existing);
|
|
159
|
+
}
|
|
160
|
+
const key = serversKey(target.format);
|
|
161
|
+
const entry = command ? jsonEntry(target, command) : undefined;
|
|
162
|
+
const snippet = entry ? JSON.stringify({ [key]: { [ENTRY]: entry } }, null, 2) : "";
|
|
163
|
+
let data;
|
|
164
|
+
try {
|
|
165
|
+
data = text.trim() ? parseJsonc(text) : {};
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
throw new ManualEditError(target, "its JSON could not be parsed", snippet);
|
|
169
|
+
}
|
|
170
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
171
|
+
throw new ManualEditError(target, "it is not a JSON object", snippet);
|
|
172
|
+
const object = data;
|
|
173
|
+
const servers = (typeof object[key] === "object" && object[key] !== null ? object[key] : {});
|
|
174
|
+
const existing = servers[ENTRY];
|
|
175
|
+
if (entry ? JSON.stringify(existing) === JSON.stringify(entry) : existing === undefined || !isProxyEntry(existing))
|
|
176
|
+
return "unchanged";
|
|
177
|
+
// Rewriting would drop comments and trailing commas, so such files are left to the user.
|
|
178
|
+
try {
|
|
179
|
+
JSON.parse(text || "{}");
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw new ManualEditError(target, "it contains comments", snippet);
|
|
183
|
+
}
|
|
184
|
+
if (entry)
|
|
185
|
+
servers[ENTRY] = entry;
|
|
186
|
+
else
|
|
187
|
+
delete servers[ENTRY];
|
|
188
|
+
object[key] = servers;
|
|
189
|
+
const indent = text.match(/\n([ \t]+)"/)?.[1] ?? " ";
|
|
190
|
+
writeText(target.path, `${JSON.stringify(object, null, indent)}\n`);
|
|
191
|
+
return outcome(entry, existing);
|
|
192
|
+
}
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export declare const CLIENT_ID = "toolstile-cli";
|
|
2
|
+
export declare class ApiError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code?: string | undefined;
|
|
5
|
+
constructor(status: number, message: string, code?: string | undefined);
|
|
6
|
+
}
|
|
7
|
+
export interface Workspace {
|
|
8
|
+
id: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
name: string;
|
|
11
|
+
personal: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface Account {
|
|
14
|
+
email: string;
|
|
15
|
+
name: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
workspaces: Workspace[];
|
|
18
|
+
}
|
|
19
|
+
export interface DeviceCode {
|
|
20
|
+
device_code: string;
|
|
21
|
+
user_code: string;
|
|
22
|
+
verification_uri: string;
|
|
23
|
+
verification_uri_complete: string;
|
|
24
|
+
expires_in: number;
|
|
25
|
+
interval: number;
|
|
26
|
+
}
|
|
27
|
+
type Fetch = typeof fetch;
|
|
28
|
+
/** The Toolstile endpoints the CLI uses. Session calls carry the device-flow session token. */
|
|
29
|
+
export declare function createApi(server: string, fetcher?: Fetch): {
|
|
30
|
+
deviceCode: () => Promise<DeviceCode>;
|
|
31
|
+
deviceToken: (deviceCode: string) => Promise<{
|
|
32
|
+
access_token: string;
|
|
33
|
+
expires_in: number;
|
|
34
|
+
}>;
|
|
35
|
+
account: (token: string) => Promise<Account>;
|
|
36
|
+
signOut: (token: string) => Promise<void>;
|
|
37
|
+
device: (token: string, input: {
|
|
38
|
+
workspaceId: string;
|
|
39
|
+
id?: string;
|
|
40
|
+
label: string;
|
|
41
|
+
rename?: boolean;
|
|
42
|
+
}) => Promise<{
|
|
43
|
+
id: string;
|
|
44
|
+
label: string;
|
|
45
|
+
}>;
|
|
46
|
+
agents: (token: string, workspaceId: string, deviceId: string) => Promise<{
|
|
47
|
+
agents: {
|
|
48
|
+
id: string;
|
|
49
|
+
label: string;
|
|
50
|
+
}[];
|
|
51
|
+
}>;
|
|
52
|
+
addAgent: (token: string, input: {
|
|
53
|
+
workspaceId: string;
|
|
54
|
+
deviceId: string;
|
|
55
|
+
label: string;
|
|
56
|
+
}) => Promise<{
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
token: string;
|
|
60
|
+
}>;
|
|
61
|
+
removeAgent: (token: string, workspaceId: string, id: string) => Promise<void>;
|
|
62
|
+
};
|
|
63
|
+
export type Api = ReturnType<typeof createApi>;
|
|
64
|
+
export {};
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export const CLIENT_ID = "toolstile-cli";
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(status, message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "ApiError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function request(fetcher, url, init = {}) {
|
|
13
|
+
let response;
|
|
14
|
+
try {
|
|
15
|
+
response = await fetcher(url, {
|
|
16
|
+
...init,
|
|
17
|
+
// Never follow a redirect with a session token to another address.
|
|
18
|
+
redirect: "error",
|
|
19
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new ApiError(0, `Could not reach ${new URL(url).origin}. Check your connection.`);
|
|
24
|
+
}
|
|
25
|
+
if (response.status === 204)
|
|
26
|
+
return undefined;
|
|
27
|
+
const body = (await response.json().catch(() => null));
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const message = body?.error_description ?? body?.message;
|
|
30
|
+
throw new ApiError(response.status, typeof message === "string" ? message : `Request failed (${response.status})`, typeof body?.error === "string" ? body.error : undefined);
|
|
31
|
+
}
|
|
32
|
+
return body;
|
|
33
|
+
}
|
|
34
|
+
/** The Toolstile endpoints the CLI uses. Session calls carry the device-flow session token. */
|
|
35
|
+
export function createApi(server, fetcher = fetch) {
|
|
36
|
+
const base = server.replace(/\/+$/, "");
|
|
37
|
+
const session = (token, init = {}) => ({
|
|
38
|
+
...init,
|
|
39
|
+
headers: { ...init.headers, Authorization: `Bearer ${token}` },
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
deviceCode: () => request(fetcher, `${base}/api/auth/device/code`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
body: JSON.stringify({ client_id: CLIENT_ID }),
|
|
45
|
+
}),
|
|
46
|
+
deviceToken: (deviceCode) => request(fetcher, `${base}/api/auth/device/token`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
50
|
+
device_code: deviceCode,
|
|
51
|
+
client_id: CLIENT_ID,
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
account: (token) => request(fetcher, `${base}/api/cli/account`, session(token)),
|
|
55
|
+
signOut: (token) => request(fetcher, `${base}/api/cli/sign-out`, session(token, { method: "POST" })),
|
|
56
|
+
device: (token, input) => request(fetcher, `${base}/api/cli/device`, session(token, { method: "POST", body: JSON.stringify(input) })),
|
|
57
|
+
agents: (token, workspaceId, deviceId) => request(fetcher, `${base}/api/cli/agents?${new URLSearchParams({ workspaceId, deviceId })}`, session(token)),
|
|
58
|
+
addAgent: (token, input) => request(fetcher, `${base}/api/cli/agents`, session(token, { method: "POST", body: JSON.stringify(input) })),
|
|
59
|
+
removeAgent: (token, workspaceId, id) => request(fetcher, `${base}/api/cli/agents/${encodeURIComponent(id)}?${new URLSearchParams({ workspaceId })}`, session(token, { method: "DELETE" })),
|
|
60
|
+
};
|
|
61
|
+
}
|
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
|
-
|
|
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
|
|
49
|
-
|
|
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
|
|
134
|
+
return at(rest[i + 1] ?? "", offset + i + 1);
|
|
87
135
|
}
|
|
88
136
|
i++;
|
|
89
137
|
}
|
|
90
138
|
continue;
|
|
91
139
|
}
|
|
92
|
-
return
|
|
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),
|
|
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),
|
|
107
|
-
: { registry, name: spec, version: null,
|
|
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,
|
|
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),
|
|
120
|
-
: { registry, name: spec, version: null,
|
|
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,12 +198,12 @@ 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.
|
|
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 };
|
|
156
|
-
/** Toolstile's own gateway is meant to be added to every client. */
|
|
157
|
-
const TOOLSTILE = /^url:([a-z0-9-]+\.)*toolstile\.com
|
|
205
|
+
/** Toolstile's own gateway, directly or through `toolstile mcp proxy`, is meant to be added to every client. */
|
|
206
|
+
const TOOLSTILE = /^(url:([a-z0-9-]+\.)*toolstile\.com\/|npm:toolstile$)/i;
|
|
158
207
|
/** A path for a shell command: `~/...` under the home folder, single-quoted when needed. */
|
|
159
208
|
export function shellPath(path, home) {
|
|
160
209
|
const quote = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
|
|
@@ -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(
|
|
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) {
|