Skip to content

feat(file-preview): render local HTML files as sandboxed pages - #547

Open
ideaCompany wants to merge 3 commits into
Ark0N:masterfrom
ideaCompany:feat/html-file-view
Open

ideaCompany wants to merge 3 commits into
Ark0N:masterfrom
ideaCompany:feat/html-file-view

Conversation

@ideaCompany

Copy link
Copy Markdown

Why

Agents produce HTML all the time: test and coverage reports, benchmark charts, generated docs, prototypes, the "here is a dashboard of what I found" page. Today, when an agent prints the path to one of those files, Codeman can't show it:

  • clicked in the terminal, a .html path opens in the log viewer as raw source;
  • clicked in the response viewer / Files panel, it is served download-only by design.

On the machine running Codeman you can work around that by opening the file yourself. From any other device, the phone and remote-browser setups Codeman is built for, there is no workaround short of asking the agent to start a web server and then opening its localhost URL through a web tab. Codeman already makes remote localhost dev servers viewable through the web-tab proxy. This PR closes the matching gap for plain HTML files, which is the more common case for agent output.

I know html/htm being download-only is a deliberate invariant ("widening READ never widens RUN"). This PR does not touch it: file-raw, the attachment routes and the text preview still never serve HTML as HTML. It adds one separate, narrowly fenced surface whose whole design is "render it, but never same-origin".

What it does

Clicking an .html path (terminal, response viewer, Files panel) opens the page rendered in the existing file-preview overlay, with its relative CSS, scripts, images and data files working. The overlay's popout button opens the same page in its own tab.

How it stays safe

The danger with rendering HTML from Codeman's origin is that the page could read the Codeman document and drive the agent-spawning API. The design removes that rather than mitigating it:

  1. Opaque origin, always. Every /html-view response carries Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads, with no allow-same-origin. Because it is a response header, not just an iframe attribute, the page is opaque-origin even when opened as a top-level tab. Verified in Chromium: self.origin === 'null', window.parent.document and document.cookie throw, and fetch('/api/sessions') from the page is blocked.
  2. Capability, not cookie. An opaque-origin page's subresource requests carry no SameSite=lax cookie, so the route authenticates the same way the web-tab proxy does: POST /api/sessions/:id/html-view (normal auth, findSessionOrFail ownership) mints a 192-bit, memory-only capability with a rolling TTL. It is revoked on logout, admin logout and user deletion, next to webviewCapabilities.revokeOwner.
  3. One directory, read-only. A capability serves only the HTML file's own directory tree. Requests are GET/HEAD only. Each file is realpath-resolved and must stay inside that directory (symlinks out are refused). Dotfiles and dot-directories are never served, the extension must be on an asset allowlist (html, css, js, json, images, fonts, …), and the attachment blocklist is applied on top.
  4. Same admission rules as the file preview. The HTML file itself is admitted like a preview: inside the session workspace via validateSessionFilePath, or outside it under the attachment guard (blocked trees, attachmentConfineToWorkspace). Remote (SSH) cases get a clear 400 for now.
  5. The exemption is fenced. The auth middleware skips the cookie only for GET/HEAD on /html-view/<live cap>/…; nothing else gains a bypass. The production CSP for every other route is unchanged.

The one header that widens something is Access-Control-Allow-Origin: * (no credentials) on /html-view responses. It is needed because the opaque-origin page's fetch('data.json') of its own sibling file is CORS-checked with Origin: null. It exposes nothing beyond what the capability URL already serves.

Changes

  • src/html-view-capabilities.ts: capability store (mirrors webview-capabilities.ts) plus the path parser.
  • src/web/routes/html-view-routes.ts: mint route and serving route.
  • src/web/middleware/auth.ts: hasValidHtmlViewCapability() exemption in both auth branches.
  • session-routes.ts / admin-routes.ts: revoke on logout, admin logout and user delete.
  • panels-ui.js: HTML branch in openFilePreview() (sandboxed iframe; popout uses the same URL).
  • terminal-ui.js: .html terminal links go to the preview instead of the log viewer.
  • CLAUDE.md: the file-path-links invariant now names /html-view as the one place HTML renders.

Known limits

  • Root-absolute references inside the page (/style.css) do not resolve; relative ones do. Agent-generated pages almost always use relative paths.
  • Remote (SSH) cases are refused for now; they could follow the remote file-read path later.

Testing

  • test/routes/html-view-routes.test.ts (new, real temp files, no fs mocks): sandbox CSP without allow-same-origin, sibling and subdirectory assets served, and 404 for .env, assets/../.env, %2e%2e/…, ..%2f…, non-asset types and a symlink pointing outside the directory. Also covers unknown and revoked capabilities, non-HTML and missing files, and capability reuse.
  • npm test: 457 files / 8871 tests pass. typecheck, lint, format:check, check:frontend-syntax and check:browser-excludes are clean.
  • Manually, in Chromium against a built instance: page renders with CSS, JS, a relative fetch() and an image; the sandbox checks listed above hold. Remote localhost links through web tabs were checked too, unchanged.

If you'd rather discuss the design first (the contributing guide asks for that on bigger changes), I'm happy to move this to a Discussion. It is opened as a PR because the security argument is easier to judge with the actual code and tests in front of you.

🤖 Generated with Claude Code

Clicking an agent's `.html` path showed source text or downloaded it, so a
generated report could only be viewed by asking the agent to start a web
server. Render it instead, without serving HTML same-origin:

- POST /api/sessions/:id/html-view mints a capability for the file's own
  directory (workspace path, or outside it under the attachment guard).
- GET /html-view/:cap/* serves the page and its relative assets with
  `Content-Security-Policy: sandbox` WITHOUT allow-same-origin, so the page
  runs in an opaque origin (also as a top-level tab) and cannot read the
  Codeman document, its cookies or its API.
- Served files: non-dot, asset-allowlisted, realpath-confined to that
  directory, blocklist applied; GET only. Capabilities are memory-only with a
  rolling TTL and are revoked with the web-tab ones on logout.
- Frontend: the file preview renders HTML in a sandboxed iframe (popout opens
  the same URL); terminal `.html` links go to the preview, not the log viewer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for this, @ideaCompany, and for writing up the security argument so carefully. The PR renders local .html files as real pages in the file preview, served from a new capability-gated /html-view/<cap>/... route under a Content-Security-Policy: sandbox header without allow-same-origin.

The opaque-origin design holds up. I checked that the auth exemption only admits GET and HEAD with a live capability (POST and unknown capabilities still get 401), that .., encoded traversal, dotfiles and symlinks out of the directory are refused, and that typecheck, lint, format and the tests are green. A few things need to change before this can go in:

1. The capability serves a whole directory tree to the page's own scripts, with no egress limit (src/web/routes/html-view-routes.ts:131, :167)

The route replaces Codeman's CSP with sandbox ... only, so a rendered page can fetch() any host, and with Access-Control-Allow-Origin: * its scripts can read every non-dot, asset-typed file under the HTML file's directory, recursively. For /tmp/report.html (where agents often write reports) that is every matching file under /tmp, including other sessions' scratch output; for an HTML file in $HOME it is the home tree. I reproduced it with a deep/deeper/service-account.json next to an out-of-workspace page: 200 with Access-Control-Allow-Origin: *. An HTML file inside a dot directory is admitted as well (.cache/r.html minted fine and served its sibling token.json), because the no-dot rule only applies below the capability root. Please:

  • refuse to mint for broad roots (/, the home directory, os.tmpdir(), the cases root and the user-space roots), or serve out-of-workspace pages non-recursively;
  • apply the dot-segment rule to the HTML file's own resolved path too;
  • leave the CSP itself alone for now: how much network access a rendered page gets is my call, and I answer that below.

2. HTML loses its source view and the File Viewer editor (src/web/public/panels-ui.js:4123)

The new branch returns before the file-content path, so an in-workspace .html opened from the Files panel, the response viewer or the terminal can no longer be read as source or edited, although html and htm are in EDITABLE_EXTENSIONS (src/config/file-editing.ts:46). Remote (SSH) cases get worse too: the Files panel used to show their .html source and now shows the 400 "cannot be rendered" error. Please render by default but keep a Source toggle (the MD pill on markdown previews is the pattern to copy) that falls through to the existing text path with its Edit button, and fall through to it automatically when the mint request fails.

3. The new auth exemption and revocations need tests (src/web/middleware/auth.ts:159, src/web/routes/session-routes.ts:853, src/web/routes/admin-routes.ts:187 and :211)

The route tests are good, but nothing pins the middleware side. Please add html-view cases to test/webview-auth-exemption.test.ts (a live capability passes GET and HEAD; POST or PUT on the capability path, an unknown or revoked capability and a lookalike /html-viewx/... prefix all get 401) and to test/webview-capability-revocation.test.ts for logout, admin logout and user deletion. A test for the out-of-workspace mint branch (a blocked tree and attachmentConfineToWorkspace both answer 403) closes the remaining gap in test/routes/html-view-routes.test.ts.

4. Docs that now say the opposite

  • docs/wiki/Working-With-Files.md:103 says ".html previews as source rather than being rendered, so nothing served this way can execute in the page".
  • docs/architecture-invariants.md (File-path links) still says markup stays download-only and that in-workspace text keeps the tail viewer, and the new CLAUDE.md text links there.
  • docs/security-architecture.md lists every route that skips cookie auth; /html-view belongs next to the web-tab proxy.
  • The comment at src/web/routes/file-routes.ts:244.

Smaller things (I can take these at merge time if you prefer):

  • Media in a rendered page cannot seek, because the route streams without Range support. Reusing sendFileBody() from file-routes.ts gives you that (it is module-private today, so it needs an export), or drop the media types from ASSET_TYPES.
  • src/web/public/sw.js:103 skips only /api/, so a popped-out page (a top-level navigation the service worker intercepts) lands in CacheStorage despite Cache-Control: no-store, and the offline fallback can serve it after logout revoked the capability. Skip /html-view/ there too.
  • The new helper in auth.ts was inserted between hasValidWebviewCapability's JSDoc and the function, so that doc block now sits above the wrong function.
  • The HTML check lives in three places (HTML_VIEW_EXTENSIONS, the regex at terminal-ui.js:1985, the extension check at panels-ui.js:4123). A rendersAsPage() helper next to previewsInFileViewer() in constants.js keeps the two frontend copies in one place.
  • Validate the mint body with a Zod schema in schemas.ts, and add the html-view count to the admin logout audit line.

On network access for rendered pages: whether a page keeps open network access (agent dashboards often load Chart.js from a CDN) or is limited to Codeman's own origin is a policy decision for me, and I will post it here before you touch the CSP. Items 1 (scope), 2, 3 and 4 do not depend on it. Once those are in, I will re-review and merge.

ideaCompany and others added 2 commits October 8, 2026 09:15
- Refuse to mint for an HTML file in a hidden directory or directly in a
  broad root (/, home, tmpdir, cases and user-space roots): the capability
  serves the directory tree recursively to the page's own scripts.
- Keep HTML's source view and editor: a Page pill (per-device htmlRendered,
  like MD) toggles to the file-content text path, and a refused mint (remote
  case, broad root, hidden dir) falls through to it automatically.
- Tests: html-view auth exemption edges, revocation on logout / admin logout /
  user deletion, out-of-workspace mint (blocked tree, confinement), hidden
  and broad roots.
- Docs: Working-With-Files, architecture-invariants, security-architecture,
  the file-routes comment and CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ideaCompany

Copy link
Copy Markdown
Author

Thanks for the thorough review, and for reproducing the scope problem. I had missed that one. Items 1 to 4 are in, and master is merged in (it was 41 commits behind, no conflicts). I've left the CSP alone and will wait for your decision on network access.

1. Scope

  • Minting is refused (403) for an HTML file that sits directly in a broad root: /, the home directory, os.tmpdir(), the cases root, the user-spaces root, and a user's own space and its cases dir (isBroadHtmlViewRoot(), all compared after realpath). The message tells the user to move the file into a folder of its own.
  • The dot-segment rule now applies to the HTML file's own resolved path too, so .cache/r.html is refused (403).
  • I went with refusing broad roots rather than serving out-of-workspace pages non-recursively, because agent reports often keep their assets in a subfolder. Happy to switch if you prefer the other option.

2. Source view and editor

  • HTML still renders by default. A Page pill (copied from the MD pill, per-device htmlRendered pref) switches to the existing file-content text path, where the Edit button works again.
  • A refused mint (remote SSH case, broad root, hidden directory) falls through to that source view automatically and hides the pill, since retrying would just be refused again.

3. Tests

  • test/webview-auth-exemption.test.ts: a live capability passes GET and HEAD. POST and PUT on the capability path, an unknown or revoked capability, and the lookalike /html-viewx/... prefix all get 401. Web-tab and html-view capabilities don't open each other's routes.
  • test/webview-capability-revocation.test.ts: HtmlViewCapabilityStore.revokeOwner, plus logout (single-user and multi-user), admin logout and user deletion.
  • test/routes/html-view-routes.test.ts: the out-of-workspace mint branch (a blocked tree and attachmentConfineToWorkspace both answer 403), hidden directories in and outside the workspace, and broad roots.

4. Docs
docs/wiki/Working-With-Files.md, docs/architecture-invariants.md (File-path links, including the terminal's .html exception to the tail viewer), docs/security-architecture.md (an /html-view entry next to the web-tab proxy) and the file-routes.ts comment. The CLAUDE.md text is updated to match.

I've left the smaller items (Range support or dropping media types, the sw.js skip, the misplaced JSDoc, rendersAsPage(), Zod and the audit count) for you to take at merge time, as you offered.

Checks: typecheck, lint, format, frontend syntax and browser-excludes are clean, and the full npm test passes (469 files, 9147 tests). I also drove it in Chromium against a built instance: the page renders, Page → source with Edit visible → page again, and a page in /tmp falls back to source with the pill hidden.

@Ark0N

Ark0N commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Thanks for the quick turnaround, @ideaCompany. This PR renders a clicked .html file as a page in the file preview, served from a capability-gated /html-view/<cap>/... route under a sandbox CSP without allow-same-origin.

I checked round 2 against the code, and all four items from my last review are in. The design holds up under probing: crafted /html-view/<cap>/../../api/... URLs never reach another route, a write with Origin: null is refused, and the sandbox CSP replaces the global one with the production header hook in place. Typecheck, lint, format, frontend syntax, browser-excludes, lockfile and the full npm test (469 files, 9147 tests) are green here too.

Three things before this goes in:

1. The broad-root check misses /tmp whenever TMPDIR is set (src/web/routes/html-view-routes.ts:104)

os.tmpdir() returns $TMPDIR when it is set, and macOS sets it in every terminal session (/var/folders/.../T). With TMPDIR set, isBroadHtmlViewRoot('/tmp') and isBroadHtmlViewRoot('/private/tmp') return false, and /var/tmp, /home and WSL's /mnt/d are never refused. So on a Mac where the server was started from a terminal, /tmp/report.html still gets a capability over all of /private/tmp. Please do one of these:

  • serve out-of-workspace pages non-recursively (the other option from my first review), which closes the whole class;
  • or add literal /tmp and /var/tmp to the list through canonical() (so macOS gets /private/tmp), refuse any directory that is an ancestor of a broad root (that covers /home and /Users), and add a test that sets TMPDIR to another directory and asserts /tmp is still refused.

2. Symlinks get around the dot and extension rules (src/web/routes/html-view-routes.ts:199-214)

The dot check and the extension allowlist run on the requested name; after realpathSync only containment and the blocklist are checked. A symlink cfg.json -> .git/config inside the page's folder is served as 200 application/json. Please apply the dot rule to the segments of relative(record.rootDir, resolvedPath) and the extension allowlist to the resolved file name, and add that case to test/routes/html-view-routes.test.ts.

3. A test for the frontend half (src/web/public/panels-ui.js:4129)

The HTML branch in openFilePreview(), the Page pill and the fallback to the source view on a refused mint have no test. test/file-preview-markdown.test.ts already loads constants.js and panels-ui.js under jsdom for the MD pill, so a stubbed fetch covers it:

  • a successful mint renders an iframe whose sandbox attribute has no allow-same-origin;
  • a refused mint shows the source view with the Page pill hidden;
  • the pill flips between the two views and persists codeman:filePreviewHtmlRendered.

A small one while you are in the route: the serving route checks the blocklist but not confineToWorkspace (html-view-routes.ts:213), while the attachment serve path re-checks it on every request (file-routes.ts:337). A capability minted before an admin turns confinement on keeps serving the outside folder. Recording at mint time whether the root is outside the workspace, and refusing it at serve time when confinement is on, closes that.

I will take these at merge time, together with last round's list (Range support, the sw.js skip, the JSDoc placement, rendersAsPage(), Zod and the audit count):

  • await fs.realpath instead of realpathSync in the serving route (html-view-routes.ts:207);
  • the dot rule scoped to the part below the workspace root for in-workspace files (html-view-routes.ts:165), so a workspace under a hidden folder can still render;
  • a CORS preflight answer for /html-view (src/web/middleware/auth.ts:733);
  • the GET /html-view/:cap/* entry in docs/api-reference.md:44.

On network access: I still owe you the decision on what rendered pages may reach, and I will post it here separately. It does not change the three items above.

Once 1 to 3 are in, I will re-review and merge.

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