@oh-my-tool/cli 0.2.0 → 0.3.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 +7 -2
- package/assets/skills/oh-my-tool/SKILL.md +11 -3
- package/bin/ohmytool.cjs +0 -0
- package/package.json +20 -11
- package/src/cli/commands/connections.ts +98 -0
- package/src/cli/commands/describe.ts +23 -22
- package/src/cli/commands/extension.ts +24 -24
- package/src/cli/commands/index.ts +8 -6
- package/src/cli/commands/integrate.ts +64 -64
- package/src/cli/commands/mcp.ts +86 -0
- package/src/cli/commands/run.ts +14 -8
- package/src/cli/commands/search.ts +14 -13
- package/src/cli/commands/secret.ts +68 -68
- package/src/cli/context.ts +37 -4
- package/src/cli/index.ts +338 -273
- package/src/cli/output.ts +165 -0
- package/src/cli/parseArgs.ts +64 -44
- package/src/config/config.ts +207 -63
- package/src/core/executor.ts +89 -89
- package/src/core/registry.ts +31 -31
- package/src/core/result.ts +14 -14
- package/src/extension/discovery.ts +61 -61
- package/src/extension/install.ts +23 -23
- package/src/extension/loader.ts +32 -32
- package/src/extension/manifest.ts +114 -114
- package/src/integration/adapters.ts +98 -98
- package/src/integration/index.ts +4 -4
- package/src/integration/manager.ts +375 -375
- package/src/integration/skill.ts +84 -84
- package/src/integration/types.ts +55 -55
- package/src/policy/policy.ts +136 -136
- package/src/runtime/errors.ts +7 -2
- package/src/runtime/executor.ts +6 -1
- package/src/runtime/provider.ts +2 -1
- package/src/runtime/providers/mcp/normalize.ts +36 -0
- package/src/runtime/providers/mcp/oauth-callback.ts +91 -0
- package/src/runtime/providers/mcp/oauth-provider.ts +348 -0
- package/src/runtime/providers/mcp/oauth-store.ts +106 -0
- package/src/runtime/providers/mcp/provider.ts +99 -0
- package/src/runtime/providers/mcp/safe-errors.ts +63 -0
- package/src/runtime/providers/mcp/session.ts +117 -0
- package/src/runtime/providers/mcp/transport.ts +140 -0
- package/src/runtime/providers/native/provider.ts +1 -1
- package/src/runtime/result.ts +1 -1
- package/src/runtime/runtime.ts +38 -12
- package/src/runtime/schema.ts +14 -4
- package/src/search/search.ts +78 -78
- package/src/secrets/secrets.ts +45 -45
- package/src/version.ts +1 -1
package/src/integration/skill.ts
CHANGED
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
import {
|
|
2
|
-
cpSync,
|
|
3
|
-
existsSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
readdirSync,
|
|
7
|
-
renameSync,
|
|
8
|
-
statSync,
|
|
9
|
-
} from "node:fs";
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
11
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
|
-
|
|
14
|
-
export interface SkillMetadata {
|
|
15
|
-
name: string;
|
|
16
|
-
description: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function bundledSkillPath(): string {
|
|
20
|
-
return fileURLToPath(new URL("../../assets/skills/oh-my-tool", import.meta.url));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function validateSkill(directory: string): SkillMetadata {
|
|
24
|
-
const skillFile = join(directory, "SKILL.md");
|
|
25
|
-
if (!existsSync(skillFile)) throw new Error(`Skill is missing SKILL.md: ${directory}`);
|
|
26
|
-
const content = readFileSync(skillFile, "utf8");
|
|
27
|
-
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
28
|
-
if (!frontmatter) throw new Error("Skill SKILL.md is missing YAML frontmatter");
|
|
29
|
-
const name = frontmatter[1].match(/^name:\s*(.+?)\s*$/m)?.[1];
|
|
30
|
-
const description = frontmatter[1].match(/^description:\s*(.+?)\s*$/m)?.[1];
|
|
31
|
-
if (!name || !description) throw new Error("Skill frontmatter requires name and description");
|
|
32
|
-
if (name !== "oh-my-tool") throw new Error(`Unexpected bundled skill name: ${name}`);
|
|
33
|
-
return { name, description };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function filesUnder(directory: string): string[] {
|
|
37
|
-
const files: string[] = [];
|
|
38
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
39
|
-
const path = join(directory, entry.name);
|
|
40
|
-
if (entry.isDirectory()) files.push(...filesUnder(path));
|
|
41
|
-
else if (entry.isFile()) files.push(path);
|
|
42
|
-
}
|
|
43
|
-
return files.sort((a, b) => a.localeCompare(b));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function skillDigest(directory: string): string {
|
|
47
|
-
const hash = createHash("sha256");
|
|
48
|
-
for (const file of filesUnder(directory)) {
|
|
49
|
-
hash.update(relative(directory, file).replaceAll("\\", "/"));
|
|
50
|
-
hash.update("\0");
|
|
51
|
-
hash.update(readFileSync(file));
|
|
52
|
-
hash.update("\0");
|
|
53
|
-
}
|
|
54
|
-
return `sha256:${hash.digest("hex")}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function stageCanonicalSkill(
|
|
58
|
-
omtHome: string,
|
|
59
|
-
source: string,
|
|
60
|
-
version: string,
|
|
61
|
-
): { path: string; digest: string } {
|
|
62
|
-
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
63
|
-
throw new Error(`Invalid skill version: ${version}`);
|
|
64
|
-
}
|
|
65
|
-
validateSkill(source);
|
|
66
|
-
const digest = skillDigest(source);
|
|
67
|
-
const canonicalRoot = resolve(omtHome, "integrations", "skills", "oh-my-tool");
|
|
68
|
-
const target = resolve(canonicalRoot, version);
|
|
69
|
-
if (dirname(target) !== canonicalRoot) throw new Error(`Invalid skill version path: ${version}`);
|
|
70
|
-
if (existsSync(target)) {
|
|
71
|
-
validateSkill(target);
|
|
72
|
-
if (skillDigest(target) !== digest) {
|
|
73
|
-
throw new Error(`Immutable skill version ${version} already exists with different content`);
|
|
74
|
-
}
|
|
75
|
-
return { path: target, digest };
|
|
76
|
-
}
|
|
77
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
78
|
-
const staging = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
79
|
-
if (existsSync(staging)) throw new Error(`Skill staging path already exists: ${staging}`);
|
|
80
|
-
cpSync(source, staging, { recursive: true, errorOnExist: true });
|
|
81
|
-
validateSkill(staging);
|
|
82
|
-
if (statSync(staging).isDirectory()) renameSync(staging, target);
|
|
83
|
-
return { path: target, digest };
|
|
84
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
export interface SkillMetadata {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function bundledSkillPath(): string {
|
|
20
|
+
return fileURLToPath(new URL("../../assets/skills/oh-my-tool", import.meta.url));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateSkill(directory: string): SkillMetadata {
|
|
24
|
+
const skillFile = join(directory, "SKILL.md");
|
|
25
|
+
if (!existsSync(skillFile)) throw new Error(`Skill is missing SKILL.md: ${directory}`);
|
|
26
|
+
const content = readFileSync(skillFile, "utf8");
|
|
27
|
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
28
|
+
if (!frontmatter) throw new Error("Skill SKILL.md is missing YAML frontmatter");
|
|
29
|
+
const name = frontmatter[1].match(/^name:\s*(.+?)\s*$/m)?.[1];
|
|
30
|
+
const description = frontmatter[1].match(/^description:\s*(.+?)\s*$/m)?.[1];
|
|
31
|
+
if (!name || !description) throw new Error("Skill frontmatter requires name and description");
|
|
32
|
+
if (name !== "oh-my-tool") throw new Error(`Unexpected bundled skill name: ${name}`);
|
|
33
|
+
return { name, description };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function filesUnder(directory: string): string[] {
|
|
37
|
+
const files: string[] = [];
|
|
38
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
39
|
+
const path = join(directory, entry.name);
|
|
40
|
+
if (entry.isDirectory()) files.push(...filesUnder(path));
|
|
41
|
+
else if (entry.isFile()) files.push(path);
|
|
42
|
+
}
|
|
43
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function skillDigest(directory: string): string {
|
|
47
|
+
const hash = createHash("sha256");
|
|
48
|
+
for (const file of filesUnder(directory)) {
|
|
49
|
+
hash.update(relative(directory, file).replaceAll("\\", "/"));
|
|
50
|
+
hash.update("\0");
|
|
51
|
+
hash.update(readFileSync(file));
|
|
52
|
+
hash.update("\0");
|
|
53
|
+
}
|
|
54
|
+
return `sha256:${hash.digest("hex")}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function stageCanonicalSkill(
|
|
58
|
+
omtHome: string,
|
|
59
|
+
source: string,
|
|
60
|
+
version: string,
|
|
61
|
+
): { path: string; digest: string } {
|
|
62
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
63
|
+
throw new Error(`Invalid skill version: ${version}`);
|
|
64
|
+
}
|
|
65
|
+
validateSkill(source);
|
|
66
|
+
const digest = skillDigest(source);
|
|
67
|
+
const canonicalRoot = resolve(omtHome, "integrations", "skills", "oh-my-tool");
|
|
68
|
+
const target = resolve(canonicalRoot, version);
|
|
69
|
+
if (dirname(target) !== canonicalRoot) throw new Error(`Invalid skill version path: ${version}`);
|
|
70
|
+
if (existsSync(target)) {
|
|
71
|
+
validateSkill(target);
|
|
72
|
+
if (skillDigest(target) !== digest) {
|
|
73
|
+
throw new Error(`Immutable skill version ${version} already exists with different content`);
|
|
74
|
+
}
|
|
75
|
+
return { path: target, digest };
|
|
76
|
+
}
|
|
77
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
78
|
+
const staging = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
79
|
+
if (existsSync(staging)) throw new Error(`Skill staging path already exists: ${staging}`);
|
|
80
|
+
cpSync(source, staging, { recursive: true, errorOnExist: true });
|
|
81
|
+
validateSkill(staging);
|
|
82
|
+
if (statSync(staging).isDirectory()) renameSync(staging, target);
|
|
83
|
+
return { path: target, digest };
|
|
84
|
+
}
|
package/src/integration/types.ts
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
export type AgentId = "codex" | "omp" | "qoder" | "pi" | "cursor" | "claude";
|
|
2
|
-
|
|
3
|
-
export const AGENT_IDS: readonly AgentId[] = [
|
|
4
|
-
"codex",
|
|
5
|
-
"omp",
|
|
6
|
-
"qoder",
|
|
7
|
-
"pi",
|
|
8
|
-
"cursor",
|
|
9
|
-
"claude",
|
|
10
|
-
];
|
|
11
|
-
export type IntegrationStatus =
|
|
12
|
-
| "not-installed"
|
|
13
|
-
| "installed"
|
|
14
|
-
| "current"
|
|
15
|
-
| "update-available"
|
|
16
|
-
| "broken"
|
|
17
|
-
| "conflict"
|
|
18
|
-
| "repaired"
|
|
19
|
-
| "uninstalled";
|
|
20
|
-
|
|
21
|
-
export interface AgentDetection {
|
|
22
|
-
id: AgentId;
|
|
23
|
-
displayName: string;
|
|
24
|
-
command: string;
|
|
25
|
-
target: string;
|
|
26
|
-
variant?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface IntegrationResult {
|
|
30
|
-
agent: AgentId;
|
|
31
|
-
displayName: string;
|
|
32
|
-
target: string;
|
|
33
|
-
status: IntegrationStatus;
|
|
34
|
-
detail?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface ManagedAgentState {
|
|
38
|
-
target: string;
|
|
39
|
-
canonical: string;
|
|
40
|
-
version: string;
|
|
41
|
-
digest: string;
|
|
42
|
-
mode: "junction" | "symlink";
|
|
43
|
-
backup?: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface IntegrationState {
|
|
47
|
-
schemaVersion: 1;
|
|
48
|
-
skills: {
|
|
49
|
-
"oh-my-tool"?: {
|
|
50
|
-
version: string;
|
|
51
|
-
digest: string;
|
|
52
|
-
agents: Partial<Record<AgentId, ManagedAgentState>>;
|
|
53
|
-
};
|
|
54
|
-
};
|
|
55
|
-
}
|
|
1
|
+
export type AgentId = "codex" | "omp" | "qoder" | "pi" | "cursor" | "claude";
|
|
2
|
+
|
|
3
|
+
export const AGENT_IDS: readonly AgentId[] = [
|
|
4
|
+
"codex",
|
|
5
|
+
"omp",
|
|
6
|
+
"qoder",
|
|
7
|
+
"pi",
|
|
8
|
+
"cursor",
|
|
9
|
+
"claude",
|
|
10
|
+
];
|
|
11
|
+
export type IntegrationStatus =
|
|
12
|
+
| "not-installed"
|
|
13
|
+
| "installed"
|
|
14
|
+
| "current"
|
|
15
|
+
| "update-available"
|
|
16
|
+
| "broken"
|
|
17
|
+
| "conflict"
|
|
18
|
+
| "repaired"
|
|
19
|
+
| "uninstalled";
|
|
20
|
+
|
|
21
|
+
export interface AgentDetection {
|
|
22
|
+
id: AgentId;
|
|
23
|
+
displayName: string;
|
|
24
|
+
command: string;
|
|
25
|
+
target: string;
|
|
26
|
+
variant?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IntegrationResult {
|
|
30
|
+
agent: AgentId;
|
|
31
|
+
displayName: string;
|
|
32
|
+
target: string;
|
|
33
|
+
status: IntegrationStatus;
|
|
34
|
+
detail?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManagedAgentState {
|
|
38
|
+
target: string;
|
|
39
|
+
canonical: string;
|
|
40
|
+
version: string;
|
|
41
|
+
digest: string;
|
|
42
|
+
mode: "junction" | "symlink";
|
|
43
|
+
backup?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IntegrationState {
|
|
47
|
+
schemaVersion: 1;
|
|
48
|
+
skills: {
|
|
49
|
+
"oh-my-tool"?: {
|
|
50
|
+
version: string;
|
|
51
|
+
digest: string;
|
|
52
|
+
agents: Partial<Record<AgentId, ManagedAgentState>>;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/policy/policy.ts
CHANGED
|
@@ -1,139 +1,139 @@
|
|
|
1
|
-
import type { Config } from "../config/config";
|
|
2
|
-
import { getConnectionConfig } from "../config/config";
|
|
3
|
-
|
|
1
|
+
import type { Config } from "../config/config";
|
|
2
|
+
import { getConnectionConfig } from "../config/config";
|
|
3
|
+
|
|
4
4
|
export class PolicyError extends Error {
|
|
5
5
|
readonly code = "POLICY_VIOLATION";
|
|
6
6
|
}
|
|
7
|
-
|
|
8
|
-
const FORBIDDEN = /(?:^|[^a-z_])(insert|update|delete|drop|alter|create|truncate|rename|grant|revoke|replace|call|set|commit|rollback|load\s+data|lock\s+tables|unlock\s+tables|create\s+database|drop\s+database)(?:[^a-z_]|$)/i;
|
|
9
|
-
|
|
10
|
-
function stripLiteralsAndComments(sql: string): string {
|
|
11
|
-
let out = "";
|
|
12
|
-
let i = 0;
|
|
13
|
-
const n = sql.length;
|
|
14
|
-
while (i < n) {
|
|
15
|
-
const ch = sql[i];
|
|
16
|
-
const next = sql[i + 1];
|
|
17
|
-
// line comments
|
|
18
|
-
if (ch === "-" && next === "-") {
|
|
19
|
-
while (i < n && sql[i] !== "\n") i++;
|
|
20
|
-
continue;
|
|
21
|
-
}
|
|
22
|
-
if (ch === "#") {
|
|
23
|
-
while (i < n && sql[i] !== "\n") i++;
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
// block comments
|
|
27
|
-
if (ch === "/" && next === "*") {
|
|
28
|
-
i += 2;
|
|
29
|
-
while (i < n && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
|
|
30
|
-
i += 2;
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
// string literals
|
|
34
|
-
if (ch === "'" || ch === '"') {
|
|
35
|
-
const quote = ch;
|
|
36
|
-
i++;
|
|
37
|
-
while (i < n) {
|
|
38
|
-
if (sql[i] === "\\") {
|
|
39
|
-
i += 2;
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (sql[i] === quote) {
|
|
43
|
-
if (sql[i + 1] === quote) {
|
|
44
|
-
i += 2;
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
i++;
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
i++;
|
|
51
|
-
}
|
|
52
|
-
out += " ";
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
out += ch;
|
|
56
|
-
i++;
|
|
57
|
-
}
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function countStatements(sql: string): number {
|
|
62
|
-
const cleaned = stripLiteralsAndComments(sql);
|
|
63
|
-
const parts = cleaned
|
|
64
|
-
.split(";")
|
|
65
|
-
.map((p) => p.trim())
|
|
66
|
-
.filter((p) => p.length > 0);
|
|
67
|
-
return parts.length;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function assertReadOnly(sql: string): void {
|
|
71
|
-
if (typeof sql !== "string" || sql.trim().length === 0) {
|
|
72
|
-
throw new PolicyError("sql must be a non-empty string");
|
|
73
|
-
}
|
|
74
|
-
if (countStatements(sql) > 1) {
|
|
75
|
-
throw new PolicyError("only a single read-only statement is allowed");
|
|
76
|
-
}
|
|
77
|
-
const cleaned = stripLiteralsAndComments(sql);
|
|
78
|
-
if (FORBIDDEN.test(cleaned)) {
|
|
79
|
-
throw new PolicyError("sql contains a non read-only statement");
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const FORBIDDEN_INPUT = new Set([
|
|
84
|
-
"host",
|
|
85
|
-
"username",
|
|
86
|
-
"password",
|
|
87
|
-
"port",
|
|
88
|
-
"database",
|
|
89
|
-
"secret",
|
|
90
|
-
"tls",
|
|
91
|
-
]);
|
|
92
|
-
|
|
93
|
-
export function validateConnectionInput(
|
|
94
|
-
input: Record<string, unknown>,
|
|
95
|
-
config: Config,
|
|
96
|
-
extensionId: string,
|
|
97
|
-
): void {
|
|
98
|
-
for (const key of FORBIDDEN_INPUT) {
|
|
99
|
-
if (key in input && input[key] !== undefined && input[key] !== null) {
|
|
100
|
-
throw new PolicyError(`agent input must not contain '${key}'`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const connection = input["connection"];
|
|
104
|
-
if (typeof connection !== "string" || connection.length === 0) {
|
|
105
|
-
throw new PolicyError("input must specify a configured 'connection' name");
|
|
106
|
-
}
|
|
107
|
-
if (!getConnectionConfig(config, extensionId, connection)) {
|
|
108
|
-
throw new PolicyError(`unknown connection '${connection}' for extension '${extensionId}'`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export interface Limits {
|
|
113
|
-
maxRows: number;
|
|
114
|
-
timeoutMs: number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export const DEFAULT_MAX_ROWS = 100;
|
|
118
|
-
export const MAX_MAX_ROWS = 1000;
|
|
119
|
-
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
120
|
-
export const MAX_TIMEOUT_MS = 30000;
|
|
121
|
-
|
|
122
|
-
export function applyLimits(input: Record<string, unknown>): Limits {
|
|
123
|
-
const maxRows = clamp(
|
|
124
|
-
typeof input.maxRows === "number" ? input.maxRows : DEFAULT_MAX_ROWS,
|
|
125
|
-
1,
|
|
126
|
-
MAX_MAX_ROWS,
|
|
127
|
-
);
|
|
128
|
-
const timeoutMs = clamp(
|
|
129
|
-
typeof input.timeoutMs === "number" ? input.timeoutMs : DEFAULT_TIMEOUT_MS,
|
|
130
|
-
1,
|
|
131
|
-
MAX_TIMEOUT_MS,
|
|
132
|
-
);
|
|
133
|
-
return { maxRows, timeoutMs };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function clamp(value: number, min: number, max: number): number {
|
|
137
|
-
return Math.min(Math.max(value, min), max);
|
|
138
|
-
}
|
|
139
|
-
|
|
7
|
+
|
|
8
|
+
const FORBIDDEN = /(?:^|[^a-z_])(insert|update|delete|drop|alter|create|truncate|rename|grant|revoke|replace|call|set|commit|rollback|load\s+data|lock\s+tables|unlock\s+tables|create\s+database|drop\s+database)(?:[^a-z_]|$)/i;
|
|
9
|
+
|
|
10
|
+
function stripLiteralsAndComments(sql: string): string {
|
|
11
|
+
let out = "";
|
|
12
|
+
let i = 0;
|
|
13
|
+
const n = sql.length;
|
|
14
|
+
while (i < n) {
|
|
15
|
+
const ch = sql[i];
|
|
16
|
+
const next = sql[i + 1];
|
|
17
|
+
// line comments
|
|
18
|
+
if (ch === "-" && next === "-") {
|
|
19
|
+
while (i < n && sql[i] !== "\n") i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (ch === "#") {
|
|
23
|
+
while (i < n && sql[i] !== "\n") i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// block comments
|
|
27
|
+
if (ch === "/" && next === "*") {
|
|
28
|
+
i += 2;
|
|
29
|
+
while (i < n && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
|
|
30
|
+
i += 2;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// string literals
|
|
34
|
+
if (ch === "'" || ch === '"') {
|
|
35
|
+
const quote = ch;
|
|
36
|
+
i++;
|
|
37
|
+
while (i < n) {
|
|
38
|
+
if (sql[i] === "\\") {
|
|
39
|
+
i += 2;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (sql[i] === quote) {
|
|
43
|
+
if (sql[i + 1] === quote) {
|
|
44
|
+
i += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
i++;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
out += " ";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out += ch;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function countStatements(sql: string): number {
|
|
62
|
+
const cleaned = stripLiteralsAndComments(sql);
|
|
63
|
+
const parts = cleaned
|
|
64
|
+
.split(";")
|
|
65
|
+
.map((p) => p.trim())
|
|
66
|
+
.filter((p) => p.length > 0);
|
|
67
|
+
return parts.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function assertReadOnly(sql: string): void {
|
|
71
|
+
if (typeof sql !== "string" || sql.trim().length === 0) {
|
|
72
|
+
throw new PolicyError("sql must be a non-empty string");
|
|
73
|
+
}
|
|
74
|
+
if (countStatements(sql) > 1) {
|
|
75
|
+
throw new PolicyError("only a single read-only statement is allowed");
|
|
76
|
+
}
|
|
77
|
+
const cleaned = stripLiteralsAndComments(sql);
|
|
78
|
+
if (!/^(select|with|show|explain|describe|desc)\b/i.test(cleaned.trim()) || FORBIDDEN.test(cleaned) || /\bfor\s+update\b|\block\s+in\s+share\s+mode\b|\binto\s+(out|dump)file\b|\bload_file\s*\(/i.test(cleaned)) {
|
|
79
|
+
throw new PolicyError("sql contains a non read-only statement");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const FORBIDDEN_INPUT = new Set([
|
|
84
|
+
"host",
|
|
85
|
+
"username",
|
|
86
|
+
"password",
|
|
87
|
+
"port",
|
|
88
|
+
"database",
|
|
89
|
+
"secret",
|
|
90
|
+
"tls",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
export function validateConnectionInput(
|
|
94
|
+
input: Record<string, unknown>,
|
|
95
|
+
config: Config,
|
|
96
|
+
extensionId: string,
|
|
97
|
+
): void {
|
|
98
|
+
for (const key of FORBIDDEN_INPUT) {
|
|
99
|
+
if (key in input && input[key] !== undefined && input[key] !== null) {
|
|
100
|
+
throw new PolicyError(`agent input must not contain '${key}'`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const connection = input["connection"];
|
|
104
|
+
if (typeof connection !== "string" || connection.length === 0) {
|
|
105
|
+
throw new PolicyError("input must specify a configured 'connection' name");
|
|
106
|
+
}
|
|
107
|
+
if (!getConnectionConfig(config, extensionId, connection)) {
|
|
108
|
+
throw new PolicyError(`unknown connection '${connection}' for extension '${extensionId}'`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface Limits {
|
|
113
|
+
maxRows: number;
|
|
114
|
+
timeoutMs: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const DEFAULT_MAX_ROWS = 100;
|
|
118
|
+
export const MAX_MAX_ROWS = 1000;
|
|
119
|
+
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
120
|
+
export const MAX_TIMEOUT_MS = 30000;
|
|
121
|
+
|
|
122
|
+
export function applyLimits(input: Record<string, unknown>): Limits {
|
|
123
|
+
const maxRows = clamp(
|
|
124
|
+
typeof input.maxRows === "number" ? input.maxRows : DEFAULT_MAX_ROWS,
|
|
125
|
+
1,
|
|
126
|
+
MAX_MAX_ROWS,
|
|
127
|
+
);
|
|
128
|
+
const timeoutMs = clamp(
|
|
129
|
+
typeof input.timeoutMs === "number" ? input.timeoutMs : DEFAULT_TIMEOUT_MS,
|
|
130
|
+
1,
|
|
131
|
+
MAX_TIMEOUT_MS,
|
|
132
|
+
);
|
|
133
|
+
return { maxRows, timeoutMs };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clamp(value: number, min: number, max: number): number {
|
|
137
|
+
return Math.min(Math.max(value, min), max);
|
|
138
|
+
}
|
|
139
|
+
|
package/src/runtime/errors.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export class RuntimeError extends Error {
|
|
2
|
-
constructor(
|
|
3
|
-
|
|
2
|
+
constructor(
|
|
3
|
+
public readonly code: string,
|
|
4
|
+
message: string,
|
|
5
|
+
public readonly details?: unknown,
|
|
6
|
+
options?: ErrorOptions,
|
|
7
|
+
) {
|
|
8
|
+
super(message, options);
|
|
4
9
|
this.name = "RuntimeError";
|
|
5
10
|
}
|
|
6
11
|
}
|
package/src/runtime/executor.ts
CHANGED
|
@@ -38,7 +38,9 @@ export async function executeRuntimeTool(
|
|
|
38
38
|
rawInput: Record<string, unknown>,
|
|
39
39
|
): Promise<ExecutionResult> {
|
|
40
40
|
try {
|
|
41
|
-
const input = validateInput(deps.descriptor.inputSchema as any, rawInput
|
|
41
|
+
const input = validateInput(deps.descriptor.inputSchema as any, rawInput, {
|
|
42
|
+
applyDefaults: deps.descriptor.provider.kind !== "mcp",
|
|
43
|
+
});
|
|
42
44
|
await deps.policy.preflight(deps.descriptor, input);
|
|
43
45
|
const context = await deps.createExecutionContext(deps.descriptor, input);
|
|
44
46
|
const result = await deps.provider.execute(deps.descriptor.id, input, context);
|
|
@@ -56,6 +58,9 @@ export async function executeRuntimeTool(
|
|
|
56
58
|
error: {
|
|
57
59
|
code: typed.code ?? "EXECUTION_FAILED",
|
|
58
60
|
message: typed.message ?? String(error),
|
|
61
|
+
...("details" in (typed as object) && (typed as { details?: unknown }).details !== undefined
|
|
62
|
+
? { details: (typed as { details: unknown }).details }
|
|
63
|
+
: {}),
|
|
59
64
|
},
|
|
60
65
|
};
|
|
61
66
|
}
|
package/src/runtime/provider.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface ToolDescriptor {
|
|
|
8
8
|
risk: "read" | "write" | "admin";
|
|
9
9
|
inputSchema?: Record<string, unknown>;
|
|
10
10
|
provider: { id: string; kind: string };
|
|
11
|
-
source: { id: string; kind: string };
|
|
11
|
+
source: { id: string; kind: string; version?: string };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type ToolSearchResult = Omit<ToolDescriptor, "inputSchema">;
|
|
@@ -24,6 +24,7 @@ export interface ToolProvider {
|
|
|
24
24
|
readonly kind: string;
|
|
25
25
|
listTools(): Promise<readonly ToolDescriptor[]>;
|
|
26
26
|
execute(toolId: string, input: unknown, context: ExecutionContext): Promise<ToolResult>;
|
|
27
|
+
close?(): Promise<void>;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export type { ToolResult } from "./result";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Tool } from "@modelcontextprotocol/client";
|
|
2
|
+
import type { ToolDescriptor } from "../../provider";
|
|
3
|
+
|
|
4
|
+
export interface NormalizedMcpTool {
|
|
5
|
+
readonly descriptor: ToolDescriptor;
|
|
6
|
+
readonly remoteName: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function riskFor(tool: Tool): ToolDescriptor["risk"] {
|
|
10
|
+
if (tool.annotations?.destructiveHint === true) return "admin";
|
|
11
|
+
if (tool.annotations?.readOnlyHint === true) return "read";
|
|
12
|
+
return "write";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeMcpTool(
|
|
16
|
+
serverId: string,
|
|
17
|
+
namespace: string,
|
|
18
|
+
providerId: string,
|
|
19
|
+
tool: Tool,
|
|
20
|
+
): NormalizedMcpTool {
|
|
21
|
+
const description = tool.description ?? tool.title ?? `MCP tool ${tool.name}`;
|
|
22
|
+
const keywords = [...new Set([serverId, namespace, tool.name, tool.title].filter((value): value is string => value !== undefined))];
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
descriptor: {
|
|
26
|
+
id: `${namespace}.${tool.name}`,
|
|
27
|
+
description,
|
|
28
|
+
keywords,
|
|
29
|
+
risk: riskFor(tool),
|
|
30
|
+
inputSchema: (tool.inputSchema ?? { type: "object", properties: {} }) as Record<string, unknown>,
|
|
31
|
+
provider: { id: providerId, kind: "mcp" },
|
|
32
|
+
source: { id: serverId, kind: "mcp-server" },
|
|
33
|
+
},
|
|
34
|
+
remoteName: tool.name,
|
|
35
|
+
};
|
|
36
|
+
}
|