Skip to content

[APPS-2962] Add: report the app Content Security Policy from the apps dev server - #611

Open
tyffical wants to merge 1 commit into
masterfrom
tiffany.trinh/apps-2962-local-app-csp
Open

tyffical wants to merge 1 commit into
masterfrom
tiffany.trinh/apps-2962-local-app-csp

Conversation

@tyffical

@tyffical tyffical commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Architecture

flowchart TD
    browser["Browser: GET page or worker script"] --> csp["createAppCspMiddleware, registered in configureServer"]
    csp -->|"server.proxy path: passed on unwrapped"| vite["Vite's own middlewares: index HTML, public dir, transforms"]
    csp -->|"other paths: drop If-None-Match, wrap writeHead, write, end and flushHeaders"| vite
    vite -->|"text/html, or a Sec-Fetch-Dest worker"| policy["Content-Security-Policy-Report-Only beside any policy already set: APP_CSP_DIRECTIVES, plus the React Refresh preamble hash and the HMR WebSocket origins"]
    vite -->|"anything else"| unchanged["Sent unchanged"]
    html["transformIndexHtml, dev only"] --> logger["Logger script: logs each securitypolicyviolation once"]
Loading
  • APP_CSP_DIRECTIVES copies web-ui's getAppCsp at 228286c; check-app-csp-drift compares them locally, since CI can't read web-ui.

Changes

11 changes across the dev server CSP files, index.ts, the drift script, README.md and tests
What changed File
Adds the CSP middleware. dev-server-csp.ts
Adds the dev-only violation logger. dev-server-csp-violations.ts
Holds the copied production directives. app-csp.ts
Registers both, off under Vitest, with the logger tag in the same pre-order transformIndexHtml hook as the dev client. index.ts
Skips both for appType: 'custom' hosts such as Storybook. index.ts
Exports getRequestHost. dev-server-request-guard.ts
Adds the check-app-csp-drift script. check-app-csp-drift.js, package.json
Documents the policy and its known gaps. README.md
Tests the middleware, logger and drift script. dev-server-csp.test.ts, dev-server-csp.integration.test.ts, dev-server-csp-violations.test.ts, check-app-csp-drift.test.ts
Adds test fixtures, including an http.get helper. dev-server-csp.fixtures.ts
Updates the guard and configureServer tests. dev-server-request-guard.test.ts, index.test.ts

QA Instructions

  • Local checks pass at 66c1dde5: the commands below, and the header, console warning and HMR rows in headless Chromium on a plain Vite 6.4.3 app with a stand-in React Refresh preamble. The backend function row passes at the same sha in headless Chromium on a Vite 8 template app against a Datadog staging org; the Chrome rows are from 298fe58b, in Chrome on that app with datadog-apps dev. Setup: build-plugins: Testing and QA Guide.
yarn install && yarn build:all && yarn typecheck:all && yarn lint && yarn cli integrity
# Expected: all exit 0, integrity leaves no changes to commit
export TMPDIR=$(cd $TMPDIR && pwd -P)/  # macOS only, for two build tests
yarn test:unit packages/plugins/apps
# Expected: all pass
yarn test:unit
# Expected: all pass
yarn workspace @dd/apps-plugin check-app-csp-drift <web-ui checkout> 228286c7c3d8dd295ea8936190fc6a54308fde25
# Expected: The app CSP copy matches packages/apps/app-builder/lib/sandbox-shim/csp.ts at 228286c7c3d8dd295ea8936190fc6a54308fde25. ✅ VERIFIED
yarn workspace @dd/apps-plugin check-app-csp-drift <web-ui checkout> 1ba1c67d708cb9e8cae5681e0b48f4ac1b17a0dd
# Expected: exit 1, "production: connect-src 'self'" / "copy: connect-src 'self' https:" ✅ VERIFIED
Check Result Evidence
Response header Content-Security-Policy-Report-Only with the React Refresh preamble hash; no enforcing header datadog-apps dev; headless Chromium
Console warnings One per #csp-probes probe, each of which still runs: inline (script-src-elem), eval (script-src), http://example.com/ (connect-src), https://www.datadoghq.com/favicon.ico (img-src), blob (worker-src), https://example.com (frame-src) Chrome console; headless Chromium
Backend function "Hello, Qa App! This greeting ran as a backend function." Page
HMR "Count v1" edited to "Count v2" shows "Count v2: 1", no reload Page; headless Chromium
@vitejs/plugin-react-swc with base: '/qa-base/' The same six warnings and HMR; the logger loads from /qa-base/@id/… ahead of the dev client, both 200 vite.swc.config.ts
Vite 8 bundled dev mode Report-only header and HMR, but no [Datadog Apps] warnings (README known gap) vite.bundled.config.ts
appType: 'custom' (middleware-mode server) No CSP header, no violations Manual run
Control: master plugin No CSP header, every probe runs, no violations Manual run
  • The inline warning: [Datadog Apps] The app Content Security Policy would block inline (script-src-elem). Uploaded apps get this policy when the sandbox shim serves them.
  • A URL worker's violation gets no warning, since the page can't see it; the worker script still gets the report-only header.
Manual QA reproduction
  • In a template app's vite.config.ts, import datadogVitePlugin from <build-plugins checkout>/packages/published/vite-plugin/dist/src/index.mjs.

  • index.html, in <head>:

<script>window.__qaInlineRan = true;</script>
  • src/main.tsx, after the imports:
import { runCspProbes } from './cspProbes';

void runCspProbes();
  • src/App.tsx: add const [count, setCount] = useState(0); and render:
<span id="greeting">{greetingText}</span>
<button id="counter" onClick={() => setCount(count + 1)}>
    <span id="hmr-label">Count v1</span>: {count}
</button>
  • src/cspProbes.ts:
type ProbeResult = { probe: string; outcome: string };

const violations: string[] = [];
document.addEventListener('securitypolicyviolation', (event) => {
    violations.push(`${event.effectiveDirective} ${event.blockedURI}`);
});

const withTimeout = <T>(promise: Promise<T>, fallback: T, ms = 3000) =>
    Promise.race([promise, new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))]);

const probeImage = () =>
    withTimeout(
        new Promise<string>((resolve) => {
            const image = new Image();
            image.onload = () => resolve('ran');
            image.onerror = () => resolve('blocked');
            image.src = 'https://www.datadoghq.com/favicon.ico';
        }),
        'timeout',
    );

const probeBlobWorker = () =>
    withTimeout(
        new Promise<string>((resolve) => {
            try {
                const url = URL.createObjectURL(
                    new Blob(['postMessage("hi")'], { type: 'text/javascript' }),
                );
                const worker = new Worker(url);
                worker.onmessage = () => resolve('ran');
                worker.onerror = () => resolve('blocked');
            } catch (error) {
                resolve(`blocked: ${(error as Error).name}`);
            }
        }),
        'blocked (no message)',
    );

const probeIframe = () =>
    withTimeout(
        new Promise<string>((resolve) => {
            const before = violations.length;
            const frame = document.createElement('iframe');
            frame.src = 'https://example.com/';
            frame.onload = () => {
                const blocked = violations.slice(before).some((v) => v.startsWith('frame-src'));
                resolve(blocked ? 'blocked' : 'loaded');
            };
            document.body.appendChild(frame);
        }),
        'timeout',
    );

const probeUrlWorkerFetch = () =>
    withTimeout(
        new Promise<string>((resolve) => {
            const worker = new Worker(new URL('./probeWorker.ts', import.meta.url), {
                type: 'module',
            });
            worker.onmessage = (event) => resolve(`worker http: fetch ${event.data}`);
            worker.onerror = () => resolve('worker failed to start');
        }),
        'timeout',
    );

export async function runCspProbes() {
    const results: ProbeResult[] = [];
    results.push({
        probe: 'inline classic script',
        outcome: (window as { __qaInlineRan?: boolean }).__qaInlineRan ? 'ran' : 'blocked',
    });
    try {
        // eslint-disable-next-line no-eval
        eval('1 + 1');
        results.push({ probe: 'eval', outcome: 'ran' });
    } catch (error) {
        results.push({ probe: 'eval', outcome: `blocked: ${(error as Error).name}` });
    }
    try {
        await fetch('http://example.com/', { mode: 'no-cors' });
        results.push({ probe: 'http: fetch', outcome: 'ran' });
    } catch (error) {
        results.push({ probe: 'http: fetch', outcome: `blocked: ${(error as Error).name}` });
    }
    results.push({ probe: 'https: image', outcome: await probeImage() });
    results.push({ probe: 'blob: worker', outcome: await probeBlobWorker() });
    results.push({ probe: 'iframe', outcome: await probeIframe() });
    results.push({ probe: 'URL worker', outcome: await probeUrlWorkerFetch() });

    const output = document.createElement('pre');
    output.id = 'csp-probes';
    output.textContent = JSON.stringify({ results, violations }, null, 2);
    document.body.appendChild(output);
    console.log('csp-probes', JSON.stringify({ results, violations }));
}
  • src/probeWorker.ts:
fetch('http://example.com/', { mode: 'no-cors' })
    .then(() => postMessage('ran'))
    .catch((error: Error) => postMessage(`blocked: ${error.name}`));
  • vite.bundled.config.ts (bundled dev mode run):
import { defineConfig, mergeConfig } from 'vite';

import baseConfig from './vite.config';

export default mergeConfig(baseConfig, defineConfig({ experimental: { bundledDev: true } }));
  • vite.swc.config.ts: vite.config.ts with @vitejs/plugin-react-swc and base: '/qa-base/'.

  • Run:

npx datadog-apps dev                                                    # http://localhost:5173/
npx vite --config vite.bundled.config.ts --port 5174 --strictPort      # bundled dev mode
npx vite --config vite.swc.config.ts --port 5175 --strictPort          # swc, /qa-base/
  • Read #csp-probes and the console on each page; for HMR, click the counter and edit Count v1.
Headless Chromium check (agent-runnable)
  • App: the reproduction's index.html (plus <span id="hmr-label">Count v1</span> in <body>), src/cspProbes.ts and src/probeWorker.ts; src/label.ts exports label = 'Count v1', which src/main.ts renders into #hmr-label and accepts with import.meta.hot.accept('./label', …).
  • vite.config.mjs:
import { defineConfig } from 'vite';
import { datadogVitePlugin } from '<build-plugins checkout>/packages/published/vite-plugin/dist/src/index.mjs';

const preamble = [
    'import { injectIntoGlobalHook } from "/@react-refresh";',
    'injectIntoGlobalHook(window);',
    'window.$RefreshReg$ = () => {};',
    'window.$RefreshSig$ = () => (type) => type;',
].join('\n');

// Stands in for @vitejs/plugin-react, which isn't installed here: same preamble, same hook order.
const reactPreamblePlugin = () => ({
    name: 'qa-react-preamble',
    resolveId: (id) => (id === '/@react-refresh' ? '\0qa-react-refresh' : null),
    load: (id) => (id === '\0qa-react-refresh' ? 'export const injectIntoGlobalHook = () => { window.__qaPreambleRan = true; };' : null),
    transformIndexHtml: () => [{ tag: 'script', attrs: { type: 'module' }, children: preamble }],
});

export default defineConfig({
    plugins: [reactPreamblePlugin(), datadogVitePlugin({ apps: { enable: true } })],
});
  • browser-qa.js:
const fs = require('fs');
const path = require('path');
const { chromium } = require(process.env.PLAYWRIGHT_PATH);

const [url, appRoot] = process.argv.slice(2);
const labelPath = path.join(appRoot, 'src/label.ts');

(async () => {
    const browser = await chromium.launch();
    const page = await browser.newPage();
    const consoleLines = [];
    page.on('console', (message) => consoleLines.push(`${message.type()}: ${message.text()}`));
    await page.addInitScript(() => {
        window.__allViolations = [];
        document.addEventListener('securitypolicyviolation', (event) => {
            window.__allViolations.push(`${event.effectiveDirective} ${event.blockedURI}`);
        });
    });
    const response = await page.goto(url);
    const headers = response.headers();
    console.log('status:', response.status());
    console.log('enforcing header:', headers['content-security-policy'] ?? 'none');
    console.log('report-only header present:', Boolean(headers['content-security-policy-report-only']));
    await page.waitForSelector('#csp-probes', { timeout: 30000 });
    const probes = JSON.parse(await page.textContent('#csp-probes'));
    console.log('probe results:', JSON.stringify(probes.results));
    console.log('preamble ran:', await page.evaluate(() => window.__qaPreambleRan === true));

    console.log('violations before the HMR edit:', JSON.stringify(await page.evaluate(() => window.__allViolations)));
    await page.evaluate(() => {
        window.__qaKept = 'no-reload';
    });
    fs.writeFileSync(labelPath, "export const label = 'Count v2';\n");
    await page.waitForFunction(
        () => document.getElementById('hmr-label').textContent === 'Count v2',
        null,
        { timeout: 20000 },
    );
    console.log('after edit, label:', await page.textContent('#hmr-label'));
    console.log('after edit, no reload:', await page.evaluate(() => window.__qaKept === 'no-reload'));
    fs.writeFileSync(labelPath, "export const label = 'Count v1';\n");

    console.log('all violations seen from page start:', JSON.stringify(await page.evaluate(() => window.__allViolations)));
    console.log('[Datadog Apps] console lines:');
    for (const line of consoleLines.filter((entry) => entry.includes('[Datadog Apps]'))) {
        console.log('  ' + line);
    }
    await browser.close();
})().catch((error) => {
    console.error(error);
    fs.writeFileSync(labelPath, "export const label = 'Count v1';\n");
    process.exit(1);
});
  • Run, with no Datadog credentials in the environment:
npx vite --port 5191 --strictPort
PLAYWRIGHT_PATH=<build-plugins checkout>/node_modules/playwright node browser-qa.js http://localhost:5191/ <app root>
  • Output at 66c1dde5; the trailing script-src eval entries come from Playwright's waitForFunction polling:
status: 200
enforcing header: none
report-only header present: true
probe results: [{"probe":"inline classic script","outcome":"ran"},{"probe":"eval","outcome":"ran"},{"probe":"http: fetch","outcome":"ran"},{"probe":"https: image","outcome":"ran"},{"probe":"blob: worker","outcome":"ran"},{"probe":"iframe","outcome":"blocked"},{"probe":"URL worker","outcome":"worker http: fetch ran"}]
preamble ran: true
violations before the HMR edit: ["script-src-elem inline","script-src eval","connect-src http://example.com/","connect-src http://example.com/","img-src https://www.datadoghq.com/favicon.ico","worker-src blob","worker-src blob","frame-src https://example.com","frame-src https://example.com"]
after edit, label: Count v2
after edit, no reload: true
all violations seen from page start: ["script-src-elem inline","script-src eval","connect-src http://example.com/","connect-src http://example.com/","img-src https://www.datadoghq.com/favicon.ico","worker-src blob","worker-src blob","frame-src https://example.com","frame-src https://example.com","script-src eval","script-src eval","script-src eval","script-src eval","script-src eval","script-src eval","script-src eval"]
[Datadog Apps] console lines:
  warning: [Datadog Apps] The app Content Security Policy would block inline (script-src-elem). Uploaded apps get this policy when the sandbox shim serves them.
  warning: [Datadog Apps] The app Content Security Policy would block eval (script-src). Uploaded apps get this policy when the sandbox shim serves them.
  warning: [Datadog Apps] The app Content Security Policy would block http://example.com/ (connect-src). Uploaded apps get this policy when the sandbox shim serves them.
  warning: [Datadog Apps] The app Content Security Policy would block https://www.datadoghq.com/favicon.ico (img-src). Uploaded apps get this policy when the sandbox shim serves them.
  warning: [Datadog Apps] The app Content Security Policy would block blob (worker-src). Uploaded apps get this policy when the sandbox shim serves them.
  warning: [Datadog Apps] The app Content Security Policy would block https://example.com (frame-src). Uploaded apps get this policy when the sandbox shim serves them.
Backend function row, headless (agent-runnable)
  • The Vite 8 template app (its App.tsx renders getGreeting("Qa App")) with this branch's packed plugin, served by vite with DD_SITE, DD_API_KEY and DD_APP_KEY for a Datadog staging org; backend-row.js runs without the keys:
// Loads the scaffold's page in headless Chromium and waits for its backend-function greeting.
// Usage: PLAYWRIGHT_PATH=<playwright> node backend-row.js <url>
const { chromium } = require(process.env.PLAYWRIGHT_PATH);

(async () => {
    const browser = await chromium.launch();
    const page = await browser.newPage();
    const violations = [];
    const calls = [];
    await page.exposeFunction('__qaViolation', (line) => violations.push(line));
    await page.addInitScript(() => {
        document.addEventListener('securitypolicyviolation', (event) =>
            window.__qaViolation(`${event.effectiveDirective} ${event.blockedURI}`),
        );
    });
    page.on('response', (response) => {
        if (response.url().includes('/__dd/')) calls.push(`${response.request().method()} ${new URL(response.url()).pathname} -> ${response.status()}`);
    });
    const response = await page.goto(process.argv[2]);
    const headers = response.headers();
    console.log(`report-only header present: ${Boolean(headers['content-security-policy-report-only'])}`);
    console.log(`enforcing header: ${headers['content-security-policy'] ?? 'none'}`);
    const greeting = page.getByText('This greeting ran as a backend function.');
    await greeting.waitFor({ timeout: 60000 });
    console.log(`page text: ${await greeting.textContent()}`);
    console.log(`backend calls: ${JSON.stringify(calls)}`);
    console.log(`violations: ${JSON.stringify(violations)}`);
    await browser.close();
})().catch((error) => {
    console.error(String(error));
    process.exit(1);
});
node node_modules/vite/bin/vite.js --port 5611 --strictPort &
PLAYWRIGHT_PATH=<build-plugins checkout>/node_modules/playwright node backend-row.js http://localhost:5611/
report-only header present: true
enforcing header: none
page text: Hello, Qa App! This greeting ran as a backend function.
backend calls: ["POST /__dd/executeAction -> 200"]
violations: []

Blast Radius

  • No feature flag and no opt-out: every app dev server, local and dev-verify; Vitest and appType: 'custom' hosts are exempt.
  • Low risk: report-only, so nothing is blocked; production builds and uploads are unchanged.

Out of Scope / Follow-ups

5 items deferred
Item Status Next step
Live Preview drops the CSP header. Deferred Per-preview host plan.
vite preview sends no policy. Deferred Add it to configurePreviewServer if needed.
Proxy keys with a bypass, and list-form writeHead, aren't covered. Accepted Restore if a real Vite path needs them.
Edge cases:
  • logger blames other policies
  • server.origin not allowed
  • HMR fallback port
  • HMR fallback host
  • non-UTF-8 bodies
  • drift check on a shallow web-ui clone
  • logger below other plugins' prepended inline scripts
Deferred Tiffany picks fixes.
Enforce the policy once the sandbox shim covers production. Deferred New PR.

Documentation

🤖 Generated with Claude Code

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2962-local-app-csp branch 2 times, most recently from 514a601 to 298fe58 Compare October 23, 2026 13:37
@tyffical tyffical changed the title [APPS-2962] Add: enforce the app Content Security Policy in the apps dev server [APPS-2962] Add: report the app Content Security Policy from the apps dev server Oct 23, 2026
@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Pipelines  Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 6c87182 | Docs | View more details | Give us feedback!

@tyffical
tyffical requested a balanced review from Copilot October 23, 2026 15:24
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T15:28:12.327442Z 298fe58 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@tyffical
tyffical requested a review from oliverli October 23, 2026 15:26
@tyffical
tyffical marked this pull request as ready for review October 23, 2026 15:26
@tyffical
tyffical requested a review from a team as a code owner October 23, 2026 15:26
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 298fe58bb2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Response-method interception across Vite versions and browser-specific CSP behavior warrant final human validation despite the reported passing tests and manual checks.

No open issues 🎉

What changed in this PR

Adds report-only Content Security Policy checks to the Datadog Apps Vite dev server, helping developers discover sandbox-policy violations before uploading apps without blocking local execution.

Changes:

  • Adds CSP headers to HTML and worker responses, with allowances for React Refresh and HMR.
  • Injects a deduplicated browser-console logger, excluding Vitest and custom HTML hosts.
  • Adds policy-drift tooling, unit and integration coverage, and documentation of known limitations.
File Description
packages/​plugins/​apps/​src/​vite/​raw-http.fixtures.ts Adds a socket-based HTTP test client to avoid nock interference.
packages/​plugins/​apps/​src/​vite/​index.ts Registers CSP middleware and the dev-only logger.
packages/​plugins/​apps/​src/​vite/​index.test.ts Tests middleware registration and exemptions.
packages/​plugins/​apps/​src/​vite/​dev-server-request-guard.ts Exports the shared request-host helper.
packages/​plugins/​apps/​src/​vite/​dev-server-request-guard.test.ts Tests Host and HTTP/2 authority handling.
packages/​plugins/​apps/​src/​vite/​dev-server-csp.ts Implements report-only headers and development allowances.
packages/​plugins/​apps/​src/​vite/​dev-server-csp.test.ts Tests policy generation and HTTP response handling.
packages/​plugins/​apps/​src/​vite/​dev-server-csp.integration.test.ts Tests CSP and logger integration with Vite.
packages/​plugins/​apps/​src/​vite/​dev-server-csp.fixtures.ts Provides preamble and policy test helpers.
packages/​plugins/​apps/​src/​vite/​dev-server-csp-violations.ts Adds the browser violation logger and injection tag.
packages/​plugins/​apps/​src/​vite/​dev-server-csp-violations.test.ts Tests warning messages, deduplication, and script tags.
packages/​plugins/​apps/​src/​vite/​check-app-csp-drift.test.ts Tests drift detection and script error handling.
packages/​plugins/​apps/​src/​vite/​app-csp.ts Stores and formats the copied sandbox policy.
packages/​plugins/​apps/​scripts/​check-app-csp-drift.js Compares the policy copy against a web-ui checkout.
packages/​plugins/​apps/​README.md Documents report-only behavior and coverage gaps.
packages/​plugins/​apps/​package.json Exposes the policy-drift command.

Review effort: Balanced

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2962-local-app-csp branch 6 times, most recently from 66c1dde to fca924d Compare October 28, 2026 16:52
HTML pages and worker scripts from the apps dev server now carry, as
Content-Security-Policy-Report-Only, the policy the sandbox shim applies
to uploaded apps, minus frame-ancestors. Inline scripts, eval, http:
requests, https: images, blob: workers, frames and form posts that
production would block are reported locally before upload, without
being blocked. A policy already on the response is kept. The policy is
off under Vitest and for appType 'custom' hosts such as Storybook, which
serve their own HTML, and server.proxy paths, matched as Vite's proxy
matches them, are left alone.

script-src also allows the inline React Refresh preamble by the hash of
the served text, and connect-src allows the HMR WebSocket. A dev-only
script logs each violation with the rule involved. A
check-app-csp-drift script compares the copied directives with their
source in a web-ui checkout.

Refs: APPS-2962

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2962-local-app-csp branch from fca924d to 6c87182 Compare October 28, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants