driggsby 0.7.0 → 0.7.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.
@@ -0,0 +1,78 @@
1
+ // The security headers on every app-origin response, the service worker
2
+ // the app origin hands out, and the policy the host page requires of its
3
+ // frame: a copy of what Driggsby's serving host and console do for a
4
+ // deployed app, so the preview refuses what a deployed app is refused and a
5
+ // page that works here works once deployed. The Driggsby service is
6
+ // canonical: change it there first, then mirror it here.
7
+ //
8
+ // The content security policy keeps the page's loads on its own origin
9
+ // (another site's script, font, image, or fetch is refused), refuses frames,
10
+ // objects, forms, and eval, and sandboxes the page as the serving host does.
11
+ // frame-ancestors names the host page, the only page that may frame the
12
+ // app. A file that can run script (a page, an SVG, JavaScript) also carries
13
+ // Connection-Allowlist, which has Chrome refuse any connection the page
14
+ // starts to anywhere else, WebRTC included. Any other file gets neither the
15
+ // list nor script: its sandbox grants nothing, so it opens in an origin of
16
+ // its own, as it does deployed. Never widen the list to other files; the
17
+ // serving host sends it only where script can run.
18
+ const SCRIPT_MEDIA_TYPES = new Set([
19
+ "text/html",
20
+ "image/svg+xml",
21
+ "text/javascript",
22
+ "application/javascript",
23
+ ]);
24
+ const POLICY_DIRECTIVES = [
25
+ "default-src 'self'",
26
+ "script-src 'self' 'unsafe-inline'",
27
+ "style-src 'self' 'unsafe-inline'",
28
+ "img-src 'self' data: blob:",
29
+ "font-src 'self' data:",
30
+ "connect-src 'self'",
31
+ "frame-src 'none'",
32
+ "object-src 'none'",
33
+ "form-action 'none'",
34
+ "base-uri 'none'",
35
+ ];
36
+ // What the host page requires of every document its frame shows (the
37
+ // iframe's csp attribute), as the console does: it binds even a document
38
+ // that doesn't come from this server.
39
+ export const FRAME_POLICY = POLICY_DIRECTIVES.join("; ");
40
+ // The only service worker either preview origin hands out, verbatim from
41
+ // the serving host. An app's own worker could answer the frame's
42
+ // navigations with documents it builds, carrying none of these headers, and
43
+ // on these fixed ports it would outlive the preview and answer the next
44
+ // app's too; a worker some other local server once left on the host port
45
+ // would answer the host page, which runs tools. This one listens for
46
+ // nothing.
47
+ export const INERT_SERVICE_WORKER = `// This origin's only service worker. It listens for nothing, so it answers
48
+ // no request: every page and file comes from the network with the host's
49
+ // own headers. It behaves like any default worker: it waits until no page
50
+ // is using the worker before it, then takes over.
51
+ `;
52
+ // hostOrigin is empty until the host server has bound (a startup race
53
+ // measured in milliseconds); until then nothing may frame the app.
54
+ export function appResponseHeaders(hostOrigin, contentType) {
55
+ const mediaType = (contentType.split(";")[0] ?? "").trim().toLowerCase();
56
+ const runsScript = SCRIPT_MEDIA_TYPES.has(mediaType);
57
+ const directives = [
58
+ runsScript ? "sandbox allow-scripts allow-same-origin" : "sandbox",
59
+ ...POLICY_DIRECTIVES,
60
+ `frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin}`,
61
+ ];
62
+ const headers = {
63
+ "Content-Security-Policy": directives.join("; "),
64
+ // The served content types are final; the browser never guesses.
65
+ "X-Content-Type-Options": "nosniff",
66
+ "Referrer-Policy": "no-referrer",
67
+ "Cross-Origin-Resource-Policy": "same-origin",
68
+ "Cross-Origin-Opener-Policy": "same-origin",
69
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
70
+ };
71
+ if (hostOrigin !== "") {
72
+ headers["Allow-CSP-From"] = hostOrigin;
73
+ }
74
+ if (runsScript) {
75
+ headers["Connection-Allowlist"] = "(response-origin)";
76
+ }
77
+ return headers;
78
+ }
@@ -5,6 +5,7 @@
5
5
  // only. The app token never appears here — the broker callback holds it in
6
6
  // the CLI process.
7
7
  import { createServer } from "node:http";
8
+ import { appResponseHeaders, INERT_SERVICE_WORKER } from "./app-policy.js";
8
9
  import { DEV_IDENTITY_PATH } from "./dev-state.js";
9
10
  import { hostPageHtml, HOST_PAGE_JS } from "./host-page.js";
10
11
  import { serveStaticFile } from "./static-files.js";
@@ -15,13 +16,16 @@ import { APP_TOOL_ALLOWLIST } from "./tool-allowlist.js";
15
16
  export const GENERIC_TOOL_TROUBLE = "We couldn't run that just now. Try again in a moment.";
16
17
  export const TOOL_NOT_ALLOWED_MESSAGE = "That tool isn't available to Driggsby apps. Apps can use read-only tools like get_overview and list_accounts.";
17
18
  const MAX_TOOL_CALL_BODY_BYTES = 65_536;
19
+ const PLAIN_TEXT = "text/plain; charset=utf-8";
20
+ const JAVASCRIPT = "text/javascript; charset=utf-8";
18
21
  export async function startDevServers(options) {
19
22
  // Both origins bind and advertise 127.0.0.1: advertising "localhost"
20
23
  // while binding only the IPv4 loopback breaks on machines whose browser
21
24
  // resolves localhost to ::1 first.
22
25
  let appPortForGuard = 0;
23
- // Filled in once the host server binds; app responses embed it in their
24
- // frame-ancestors policy so only the real host page can frame the app.
26
+ // Filled in once the host server binds; app responses name it in their
27
+ // frame-ancestors and Allow-CSP-From, so only the real host page can
28
+ // frame the app.
25
29
  let hostOriginForFraming = "";
26
30
  const appServer = createServer((request, response) => {
27
31
  void handleAppRequest(options, appPortForGuard, hostOriginForFraming, request, response);
@@ -70,39 +74,41 @@ export async function startDevServers(options) {
70
74
  // The app origin: the SDK path, then the project's own files.
71
75
  // ---------------------------------------------------------------------------
72
76
  async function handleAppRequest(options, appPort, hostOrigin, request, response) {
73
- // Only the real host page may frame the app: without this, any other
74
- // loopback-served page could embed the app origin, pin itself as the
75
- // app's host, and feed it fabricated numbers. Until the host server has
76
- // bound (a startup race measured in milliseconds), nothing may frame it.
77
- // nosniff keeps the browser honest about the served content types.
78
- const appHeaders = {
79
- "Content-Security-Policy": `frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin};`,
80
- "X-Content-Type-Options": "nosniff",
81
- };
77
+ // Every response carries the deployed app's headers (app-policy.ts). Their
78
+ // frame-ancestors lets only the real host page frame the app: without it,
79
+ // any other loopback-served page could embed the app origin, pin itself
80
+ // as the app's host, and feed it fabricated numbers.
81
+ const plainTextHeaders = appResponseHeaders(hostOrigin, PLAIN_TEXT);
82
82
  // Same rebinding guard as the host origin: a page on another site that
83
83
  // tricked DNS into pointing here carries its own Host, and fails.
84
84
  if (!hostHeaderIsLocal(request.headers.host, appPort)) {
85
- sendText(response, 403, "text/plain; charset=utf-8", "Forbidden.", appHeaders);
85
+ sendText(response, 403, PLAIN_TEXT, "Forbidden.", plainTextHeaders);
86
+ return;
87
+ }
88
+ // Every service worker script request, whatever path it names, gets the
89
+ // inert worker (app-policy.ts), as deployed. Browsers mark each one.
90
+ if (isServiceWorkerScriptRequest(request)) {
91
+ sendText(response, 200, JAVASCRIPT, INERT_SERVICE_WORKER, appResponseHeaders(hostOrigin, JAVASCRIPT));
86
92
  return;
87
93
  }
88
94
  const path = requestPath(request);
89
95
  if (request.method !== "GET" && request.method !== "HEAD") {
90
- sendText(response, 405, "text/plain; charset=utf-8", "Method not allowed.", appHeaders);
96
+ sendText(response, 405, PLAIN_TEXT, "Method not allowed.", plainTextHeaders);
91
97
  return;
92
98
  }
93
99
  if (path === "/-/driggsby-sdk.js") {
94
- sendText(response, 200, "text/javascript; charset=utf-8", options.sdkBundle, appHeaders);
100
+ sendText(response, 200, JAVASCRIPT, options.sdkBundle, appResponseHeaders(hostOrigin, JAVASCRIPT));
95
101
  return;
96
102
  }
97
103
  const file = await serveStaticFile(options.serveDirectory, path);
98
104
  if (file === null) {
99
- sendText(response, 404, "text/plain; charset=utf-8", "Not found.", appHeaders);
105
+ sendText(response, 404, PLAIN_TEXT, "Not found.", plainTextHeaders);
100
106
  return;
101
107
  }
102
108
  response.writeHead(file.status, {
103
109
  "Content-Type": file.contentType,
104
110
  "Cache-Control": "no-store",
105
- ...appHeaders,
111
+ ...appResponseHeaders(hostOrigin, file.contentType),
106
112
  });
107
113
  response.end(file.body);
108
114
  }
@@ -117,6 +123,12 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
117
123
  sendJson(response, 403, { ok: false, error: { message: "Forbidden." } });
118
124
  return;
119
125
  }
126
+ // The host page registers no worker, so a worker script request here is
127
+ // a foreign one; the inert worker replaces it (app-policy.ts).
128
+ if (isServiceWorkerScriptRequest(request)) {
129
+ sendText(response, 200, JAVASCRIPT, INERT_SERVICE_WORKER);
130
+ return;
131
+ }
120
132
  const path = requestPath(request);
121
133
  if (request.method === "GET" && path === DEV_IDENTITY_PATH) {
122
134
  // Lets `dev --stop` (and `dev`) confirm that a record in ~/.driggsby
@@ -129,7 +141,7 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
129
141
  return;
130
142
  }
131
143
  if (request.method === "GET" && path === "/host.js") {
132
- sendText(response, 200, "text/javascript; charset=utf-8", HOST_PAGE_JS);
144
+ sendText(response, 200, JAVASCRIPT, HOST_PAGE_JS);
133
145
  return;
134
146
  }
135
147
  if (request.method === "GET" && path === "/events") {
@@ -150,7 +162,7 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
150
162
  await handleToolCall(options, hostPort, request, response);
151
163
  return;
152
164
  }
153
- sendText(response, 404, "text/plain; charset=utf-8", "Not found.");
165
+ sendText(response, 404, PLAIN_TEXT, "Not found.");
154
166
  return;
155
167
  }
156
168
  async function handleToolCall(options, hostPort, request, response) {
@@ -209,6 +221,9 @@ function requestPath(request) {
209
221
  const queryStart = url.indexOf("?");
210
222
  return queryStart === -1 ? url : url.slice(0, queryStart);
211
223
  }
224
+ function isServiceWorkerScriptRequest(request) {
225
+ return request.headers["service-worker"] === "script" || request.headers["sec-fetch-dest"] === "serviceworker";
226
+ }
212
227
  function hostHeaderIsLocal(host, port) {
213
228
  return host === `localhost:${String(port)}` || host === `127.0.0.1:${String(port)}`;
214
229
  }
@@ -5,6 +5,7 @@
5
5
  // inside Driggsby. The page carries no token and no data; every tool call
6
6
  // goes to the CLI's own /tool-calls endpoint, which holds the token and
7
7
  // enforces the allowlist and bounds.
8
+ import { FRAME_POLICY } from "./app-policy.js";
8
9
  // The page's script is served as its own file so the page can carry a
9
10
  // Content-Security-Policy with no inline scripts.
10
11
  //
@@ -14,7 +15,11 @@
14
15
  // `appOrigin` is built by startDevServers from a literal plus a bound port
15
16
  // number, and `background` has passed readProjectConfig's strict hex-color
16
17
  // rule (or is null). Anything looser than those sources must not be
17
- // passed here.
18
+ // passed here. FRAME_POLICY is a constant with no double quote in it.
19
+ //
20
+ // The frame gets the console's sandbox and, as its csp attribute, the
21
+ // deployed policy (app-policy.ts), which Chrome then requires of every
22
+ // document the frame shows, even one this server didn't send.
18
23
  export function hostPageHtml(slug, appOrigin, background) {
19
24
  return `<!doctype html>
20
25
  <html lang="en" data-app-origin="${appOrigin}">
@@ -84,7 +89,7 @@ export function hostPageHtml(slug, appOrigin, background) {
84
89
  <span class="slug">${slug}</span>
85
90
  <span class="mode">driggsby dev — your live data</span>
86
91
  </header>
87
- <iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
92
+ <iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin" csp="${FRAME_POLICY}"></iframe>
88
93
  <script type="module" src="/host.js"></script>
89
94
  </body>
90
95
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driggsby",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Set up and build on Driggsby, the MCP server for personal finance.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "node": ">=18"
29
29
  },
30
30
  "dependencies": {
31
- "@driggsby/deploy": "0.7.0",
32
- "@driggsby/sdk": "0.7.0"
31
+ "@driggsby/deploy": "0.7.1",
32
+ "@driggsby/sdk": "0.7.1"
33
33
  }
34
34
  }