Repository Public Research preview
1// Offline tests: no Safari, no network. Run with `npm test`.2import assert from "node:assert/strict";3import { mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";4import { tmpdir } from "node:os";5import { dirname, join } from "node:path";6import { test } from "node:test";7import { fileURLToPath } from "node:url";8import { Client } from "@modelcontextprotocol/sdk/client/index.js";9import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";10import { Safari } from "../lib/safari.mjs";1112const root = dirname(dirname(fileURLToPath(import.meta.url)));13const tmp = mkdtempSync(join(tmpdir(), "safari-mcp-off-"));14const code = (fn) => { try { fn(); return null; } catch (e) { return e.code; } };1516test("URL policy: schemes, credentials, allow and block lists", () => {17 const s = new Safari({});18 assert.equal(code(() => s.checkUrl("https://example.org/a", { navigating: true })), null);19 assert.equal(code(() => s.checkUrl("http://127.0.0.1:8080/", { navigating: true })), null);20 assert.equal(code(() => s.checkUrl("javascript:alert(1)", { navigating: true })), "url_forbidden");21 assert.equal(code(() => s.checkUrl("data:text/html,<h1>x</h1>", { navigating: true })), "url_forbidden");22 assert.equal(code(() => s.checkUrl("file:///etc/passwd", { navigating: true })), "url_forbidden");23 assert.equal(code(() => s.checkUrl("ftp://example.org/", { navigating: true })), "url_forbidden");24 assert.equal(code(() => s.checkUrl("https://user:pw@example.org/", { navigating: true })), "url_forbidden");25 assert.equal(code(() => s.checkUrl("not a url", { navigating: true })), "url_invalid");26 assert.equal(code(() => s.checkUrl("about:blank")), null);27 assert.equal(code(() => new Safari({ SAFARI_MCP_ALLOW_FILE_URLS: "1" }).checkUrl("file:///tmp/a.html", { navigating: true })), null);2829 const allow = new Safari({ SAFARI_MCP_ALLOW_DOMAINS: "example.org, Docs.Example.com" });30 assert.equal(code(() => allow.checkUrl("https://example.org/")), null);31 assert.equal(code(() => allow.checkUrl("https://sub.example.org/x")), null);32 assert.equal(code(() => allow.checkUrl("https://docs.example.com/")), null);33 assert.equal(code(() => allow.checkUrl("https://example.com/")), "domain_not_allowed");34 assert.equal(code(() => allow.checkUrl("https://evil-example.org/")), "domain_not_allowed"); // suffix match is on label boundaries35 assert.equal(code(() => allow.checkUrl("https://example.org.evil.net/")), "domain_not_allowed");3637 const block = new Safari({ SAFARI_MCP_BLOCK_DOMAINS: "bank.example", SAFARI_MCP_ALLOW_DOMAINS: "bank.example" });38 assert.equal(code(() => block.checkUrl("https://www.bank.example/login")), "domain_blocked"); // the block list wins39});4041test("file access is confined to the file root, through symlinks too", () => {42 writeFileSync(join(tmp, "inside.txt"), "x");43 symlinkSync("/etc/hosts", join(tmp, "link-out"));44 const s = new Safari({ SAFARI_MCP_FILE_ROOT: tmp });45 assert.ok(s.insideRoot("inside.txt", true).endsWith("inside.txt"));46 assert.ok(s.insideRoot("new/shot.png", false).endsWith("shot.png"));47 assert.equal(code(() => s.insideRoot("../outside.txt", false)), "path_forbidden");48 assert.equal(code(() => s.insideRoot("/etc/hosts", true)), "path_forbidden");49 assert.equal(code(() => s.insideRoot("link-out", true)), "path_forbidden");50});5152test("defaults are the safe ones", () => {53 const s = new Safari({});54 assert.equal(s.scope, "owned");55 assert.equal(s.allowEval, false);56 assert.equal(s.allowFileUrls, false);57 assert.equal(new Safari({ SAFARI_MCP_SCOPE: "everything" }).scope, "owned");58 assert.equal(new Safari({ SAFARI_MCP_ALLOW_EVAL: "true" }).allowEval, false); // only the exact value "1" enables it59});6061test("the in-page helper is self-contained and reads no cookies or storage", () => {62 const src = readFileSync(join(root, "lib/page.js"), "utf8");63 new Function(src); // parses64 assert.ok(!/document\.cookie|localStorage|sessionStorage|indexedDB/.test(src));65 assert.ok(!/XMLHttpRequest|importScripts|new Function|document\.write/.test(src));66 assert.match(src, /\[redacted\]/);67 const osa = readFileSync(join(root, "lib/osa.mjs"), "utf8") + readFileSync(join(root, "lib/safari.mjs"), "utf8");68 assert.ok(!/(^|[^.\w])exec\(|execSync|shell: ?true|\bsh -c\b/m.test(osa)); // execFile only: no shell, no interpolation into AppleScript69 assert.ok(!/do shell script/.test(osa));70});7172test("protocol: tools, annotations and safety wording (does not touch Safari)", async () => {73 const c = new Client({ name: "offline", version: "1" });74 await c.connect(new StdioClientTransport({ command: process.execPath, args: [join(root, "server.mjs")], env: { PATH: process.env.PATH, SAFARI_MCP_FILE_ROOT: tmp }, stderr: "pipe" }));75 const { tools } = await c.listTools();76 assert.equal(tools.length, 30);77 for (const t of tools) { assert.ok(t.description.length > 40, t.name); assert.ok(t.annotations, t.name); }78 const by = Object.fromEntries(tools.map((t) => [t.name, t]));79 for (const n of ["snapshot", "read_text", "list_links", "extract_tables", "page_info", "wait_for"]) assert.equal(by[n].annotations.readOnlyHint, true, n);80 for (const n of ["submit_form", "close_tab", "evaluate_js"]) assert.equal(by[n].annotations.destructiveHint, true, n);81 assert.match(by.fill.description, /confirm_sensitive/);82 assert.match(by.submit_form.description, /only when the user asked/);83 assert.match(c.getInstructions(), /untrusted/);84 // Refusals that need no Safari: bad URLs and the disabled evaluator.85 const bad = await c.callTool({ name: "open_url", arguments: { url: "javascript:alert(1)" } });86 assert.equal(bad.structuredContent.error.code, "url_forbidden");87 const file = await c.callTool({ name: "open_url", arguments: { url: "file:///etc/passwd" } });88 assert.equal(file.structuredContent.error.code, "url_forbidden");89 await c.close();90});91