frinus-mcp 3.1.0 → 3.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 CHANGED
@@ -307,6 +307,47 @@ Reusable behaviours assigned to agents.
307
307
  | `skill_remove` | Detach a skill from an agent. |
308
308
  | `skill_agent_list` | List the skills owned by a given agent. |
309
309
 
310
+ ### Credential Vault (5) — `credential_exec` broker
311
+
312
+ Encrypted credentials stored in the Control Plane, referenced from memories via
313
+ `credential_ref`.
314
+
315
+ | Tool | Description |
316
+ |------|-------------|
317
+ | `credential_store` | Store an encrypted credential under a ref (e.g. `mysql_x`). |
318
+ | `credential_get` | Inspect **non-secret metadata only** (host/user/db/port + the env vars `credential_exec` will inject). Never returns the value. |
319
+ | `credential_exec` | **Run a command with the credential injected into the child process ENV.** Returns only stdout/stderr/exit_code — the secret never reaches the model, screen, or disk. |
320
+ | `credential_list` | List stored credential refs (no secret data). |
321
+ | `credential_delete` | Delete a stored credential. |
322
+
323
+ **Secret-handling principle — server-side broker, never by value.** The model
324
+ never receives a secret value: no plaintext, no temp files, no shell snippets that
325
+ carry the secret. To *use* a credential you call **`credential_exec`**. The MCP
326
+ server (already running locally via `npx -y frinus-mcp@latest`) fetches the
327
+ credential from the vault, injects its fields into the **environment** of a child
328
+ process — never into argv, never into any text the model sees — runs the command
329
+ with `shell:false` (no shell injection), and returns only the output.
330
+
331
+ ```jsonc
332
+ // MYSQL_PWD / MYSQL_USER / MYSQL_HOST are pre-injected → standard clients just work
333
+ credential_exec(ref="mysql_prod", argv=["mysql", "-e", "SELECT 1"])
334
+ credential_exec(ref="pg_prod", argv=["psql", "-c", "SELECT 1"])
335
+
336
+ // For anything else, read the injected vars inside an explicit shell:
337
+ credential_exec(ref="jira_x", argv=["sh","-c",
338
+ "curl -sS -H \"Authorization: Bearer $CRED_TOKEN\" \"$CRED_BASE_URL/whoami\""])
339
+ ```
340
+
341
+ Injected env vars (when present in the credential): password/secret/token →
342
+ `MYSQL_PWD`, `PGPASSWORD`, `CRED_PASSWORD`; `user`/`username` → `CRED_USER`,
343
+ `MYSQL_USER`, `PGUSER`; `host` → `CRED_HOST`, `MYSQL_HOST`, `PGHOST`; `port` →
344
+ `CRED_PORT`, `MYSQL_TCP_PORT`, `PGPORT`; `database` → `CRED_DATABASE`,
345
+ `PGDATABASE`; any other scalar → `CRED_<UPPER_SNAKE>` (e.g. `base_url` →
346
+ `CRED_BASE_URL`). `argv` must be an array of strings (no shell command string;
347
+ use `["sh","-c","..."]` if you really need a shell). 30s timeout, 256 KiB output
348
+ cap. Everything ships in the npm package — **the user installs nothing, edits no
349
+ PATH, and runs no extra command**; an up-to-date MCP is all that's required.
350
+
310
351
  ## Memory Types
311
352
 
312
353
  | Type | Use Case | Example |
@@ -1499,24 +1499,84 @@ to link a memory to this credential.`,
1499
1499
  },
1500
1500
  {
1501
1501
  name: "credential_get",
1502
- description: `Retrieve a decrypted credential from the vault.
1502
+ description: `Inspect a credential's NON-SECRET metadata — never its value.
1503
1503
 
1504
- Returns the decrypted credential data for the given reference.
1505
- Only the credential owner (authenticated user) can access their credentials.
1504
+ This tool returns ONLY non-secret metadata (host/user/db/port), the names of the
1505
+ secret fields, and the names of the environment variables that 'credential_exec'
1506
+ will inject. The secret VALUE is NEVER returned, so it can never be echoed inline
1507
+ into a shell command and leaked on a screen share.
1506
1508
 
1507
- Use this when a memory has a credential_ref and you need the actual
1508
- credentials to perform an action (e.g., connect to a database, call an API).`,
1509
+ To actually USE the credential (connect to a DB, call an API), do NOT request the
1510
+ value — call 'credential_exec'. It fetches the secret on the server side, injects
1511
+ it into the child process environment, runs your command, and returns only the
1512
+ output. The secret never reaches you, the screen, or disk.
1513
+
1514
+ Only the credential owner (authenticated API key) can access their credentials.`,
1509
1515
  inputSchema: {
1510
1516
  type: "object",
1511
1517
  properties: {
1512
1518
  ref: {
1513
1519
  type: "string",
1514
- description: "Credential reference to retrieve (e.g., 'jira_muza')",
1520
+ description: "Credential reference to inspect (e.g., 'jira_muza')",
1515
1521
  },
1516
1522
  },
1517
1523
  required: ["ref"],
1518
1524
  },
1519
1525
  },
1526
+ {
1527
+ name: "credential_exec",
1528
+ description: `Run a command with a vault credential injected into its environment — the secret NEVER reaches you.
1529
+
1530
+ This is the secure way to USE a stored credential. The MCP server (running locally
1531
+ via npx) fetches the credential from the vault, injects its fields into the
1532
+ ENVIRONMENT of a child process (never into argv, never into any text you see),
1533
+ runs your command with shell:false (no shell injection), and returns ONLY
1534
+ stdout, stderr, and exit_code. The secret value is never returned to you, never
1535
+ printed on screen, never logged, and never written to disk.
1536
+
1537
+ Injected environment variables (when present in the credential):
1538
+ - password/secret/token field -> MYSQL_PWD, PGPASSWORD, CRED_PASSWORD
1539
+ - user / username -> CRED_USER, MYSQL_USER, PGUSER
1540
+ - host / hostname -> CRED_HOST, MYSQL_HOST, PGHOST
1541
+ - port -> CRED_PORT, MYSQL_TCP_PORT, PGPORT
1542
+ - database / db / dbname -> CRED_DATABASE, PGDATABASE
1543
+ - any other scalar field -> CRED_<UPPER_SNAKE_NAME> (e.g. base_url -> CRED_BASE_URL)
1544
+
1545
+ Because MYSQL_PWD / PGPASSWORD etc. are standard, common clients pick them up
1546
+ with no flags:
1547
+ credential_exec(ref="mysql_prod", argv=["mysql", "-e", "SELECT 1"])
1548
+ credential_exec(ref="pg_prod", argv=["psql", "-c", "SELECT 1"])
1549
+
1550
+ For anything else, read the injected vars inside an explicit shell — the secret
1551
+ is expanded inside the child, never in text you author:
1552
+ credential_exec(ref="jira_x", argv=["sh", "-c",
1553
+ "curl -sS -H \\"Authorization: Bearer $CRED_TOKEN\\" \\"$CRED_BASE_URL/whoami\\""])
1554
+
1555
+ RULES:
1556
+ - 'argv' MUST be an array of strings (program first, then args). A shell command
1557
+ string is NOT accepted; pass ["sh","-c","..."] only when you genuinely need a shell.
1558
+ - NEVER put a secret value in argv or ask for the value — there is no path to it.
1559
+ - 30s timeout; output capped at 256 KiB per stream.`,
1560
+ inputSchema: {
1561
+ type: "object",
1562
+ properties: {
1563
+ ref: {
1564
+ type: "string",
1565
+ description: "Credential reference to use (e.g., 'mysql_centerpag', 'jira_muza').",
1566
+ },
1567
+ argv: {
1568
+ type: "array",
1569
+ items: { type: "string" },
1570
+ description: "Command as an array: program first, then each argument as a separate string. E.g. [\"mysql\", \"-e\", \"SELECT 1\"]. NOT a shell string.",
1571
+ },
1572
+ stdin: {
1573
+ type: "string",
1574
+ description: "Optional data to pipe to the command's stdin (e.g. a SQL script).",
1575
+ },
1576
+ },
1577
+ required: ["ref", "argv"],
1578
+ },
1579
+ },
1520
1580
  {
1521
1581
  name: "credential_list",
1522
1582
  description: `List all stored credentials for the current user.
@@ -5,8 +5,166 @@
5
5
  * and return formatted MCP tool results.
6
6
  */
7
7
  import axios from "axios";
8
+ import { spawn } from "node:child_process";
8
9
  import { getResolvedTenantOrgId } from "../client/memory-client.js";
9
10
  // ---------------------------------------------------------------------------
11
+ // Credential delivery — server-side broker, never by value
12
+ // ---------------------------------------------------------------------------
13
+ // SECURITY: agents running in Claude Code render the full bash command string
14
+ // on screen. If a secret value ever enters the agent's text context, the agent
15
+ // can (and historically did) splice it inline into a command like
16
+ // `export MYSQL_PWD='...'`, leaking it in plaintext during screen sharing.
17
+ //
18
+ // The structural fix is to NEVER return the secret value to the model at all,
19
+ // and — going one step further — to never even require the agent to hold the
20
+ // secret as a shell-side reference. The MCP server already runs locally (via
21
+ // `npx -y frinus-mcp@latest`) and already fetches the credential from the
22
+ // Control Plane vault. So the broker lives INSIDE this process as a native
23
+ // tool: `credential_exec`.
24
+ //
25
+ // `credential_exec` fetches the credential, injects the fields into the ENV of
26
+ // a child process (never into argv, never into the model's text), spawns the
27
+ // command with shell:false, and returns ONLY stdout/stderr/exit_code. The
28
+ // secret never enters the tool result, is never logged, and never touches disk.
29
+ // Everything ships in the npm package — the user installs nothing, edits no
30
+ // PATH, and runs no extra command.
31
+ //
32
+ // `credential_get` no longer surfaces any secret-bearing snippet: it returns
33
+ // non-sensitive metadata only and points the agent at `credential_exec`.
34
+ /** Keys (case-insensitive) treated as sensitive when surfacing metadata. */
35
+ const SECRET_KEY_RE = /pass(word|wd)?|secret|token|api[_-]?key|private[_-]?key|credential/i;
36
+ function looksLikePassword(key) {
37
+ return SECRET_KEY_RE.test(key);
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // credential_exec internals
41
+ // ---------------------------------------------------------------------------
42
+ const CRED_EXEC_TIMEOUT_MS = 30_000;
43
+ const CRED_EXEC_MAX_OUTPUT = 256 * 1024; // 256 KiB cap per stream
44
+ /**
45
+ * Map credential fields onto environment variables for the child process.
46
+ * The secret value flows ONLY into this env map — never into argv, never into
47
+ * any returned/ logged text.
48
+ *
49
+ * - Password-like field -> MYSQL_PWD, PGPASSWORD, CRED_PASSWORD
50
+ * - user/username -> CRED_USER (+ MYSQL_USER, PGUSER)
51
+ * - host/hostname -> CRED_HOST (+ MYSQL_HOST, PGHOST)
52
+ * - port -> CRED_PORT (+ MYSQL_TCP_PORT, PGPORT)
53
+ * - database/db/dbname -> CRED_DATABASE (+ PGDATABASE)
54
+ * - any other scalar -> CRED_<UPPER_SNAKE> (e.g. base_url -> CRED_BASE_URL)
55
+ */
56
+ function buildCredentialEnv(data) {
57
+ const env = {};
58
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
59
+ let passwordSet = false;
60
+ let userSet = false;
61
+ let hostSet = false;
62
+ let portSet = false;
63
+ let dbSet = false;
64
+ for (const [k, v] of Object.entries(data)) {
65
+ if (!isScalar(v))
66
+ continue;
67
+ const val = String(v);
68
+ const lk = k.toLowerCase();
69
+ // Generic CRED_<NAME> for every scalar field (raw key, upper-snaked).
70
+ const credKey = "CRED_" + k.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
71
+ env[credKey] = val;
72
+ if (!passwordSet && looksLikePassword(k)) {
73
+ env.CRED_PASSWORD = val;
74
+ env.MYSQL_PWD = val;
75
+ env.PGPASSWORD = val;
76
+ passwordSet = true;
77
+ }
78
+ else if (!userSet && (lk === "user" || lk === "username")) {
79
+ env.CRED_USER = val;
80
+ env.MYSQL_USER = val;
81
+ env.PGUSER = val;
82
+ userSet = true;
83
+ }
84
+ else if (!hostSet && (lk === "host" || lk === "hostname")) {
85
+ env.CRED_HOST = val;
86
+ env.MYSQL_HOST = val;
87
+ env.PGHOST = val;
88
+ hostSet = true;
89
+ }
90
+ else if (!portSet && lk === "port") {
91
+ env.CRED_PORT = val;
92
+ env.MYSQL_TCP_PORT = val;
93
+ env.PGPORT = val;
94
+ portSet = true;
95
+ }
96
+ else if (!dbSet && (lk === "database" || lk === "db" || lk === "dbname")) {
97
+ env.CRED_DATABASE = val;
98
+ env.PGDATABASE = val;
99
+ dbSet = true;
100
+ }
101
+ }
102
+ return env;
103
+ }
104
+ /**
105
+ * Spawn `argv` (program + args, shell:false) with the credential env injected
106
+ * on top of a minimal copy of the current env. Captures stdout/stderr with a
107
+ * size cap and a hard timeout. Never throws on a non-zero exit; throws only on
108
+ * spawn failure (e.g. command not found).
109
+ */
110
+ function runWithCredEnv(argv, credEnv, stdin) {
111
+ return new Promise((resolve, reject) => {
112
+ const child = spawn(argv[0], argv.slice(1), {
113
+ shell: false,
114
+ env: { ...process.env, ...credEnv },
115
+ stdio: ["pipe", "pipe", "pipe"],
116
+ });
117
+ let stdout = "";
118
+ let stderr = "";
119
+ let truncated = false;
120
+ let timedOut = false;
121
+ let settled = false;
122
+ const timer = setTimeout(() => {
123
+ timedOut = true;
124
+ child.kill("SIGKILL");
125
+ }, CRED_EXEC_TIMEOUT_MS);
126
+ const append = (buf, chunk) => {
127
+ if (buf.length >= CRED_EXEC_MAX_OUTPUT) {
128
+ truncated = true;
129
+ return buf;
130
+ }
131
+ const next = buf + chunk.toString("utf8");
132
+ if (next.length > CRED_EXEC_MAX_OUTPUT) {
133
+ truncated = true;
134
+ return next.slice(0, CRED_EXEC_MAX_OUTPUT);
135
+ }
136
+ return next;
137
+ };
138
+ child.stdout.on("data", (c) => { stdout = append(stdout, c); });
139
+ child.stderr.on("data", (c) => { stderr = append(stderr, c); });
140
+ child.on("error", (err) => {
141
+ if (settled)
142
+ return;
143
+ settled = true;
144
+ clearTimeout(timer);
145
+ reject(err);
146
+ });
147
+ child.on("close", (code, signal) => {
148
+ if (settled)
149
+ return;
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ resolve({
153
+ stdout,
154
+ stderr,
155
+ exit_code: code,
156
+ signal: signal ?? null,
157
+ timed_out: timedOut,
158
+ truncated,
159
+ });
160
+ });
161
+ if (typeof stdin === "string") {
162
+ child.stdin.write(stdin);
163
+ }
164
+ child.stdin.end();
165
+ });
166
+ }
167
+ // ---------------------------------------------------------------------------
10
168
  // Helper: 401/403 error handling
11
169
  // ---------------------------------------------------------------------------
12
170
  export function handleApiError(error) {
@@ -1472,11 +1630,57 @@ const handlers = {
1472
1630
  try {
1473
1631
  const ref = args.ref;
1474
1632
  const result = await cpClient.getCredential(ref);
1633
+ const data = (result ?? {});
1634
+ // SERVER-SIDE BROKER MODEL: we never return any secret value here, and we
1635
+ // no longer emit shell snippets that handle the secret either. We surface
1636
+ // ONLY non-sensitive metadata (host/user/db/port) plus the names of the
1637
+ // secret fields and the env vars they will populate, and we instruct the
1638
+ // agent to run the command through `credential_exec`, which injects the
1639
+ // secret into the child process env on the server side — the value never
1640
+ // reaches the agent, the screen, or disk.
1641
+ const nonSecret = {};
1642
+ const secretFields = [];
1643
+ const credEnvKeys = Object.keys(buildCredentialEnv(data)).sort();
1644
+ for (const [k, v] of Object.entries(data)) {
1645
+ if (typeof v === "object" && v !== null)
1646
+ continue;
1647
+ if (looksLikePassword(k))
1648
+ secretFields.push(k);
1649
+ else
1650
+ nonSecret[k] = v;
1651
+ }
1652
+ const hasUser = "user" in data || "username" in data;
1653
+ const isDbLike = hasUser && secretFields.length > 0;
1654
+ const lines = [];
1655
+ lines.push(`Credential '${ref}' resolved (metadata only — NO secret value is ever returned).`);
1656
+ lines.push("To USE this credential, call the `credential_exec` tool. It fetches the " +
1657
+ "secret on the server side, injects it into the child process environment, " +
1658
+ "runs your command, and returns ONLY stdout/stderr/exit_code. The secret " +
1659
+ "never enters this text, your context, the screen, or disk.");
1660
+ lines.push("");
1661
+ if (Object.keys(nonSecret).length > 0) {
1662
+ lines.push("Non-secret metadata: " + JSON.stringify(nonSecret));
1663
+ }
1664
+ if (secretFields.length > 0) {
1665
+ lines.push(`Secret fields (handled server-side only): ${secretFields.join(", ")}`);
1666
+ }
1667
+ lines.push(`Env vars injected into the child by credential_exec: ${credEnvKeys.join(", ")}`);
1668
+ lines.push("");
1669
+ lines.push("DO NOT request, print, or inline the secret value. Use credential_exec:");
1670
+ lines.push("");
1671
+ if (isDbLike) {
1672
+ lines.push("# MySQL (MYSQL_PWD / MYSQL_USER / MYSQL_HOST are pre-injected):");
1673
+ lines.push(`credential_exec(ref="${ref}", argv=["mysql", "-e", "SELECT 1"])`);
1674
+ lines.push("");
1675
+ lines.push("# Postgres (PGPASSWORD / PGUSER / PGHOST / PGDATABASE are pre-injected):");
1676
+ lines.push(`credential_exec(ref="${ref}", argv=["psql", "-c", "SELECT 1"])`);
1677
+ }
1678
+ else {
1679
+ lines.push("# Read an injected field inside the command (here CRED_API_KEY, etc.):");
1680
+ lines.push(`credential_exec(ref="${ref}", argv=["sh", "-c", "curl -sS -H \\"Authorization: Bearer $CRED_TOKEN\\" \\"$CRED_BASE_URL/whoami\\""])`);
1681
+ }
1475
1682
  return {
1476
- content: [{
1477
- type: "text",
1478
- text: JSON.stringify(result, null, 2),
1479
- }],
1683
+ content: [{ type: "text", text: lines.join("\n") }],
1480
1684
  };
1481
1685
  }
1482
1686
  catch (error) {
@@ -1494,6 +1698,90 @@ const handlers = {
1494
1698
  throw error;
1495
1699
  }
1496
1700
  },
1701
+ // credential_exec — server-side broker. Runs a command with the credential
1702
+ // injected into the child process ENV. Returns ONLY stdout/stderr/exit_code.
1703
+ // The secret value never enters the returned text and is never logged.
1704
+ async credential_exec(args, { cpClient }) {
1705
+ const ref = args.ref;
1706
+ const argv = args.argv;
1707
+ const stdin = args.stdin;
1708
+ // Validate argv: must be a non-empty array of strings (program + args).
1709
+ // We deliberately do NOT accept a shell command string — spawn runs with
1710
+ // shell:false to avoid shell injection. The caller can still opt into a
1711
+ // shell explicitly with argv ["sh","-c","..."], which is their choice.
1712
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === "string")) {
1713
+ return {
1714
+ content: [{
1715
+ type: "text",
1716
+ text: "argv must be a non-empty array of strings (program + args), e.g. [\"mysql\", \"-e\", \"SELECT 1\"]. A shell command string is not accepted (use [\"sh\", \"-c\", \"...\"] if you really need a shell).",
1717
+ }],
1718
+ isError: true,
1719
+ };
1720
+ }
1721
+ if (stdin !== undefined && typeof stdin !== "string") {
1722
+ return {
1723
+ content: [{ type: "text", text: "stdin, if provided, must be a string." }],
1724
+ isError: true,
1725
+ };
1726
+ }
1727
+ let credData;
1728
+ try {
1729
+ const result = await cpClient.getCredential(ref);
1730
+ credData = (result ?? {});
1731
+ }
1732
+ catch (error) {
1733
+ if (error?.response?.status === 404) {
1734
+ return {
1735
+ content: [{ type: "text", text: `No credential found for ref '${ref}'` }],
1736
+ isError: true,
1737
+ };
1738
+ }
1739
+ const apiErr = handleApiError(error);
1740
+ if (apiErr)
1741
+ return apiErr;
1742
+ throw error;
1743
+ }
1744
+ if (Object.keys(credData).length === 0) {
1745
+ return {
1746
+ content: [{ type: "text", text: `Credential '${ref}' has no fields to inject.` }],
1747
+ isError: true,
1748
+ };
1749
+ }
1750
+ const credEnv = buildCredentialEnv(credData);
1751
+ let res;
1752
+ try {
1753
+ res = await runWithCredEnv(argv, credEnv, stdin);
1754
+ }
1755
+ catch (err) {
1756
+ // Spawn failure (e.g. ENOENT command not found). The error message is
1757
+ // about the program/argv, which the caller already provided — no secret.
1758
+ return {
1759
+ content: [{
1760
+ type: "text",
1761
+ text: `Failed to execute command: ${err?.code ? err.code + " " : ""}${err?.message ?? String(err)}`,
1762
+ }],
1763
+ isError: true,
1764
+ };
1765
+ }
1766
+ // Return ONLY non-secret execution results. The credential value lived only
1767
+ // in the child's env; it is not part of stdout/stderr unless the invoked
1768
+ // command itself chose to print it (the caller's responsibility).
1769
+ const summary = [
1770
+ `exit_code: ${res.exit_code}`,
1771
+ res.signal ? `signal: ${res.signal}` : null,
1772
+ res.timed_out ? "timed_out: true (killed after 30s)" : null,
1773
+ res.truncated ? "truncated: true (output capped at 256 KiB)" : null,
1774
+ "",
1775
+ "--- stdout ---",
1776
+ res.stdout.length ? res.stdout : "(empty)",
1777
+ "--- stderr ---",
1778
+ res.stderr.length ? res.stderr : "(empty)",
1779
+ ].filter((l) => l !== null).join("\n");
1780
+ return {
1781
+ content: [{ type: "text", text: summary }],
1782
+ isError: res.exit_code !== 0,
1783
+ };
1784
+ },
1497
1785
  async credential_list(_args, { cpClient }) {
1498
1786
  try {
1499
1787
  const result = await cpClient.listCredentials();
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "manifest_version": "0.2",
2
+ "manifest_version": "0.3",
3
3
  "name": "frinus",
4
4
  "display_name": "Frinus — Memória Cognitiva",
5
5
  "version": "3.1.0",
@@ -12,7 +12,6 @@
12
12
  "homepage": "https://frinus.rdxsec.com.br",
13
13
  "documentation": "https://github.com/frinus-ai/frinus-mcp",
14
14
  "support": "https://github.com/frinus-ai/frinus-mcp/issues",
15
- "icon": "icon.png",
16
15
  "server": {
17
16
  "type": "node",
18
17
  "entry_point": "dist/index.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frinus-mcp",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Frinus - MCP Server for Cognitive Memory (φρήν + νοῦς). Long-term memory, sessions, agents and knowledge for Claude and any MCP client.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",