openvibers/openvibe.actor Commits
Actor V2-11 follow-up: the egress guard refuses private IP literals; STATUS records reach from Chat, MCP/A2A and webhooks (#297)
Changes
@@@ -28,14 +28,16 @@"Free tier per person and an operator daily ceiling; costs metered per call at the published rate cards","Pages: home with the task box, your tasks, a task's live page, the agents, the API reference, updates; robots, sitemap, llms.txt","Account export and deletion (ADR-033): tasks, spend, personal agents and every memory note are exported and erased for the person",-- "Personal agents v1 in the worktree: named instructions, editable memory, hourly/daily/weekly schedules, manual runs and ordinary Actor task limits; awaiting test run and deployment"++ "Personal agents: named instructions, editable memory, hourly/daily/weekly schedules, manual and scheduled runs, and their own approval inbox before work on the person's machine",++ "Reach from Chat (A4): Chat starts the person's own agent for a message (POST /api/v1/chat-apps/tasks, svc:chat acting for a linked person); a direct message runs with their memory, a group with the stranger's text as untrusted data and no personal tools, and an acting tool under act_with_approvals waits for the person's decision. Shipped with Chat #358 and Network #591 (Actor #296)",++ "MCP servers and A2A agents as tools (V2-11): a person connects their own MCP server by URL or an OpenVibe-offered one on /connections, turns each tool on, and the run calls it like any other tool; ask_agent talks to a connected A2A agent. Every call goes through the egress guard, is recorded as evidence with the server and tool as its source, and follows the run's approval policy (Actor #206)",++ "Task webhooks on state changes: up to 8 https endpoints per task, signed with the task's own secret (or a project secret by reference), retried with backoff, with a delivery log on the task page and public addresses only"],"notYet": ["Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over","Coding tasks through OpenVibe.Codes on hosted sandboxes (listed as unavailable with the reason)","More agent platforms, bring-your-own keys through OpenVibe.AI, paid tiers through OpenVibe.Billing",-- "Personal agent Events triggers, approvals, reach from Chat and the Frame",-- "Webhooks on task state changes"++ "Personal agent Events triggers, the rest of the approval inbox (only coding tasks ask today) and the Frame"],"capabilitiesRegistered": true,"serviceManifestRegistered": true,@@@ -49,7 +51,9 @@"private mode on OpenVibe hardware","cross-family answer checks","live task progress",-- "task webhooks (signed per task, retried with backoff, public https endpoints only)"++ "task webhooks (signed per task, retried with backoff, public https endpoints only)",++ "MCP servers and A2A agents as tools (V2-11; egress guard, evidence, approval policy)",++ "reach from Chat (A4; chat apps' agent, approvals and group policy)"],"updated": "2026-11-06"}@@@ -7,7 +7,9 @@* https only, a public address only, a timeout and a response cap. The address check is openvibe-shared/egress:* a name that resolves to localhost, to a private range or to a cloud metadata address is refused before a byte* is sent, and the check runs again at connect time (the safe lookup), so a name that answers 127.0.0.1 later-- * is refused too. Redirects are never followed: a 302 is a result, not a hop.++ * is refused too. An IP literal is checked here because Node does not call the lookup for one: without this,++ * https://127.0.0.1/ or https://169.254.169.254/ would reach this machine or cloud metadata. Redirects are never++ * followed: a 302 is a result, not a hop.** const res = await request(url, { method: "POST", json: payload, headers, timeoutMs });* const data = await json(url, { json: payload, headers });@@@ -15,6 +17,7 @@* A test injects lookup and isAllowed so a local fake server can be reached; production uses the defaults.*/const https = require("https");++const net = require("net");const egress = require("openvibe-shared/egress");const DEFAULT_TIMEOUT_MS = 10_000;@@@ -37,7 +40,10 @@ function checkUrl(url, { ports = ALLOWED_PORTS } = {}) {if (u.username || u.password) throw new GuardedError("a URL with a password in it is not allowed");const port = u.port || "443";if (!ports.includes(port)) throw new GuardedError("port " + port + " is not allowed; use 443 or 8443");-- if (egress.isInternalName(u.hostname)) throw new GuardedError(u.hostname + " is not a public host name");++ const host = egress.normalizeHost(u.hostname);++ if (net.isIP(host) ? !egress.isPublicAddress(host) : egress.isInternalName(host)) {++ throw new GuardedError(host + (net.isIP(host) ? " is not a public address" : " is not a public host name"));++ }return u;}@@@ -20,6 +20,7 @@ const mcp = require("../server/connections/mcp");const a2a = require("../server/connections/a2a");const { actsFor, aliasOf, taskText } = require("../server/connections/service");const { shortlistTools } = require("../server/runtime/brain/shortlist");++const { createRegistry } = require("../server/runtime/tools");const { createPrincipal } = require("../server/http/principal");const results = [];@@@ -89,10 +90,25 @@ function fakeMcp() {try { await egress.assertPublicUrl(url, { protocols: ["https:"] }); } catch { refused = true; }assert.ok(refused, url + " was not refused");}++ // Node does not call the lookup for an IP literal, so checkUrl has to refuse it itself.++ for (const url of ["https://127.0.0.1/mcp", "https://10.0.0.5/mcp", "https://192.168.1.4/mcp", "https://169.254.169.254/latest/meta-data", "https://[::1]/mcp", "https://[::ffff:127.0.0.1]/mcp"]) {++ assert.throws(() => checkUrl(url), /not a public (address|host name)/, url + " was not refused by checkUrl");++ }assert.throws(() => checkUrl("http://example.com/mcp"), /not allowed/);assert.throws(() => checkUrl("https://example.com:8080/mcp"), /port/);});++ await ok("a private IP literal is refused before any connection is made", async () => {++ let hits = 0;++ const server = https.createServer(cert(fs.mkdtempSync(path.join(os.tmpdir(), "actor-guard-"))), (req, res) => { hits++; res.end("internal"); });++ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));++ const port = server.address().port;++ try {++ assert.throws(() => request(`https://127.0.0.1:${port}/mcp`, { ports: [String(port)], timeoutMs: 2000 }), /not a public address/);++ assert.strictEqual(hits, 0, "the guard connected to a private address");++ } finally { await new Promise((resolve) => server.close(resolve)); }++ });++await ok("a fake MCP server lists its tools and answers a call", async () => {const fake = await fakeMcp();try {@@@ -133,6 +149,32 @@ function fakeMcp() {assert.strictEqual(shortlistTools(defs.slice(0, 10), state).length, 10, "below the limit every tool is offered");});++ await ok("a connector tool follows the chat policy and asks before it acts", async () => {++ const registry = createRegistry({ strict: true });++ let calls = 0;++ let gates = 0;++ const tool = {++ name: "mcp:cnx_TEST:write_note", alias: "mcp__cnx_TEST__write_note", scope: "person", acts: true,++ input: { type: "object", properties: {} }, description: "Write a note.", run: async () => { calls++; return { text: "wrote" }; },++ };++ const ctx = {++ requester: "user:usr_test", chatPolicy: "answer_only",++ approveChatTool: async () => { gates++; return true; }, dynamicTools: new Map([[tool.alias, tool]]),++ };++ assert.equal((await registry.run(tool.alias, {}, ctx)).isError, true, "answer_only never reaches an acting connector");++ assert.deepStrictEqual([calls, gates], [0, 0]);++ // A person-scope connector is never offered to a group, even one that may act with approvals.++ assert.equal((await registry.run(tool.alias, {}, { ...ctx, chatPolicy: "act_with_approvals" })).isError, true);++ assert.deepStrictEqual([calls, gates], [0, 0]);++ // In a direct message the person's approval is the gate: a refused or missing one runs nothing.++ assert.equal((await registry.run(tool.alias, {}, { ...ctx, chatPolicy: "dm", approveChatTool: async () => { gates++; return false; } })).isError, true);++ assert.deepStrictEqual([calls, gates], [0, 1]);++ assert.equal((await registry.run(tool.alias, {}, { requester: ctx.requester, chatPolicy: "dm", dynamicTools: ctx.dynamicTools })).isError, true, "an acting tool without a person to ask does not run");++ assert.strictEqual(calls, 0);++ await registry.run(tool.alias, {}, { ...ctx, chatPolicy: "dm" });++ assert.deepStrictEqual([calls, gates], [1, 2]);++ });++await ok("the A2A card names the skills and the auth", async () => {const card = a2a.actorCard({ baseUrl: "https://openvibe.actor", version: "1.0" });assert.ok(card.skills.length >= 4, "the card has the skills");
@@ -28,14 +28,16 @@
@@ -28,14 +28,16 @@
"Free tier per person and an operator daily ceiling; costs metered per call at the published rate cards",
"Free tier per person and an operator daily ceiling; costs metered per call at the published rate cards",
"Pages: home with the task box, your tasks, a task's live page, the agents, the API reference, updates; robots, sitemap, llms.txt",
"Pages: home with the task box, your tasks, a task's live page, the agents, the API reference, updates; robots, sitemap, llms.txt",
"Account export and deletion (ADR-033): tasks, spend, personal agents and every memory note are exported and erased for the person",
"Account export and deletion (ADR-033): tasks, spend, personal agents and every memory note are exported and erased for the person",
- "Personal agents v1 in the worktree: named instructions, editable memory, hourly/daily/weekly schedules, manual runs and ordinary Actor task limits; awaiting test run and deployment"
+ "Personal agents: named instructions, editable memory, hourly/daily/weekly schedules, manual and scheduled runs, and their own approval inbox before work on the person's machine",
+ "Reach from Chat (A4): Chat starts the person's own agent for a message (POST /api/v1/chat-apps/tasks, svc:chat acting for a linked person); a direct message runs with their memory, a group with the stranger's text as untrusted data and no personal tools, and an acting tool under act_with_approvals waits for the person's decision. Shipped with Chat #358 and Network #591 (Actor #296)",
+ "MCP servers and A2A agents as tools (V2-11): a person connects their own MCP server by URL or an OpenVibe-offered one on /connections, turns each tool on, and the run calls it like any other tool; ask_agent talks to a connected A2A agent. Every call goes through the egress guard, is recorded as evidence with the server and tool as its source, and follows the run's approval policy (Actor #206)",
+ "Task webhooks on state changes: up to 8 https endpoints per task, signed with the task's own secret (or a project secret by reference), retried with backoff, with a delivery log on the task page and public addresses only"
],
],
"notYet": [
"notYet": [
"Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over",
"Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over",
"Coding tasks through OpenVibe.Codes on hosted sandboxes (listed as unavailable with the reason)",
"Coding tasks through OpenVibe.Codes on hosted sandboxes (listed as unavailable with the reason)",
"More agent platforms, bring-your-own keys through OpenVibe.AI, paid tiers through OpenVibe.Billing",
"More agent platforms, bring-your-own keys through OpenVibe.AI, paid tiers through OpenVibe.Billing",
- "Personal agent Events triggers, approvals, reach from Chat and the Frame",
- "Webhooks on task state changes"
+ "Personal agent Events triggers, the rest of the approval inbox (only coding tasks ask today) and the Frame"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"serviceManifestRegistered": true,
"serviceManifestRegistered": true,
@@ -49,7 +51,9 @@
@@ -49,7 +51,9 @@
"private mode on OpenVibe hardware",
"private mode on OpenVibe hardware",
"cross-family answer checks",
"cross-family answer checks",
"live task progress",
"live task progress",
- "task webhooks (signed per task, retried with backoff, public https endpoints only)"
+ "task webhooks (signed per task, retried with backoff, public https endpoints only)",
+ "MCP servers and A2A agents as tools (V2-11; egress guard, evidence, approval policy)",
+ "reach from Chat (A4; chat apps' agent, approvals and group policy)"
],
],
"updated": "2026-11-06"
"updated": "2026-11-06"
}
}
@@ -7,7 +7,9 @@
@@ -7,7 +7,9 @@
* https only, a public address only, a timeout and a response cap. The address check is openvibe-shared/egress:
* https only, a public address only, a timeout and a response cap. The address check is openvibe-shared/egress:
* a name that resolves to localhost, to a private range or to a cloud metadata address is refused before a byte
* a name that resolves to localhost, to a private range or to a cloud metadata address is refused before a byte
* is sent, and the check runs again at connect time (the safe lookup), so a name that answers 127.0.0.1 later
* is sent, and the check runs again at connect time (the safe lookup), so a name that answers 127.0.0.1 later
- * is refused too. Redirects are never followed: a 302 is a result, not a hop.
+ * is refused too. An IP literal is checked here because Node does not call the lookup for one: without this,
+ * https://127.0.0.1/ or https://169.254.169.254/ would reach this machine or cloud metadata. Redirects are never
+ * followed: a 302 is a result, not a hop.
*
*
* const res = await request(url, { method: "POST", json: payload, headers, timeoutMs });
* const res = await request(url, { method: "POST", json: payload, headers, timeoutMs });
* const data = await json(url, { json: payload, headers });
* const data = await json(url, { json: payload, headers });
@@ -15,6 +17,7 @@
@@ -15,6 +17,7 @@
* A test injects lookup and isAllowed so a local fake server can be reached; production uses the defaults.
* A test injects lookup and isAllowed so a local fake server can be reached; production uses the defaults.
*/
*/
const https = require("https");
const https = require("https");
+const net = require("net");
const egress = require("openvibe-shared/egress");
const egress = require("openvibe-shared/egress");
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_TIMEOUT_MS = 10_000;
@@ -37,7 +40,10 @@ function checkUrl(url, { ports = ALLOWED_PORTS } = {}) {
@@ -37,7 +40,10 @@ function checkUrl(url, { ports = ALLOWED_PORTS } = {}) {
if (u.username || u.password) throw new GuardedError("a URL with a password in it is not allowed");
if (u.username || u.password) throw new GuardedError("a URL with a password in it is not allowed");
const port = u.port || "443";
const port = u.port || "443";
if (!ports.includes(port)) throw new GuardedError("port " + port + " is not allowed; use 443 or 8443");
if (!ports.includes(port)) throw new GuardedError("port " + port + " is not allowed; use 443 or 8443");
- if (egress.isInternalName(u.hostname)) throw new GuardedError(u.hostname + " is not a public host name");
+ const host = egress.normalizeHost(u.hostname);
+ if (net.isIP(host) ? !egress.isPublicAddress(host) : egress.isInternalName(host)) {
+ throw new GuardedError(host + (net.isIP(host) ? " is not a public address" : " is not a public host name"));
+ }
return u;
return u;
}
}
@@ -20,6 +20,7 @@ const mcp = require("../server/connections/mcp");
@@ -20,6 +20,7 @@ const mcp = require("../server/connections/mcp");
const a2a = require("../server/connections/a2a");
const a2a = require("../server/connections/a2a");
const { actsFor, aliasOf, taskText } = require("../server/connections/service");
const { actsFor, aliasOf, taskText } = require("../server/connections/service");
const { shortlistTools } = require("../server/runtime/brain/shortlist");
const { shortlistTools } = require("../server/runtime/brain/shortlist");
+const { createRegistry } = require("../server/runtime/tools");
const { createPrincipal } = require("../server/http/principal");
const { createPrincipal } = require("../server/http/principal");
const results = [];
const results = [];
@@ -89,10 +90,25 @@ function fakeMcp() {
@@ -89,10 +90,25 @@ function fakeMcp() {
try { await egress.assertPublicUrl(url, { protocols: ["https:"] }); } catch { refused = true; }
try { await egress.assertPublicUrl(url, { protocols: ["https:"] }); } catch { refused = true; }
assert.ok(refused, url + " was not refused");
assert.ok(refused, url + " was not refused");
}
}
+ // Node does not call the lookup for an IP literal, so checkUrl has to refuse it itself.
+ for (const url of ["https://127.0.0.1/mcp", "https://10.0.0.5/mcp", "https://192.168.1.4/mcp", "https://169.254.169.254/latest/meta-data", "https://[::1]/mcp", "https://[::ffff:127.0.0.1]/mcp"]) {
+ assert.throws(() => checkUrl(url), /not a public (address|host name)/, url + " was not refused by checkUrl");
+ }
assert.throws(() => checkUrl("http://example.com/mcp"), /not allowed/);
assert.throws(() => checkUrl("http://example.com/mcp"), /not allowed/);
assert.throws(() => checkUrl("https://example.com:8080/mcp"), /port/);
assert.throws(() => checkUrl("https://example.com:8080/mcp"), /port/);
});
});
+ await ok("a private IP literal is refused before any connection is made", async () => {
+ let hits = 0;
+ const server = https.createServer(cert(fs.mkdtempSync(path.join(os.tmpdir(), "actor-guard-"))), (req, res) => { hits++; res.end("internal"); });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const port = server.address().port;
+ try {
+ assert.throws(() => request(`https://127.0.0.1:${port}/mcp`, { ports: [String(port)], timeoutMs: 2000 }), /not a public address/);
+ assert.strictEqual(hits, 0, "the guard connected to a private address");
+ } finally { await new Promise((resolve) => server.close(resolve)); }
+ });
+
await ok("a fake MCP server lists its tools and answers a call", async () => {
await ok("a fake MCP server lists its tools and answers a call", async () => {
const fake = await fakeMcp();
const fake = await fakeMcp();
try {
try {
@@ -133,6 +149,32 @@ function fakeMcp() {
@@ -133,6 +149,32 @@ function fakeMcp() {
assert.strictEqual(shortlistTools(defs.slice(0, 10), state).length, 10, "below the limit every tool is offered");
assert.strictEqual(shortlistTools(defs.slice(0, 10), state).length, 10, "below the limit every tool is offered");
});
});
+ await ok("a connector tool follows the chat policy and asks before it acts", async () => {
+ const registry = createRegistry({ strict: true });
+ let calls = 0;
+ let gates = 0;
+ const tool = {
+ name: "mcp:cnx_TEST:write_note", alias: "mcp__cnx_TEST__write_note", scope: "person", acts: true,
+ input: { type: "object", properties: {} }, description: "Write a note.", run: async () => { calls++; return { text: "wrote" }; },
+ };
+ const ctx = {
+ requester: "user:usr_test", chatPolicy: "answer_only",
+ approveChatTool: async () => { gates++; return true; }, dynamicTools: new Map([[tool.alias, tool]]),
+ };
+ assert.equal((await registry.run(tool.alias, {}, ctx)).isError, true, "answer_only never reaches an acting connector");
+ assert.deepStrictEqual([calls, gates], [0, 0]);
+ // A person-scope connector is never offered to a group, even one that may act with approvals.
+ assert.equal((await registry.run(tool.alias, {}, { ...ctx, chatPolicy: "act_with_approvals" })).isError, true);
+ assert.deepStrictEqual([calls, gates], [0, 0]);
+ // In a direct message the person's approval is the gate: a refused or missing one runs nothing.
+ assert.equal((await registry.run(tool.alias, {}, { ...ctx, chatPolicy: "dm", approveChatTool: async () => { gates++; return false; } })).isError, true);
+ assert.deepStrictEqual([calls, gates], [0, 1]);
+ assert.equal((await registry.run(tool.alias, {}, { requester: ctx.requester, chatPolicy: "dm", dynamicTools: ctx.dynamicTools })).isError, true, "an acting tool without a person to ask does not run");
+ assert.strictEqual(calls, 0);
+ await registry.run(tool.alias, {}, { ...ctx, chatPolicy: "dm" });
+ assert.deepStrictEqual([calls, gates], [1, 2]);
+ });
+
await ok("the A2A card names the skills and the auth", async () => {
await ok("the A2A card names the skills and the auth", async () => {
const card = a2a.actorCard({ baseUrl: "https://openvibe.actor", version: "1.0" });
const card = a2a.actorCard({ baseUrl: "https://openvibe.actor", version: "1.0" });
assert.ok(card.skills.length >= 4, "the card has the skills");
assert.ok(card.skills.length >= 4, "the card has the skills");