diff --git a/docs/artifacts.md b/docs/artifacts.md new file mode 100644 index 0000000..8a9db76 --- /dev/null +++ b/docs/artifacts.md @@ -0,0 +1,34 @@ +# Artifacts — the viewer + +An agent can make pages for you and keep them updated: a plan, a table, a +chart, a small tool. Prax's `artifact_publish` is one example. The agent owns +them (writing, storing, versioning). **TeamWork only shows them.** + +- **In chat and notes.** A line that is exactly `[artifact:]` becomes a + live card. It reloads when the agent publishes a new version (polled every + 5 s), and can be opened full screen. +- **Where the page comes from.** `GET /api/artifacts/` proxies to the + agent (`{PRAX_URL}/teamwork/artifacts/`) behind TeamWork's login, like + the other agent panels. Unreachable → 503; unknown → 404. +- **How it is rendered, and why.** An artifact was written by an agent, + possibly while it was reading untrusted content, and TeamWork's origin holds + your session. So a page never runs on TeamWork's origin: + - it is loaded as `srcdoc` in an iframe with `sandbox="allow-scripts"` and + **no** `allow-same-origin`, so its scripts run in an opaque origin; + - a Content-Security-Policy injected first in its `` allows **no + network**, forms or base changes; the page's own tags can't loosen it, + because every policy applies; + - the frame sizes itself from a height message, accepted only from that + frame's own window. + + Verified in headless Chromium (2026-10-03). From inside the frame: + `parent.document`, `document.cookie` and `localStorage` all throw + `SecurityError`, `self.origin` is `"null"`, and `fetch` is refused by + `connect-src 'none'`. +- **Public links are the agent's business**, and need a person's decision. + Inside TeamWork an artifact is private only as long as TeamWork is: keep its + login on and reach it privately (see + [security/exposure.md](security/exposure.md)). + +Idea credit: Telepath's Television +([comparison](comparisons/television.md)). diff --git a/docs/security/exposure.md b/docs/security/exposure.md index f03c81d..9922cfd 100644 --- a/docs/security/exposure.md +++ b/docs/security/exposure.md @@ -20,6 +20,24 @@ guess is obscurity, not access control: URLs leak through browser history, screenshots, shared links, logs and referrer headers, and tunnel hostnames are scanned. +## Agents treat TeamWork as private — so it has to be + +An agent connected to TeamWork shows you its work here without asking: files, +notes, pages it made, its terminal and browser. That is the right default only +because TeamWork is meant to be private. Prax, for one, enforces exactly this +split: anything in TeamWork is fine, but putting something on a public link +needs a person's explicit decision every time +([Prax: public exposure](https://github.com/praxagent/prax/blob/main/docs/security/public-exposure.md)). + +That trust is something **you** provide when you deploy TeamWork. No agent can +check it from the inside. If TeamWork is reachable by people you don't trust, +everything an agent shows you here is reachable by them too. So: + +- keep the login on (`INTERNAL_API_KEY`, below); +- reach TeamWork privately (loopback, `tailscale serve`, an SSH tunnel); +- if you do put it behind a public tunnel, follow the checklist at the end + first. + ## What is open without a login Out of the box, TeamWork has **no login** (`INTERNAL_API_KEY` is empty and diff --git a/frontend/src/components/common/ArtifactCard.test.tsx b/frontend/src/components/common/ArtifactCard.test.tsx new file mode 100644 index 0000000..1d35d6c --- /dev/null +++ b/frontend/src/components/common/ArtifactCard.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { act, render } from '@testing-library/react'; +import { ArtifactFrame } from './ArtifactCard'; + +describe('ArtifactFrame', () => { + it('runs the page sandboxed: scripts yes, TeamWork\'s origin no', () => { + const { container } = render(); + const frame = container.querySelector('iframe')!; + expect(frame.getAttribute('sandbox')).toBe('allow-scripts'); + expect(frame.getAttribute('sandbox')).not.toContain('allow-same-origin'); + expect(frame.getAttribute('srcdoc')).toContain('Content-Security-Policy'); + expect(frame.getAttribute('srcdoc')).toContain('

Plan

'); + expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer'); + }); + + it('ignores size messages from any window but its own', () => { + const { container } = render(); + const frame = container.querySelector('iframe')!; + const before = frame.style.height; + act(() => { + window.dispatchEvent(new MessageEvent('message', { data: { tvArtifactHeight: 9999 }, source: window })); + }); + expect(frame.style.height).toBe(before); + }); +}); diff --git a/frontend/src/components/common/ArtifactCard.tsx b/frontend/src/components/common/ArtifactCard.tsx new file mode 100644 index 0000000..fd4aab6 --- /dev/null +++ b/frontend/src/components/common/ArtifactCard.tsx @@ -0,0 +1,111 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { clsx } from 'clsx'; +import { Maximize2, Minimize2, LayoutTemplate } from 'lucide-react'; +import { useArtifact, useArtifactMeta } from '@/hooks/useApi'; +import { prepareArtifactDocument } from '@/utils/artifacts'; + +/** + * A page the agent made, rendered in a sandboxed frame: `allow-scripts` and + * NOT `allow-same-origin`, so it runs in an opaque origin and cannot touch + * TeamWork's session, storage or /api/*; its CSP allows no network. See + * utils/artifacts.ts. + */ +export function ArtifactFrame({ html, title, maxHeight }: { html: string; title: string; maxHeight?: number }) { + const frameRef = useRef(null); + const [height, setHeight] = useState(240); + const doc = useMemo(() => prepareArtifactDocument(html), [html]); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + // Only this frame's own window may size it, and only with a number. + if (event.source !== frameRef.current?.contentWindow) return; + const reported = (event.data as { tvArtifactHeight?: unknown })?.tvArtifactHeight; + if (typeof reported === 'number' && Number.isFinite(reported)) { + setHeight(Math.max(80, Math.min(reported, 20_000))); + } + }; + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, []); + + return ( +