diff --git a/ASSURANCE_CASE.md b/ASSURANCE_CASE.md
index ccd924f..97c8679 100644
--- a/ASSURANCE_CASE.md
+++ b/ASSURANCE_CASE.md
@@ -88,6 +88,7 @@ The following maps [OWASP Top 10](https://owasp.org/www-project-top-ten/) and [C
| Weakness | Applicability | Countermeasure |
|----------|---------------|----------------|
| **A03:2021 Injection** (CWE-78 OS Command Injection) | All `exec.Command` calls use `git` with explicit argument lists — no shell interpolation. `--end-of-options` prevents flag injection. | Mitigated |
+| **A03:2021 Injection** (CWE-79 Cross-site Scripting) | Viewer template output is HTML-escaped by `html/template`. Defense-in-depth: every viewer response carries a strict Content-Security-Policy (`default-src 'self'`, no `unsafe-inline`) plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and `Permissions-Policy` (`internal/viewer/securityheaders.go`). | Mitigated |
| **A01:2021 Broken Access Control** (CWE-22 Path Traversal) | Agent file-read tool validates paths with `pathutil.WithinBase()` before and after symlink resolution (`internal/tool/filereader.go:91-112`). | Mitigated |
| **A02:2021 Cryptographic Failures** | All API communication uses HTTPS/TLS 1.2+. Go's default TLS configuration is used without weakening. `InsecureSkipVerify` is never set. | Mitigated |
| **A07:2021 Auth Failures** (CWE-798 Hard-coded Credentials) | API keys are read exclusively from environment variables, never embedded in code or config files, never logged. | Mitigated |
diff --git a/internal/viewer/securityheaders.go b/internal/viewer/securityheaders.go
new file mode 100644
index 0000000..ff9428a
--- /dev/null
+++ b/internal/viewer/securityheaders.go
@@ -0,0 +1,35 @@
+package viewer
+
+import "net/http"
+
+// contentSecurityPolicy locks the viewer down to first-party resources only.
+// The viewer loads no third-party scripts, styles, fonts, or frames, so a
+// strict same-origin policy holds without any 'unsafe-inline' relaxation
+// (the previously-inline session script now lives in static/session.js).
+// This mitigates injection of active content should any user- or LLM-supplied
+// value ever escape HTML escaping in a template.
+const contentSecurityPolicy = "default-src 'self'; " +
+ "script-src 'self'; " +
+ "style-src 'self'; " +
+ "img-src 'self' data:; " +
+ "object-src 'none'; " +
+ "base-uri 'none'; " +
+ "frame-ancestors 'none'; " +
+ "form-action 'none'"
+
+// securityHeaders wraps a handler and sets defense-in-depth response headers on
+// every reply. These harden the local viewer's browser-facing surface (the
+// session JSONL exposed here contains reviewed source code and the LLM's
+// analysis of it). HSTS is intentionally omitted: the viewer serves plain HTTP
+// on loopback, where HSTS is meaningless and would wrongly pin localhost.
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("Content-Security-Policy", contentSecurityPolicy)
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("Referrer-Policy", "no-referrer")
+ h.Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/viewer/securityheaders_test.go b/internal/viewer/securityheaders_test.go
new file mode 100644
index 0000000..4c65e42
--- /dev/null
+++ b/internal/viewer/securityheaders_test.go
@@ -0,0 +1,54 @@
+package viewer
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSecurityHeadersSetsAllHeaders(t *testing.T) {
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ securityHeaders(inner).ServeHTTP(rec, req)
+
+ want := map[string]string{
+ "Content-Security-Policy": contentSecurityPolicy,
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "Referrer-Policy": "no-referrer",
+ "Permissions-Policy": "geolocation=(), camera=(), microphone=()",
+ }
+ for k, v := range want {
+ if got := rec.Header().Get(k); got != v {
+ t.Errorf("header %q = %q, want %q", k, got, v)
+ }
+ }
+}
+
+// The viewer serves plain HTTP on loopback; HSTS would wrongly pin localhost.
+func TestSecurityHeadersOmitsHSTS(t *testing.T) {
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ securityHeaders(inner).ServeHTTP(rec, req)
+
+ if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
+ t.Errorf("HSTS should not be set on the loopback viewer, got %q", got)
+ }
+}
+
+// The CSP must stay strict: no 'unsafe-inline'/'unsafe-eval' relaxation, since
+// the formerly-inline session script now lives in static/session.js.
+func TestContentSecurityPolicyIsStrict(t *testing.T) {
+ for _, bad := range []string{"unsafe-inline", "unsafe-eval", "*"} {
+ if strings.Contains(contentSecurityPolicy, bad) {
+ t.Errorf("CSP unexpectedly contains %q: %s", bad, contentSecurityPolicy)
+ }
+ }
+}
diff --git a/internal/viewer/server.go b/internal/viewer/server.go
index b84e2d6..e73789f 100644
--- a/internal/viewer/server.go
+++ b/internal/viewer/server.go
@@ -11,7 +11,7 @@ import (
"time"
)
-//go:embed templates/*.html static/style.css
+//go:embed templates/*.html static/style.css static/session.js
var assets embed.FS
func StartServer(addr string) error {
@@ -54,9 +54,12 @@ func StartServer(addr string) error {
allowed := resolveAllowedHostsFromEnv(addr)
guarded := hostGuard(allowed, mux)
+ // Outermost layer: set defense-in-depth security headers on every response.
+ handler := securityHeaders(guarded)
+
srv := &http.Server{
Addr: addr,
- Handler: guarded,
+ Handler: handler,
}
fmt.Printf("\nOpen browser: http://%s\n", DisplayAddr(addr))
diff --git a/internal/viewer/static/session.js b/internal/viewer/static/session.js
new file mode 100644
index 0000000..800ab98
--- /dev/null
+++ b/internal/viewer/static/session.js
@@ -0,0 +1,24 @@
+document.querySelectorAll('.response-text').forEach(function(el) {
+ const text = el.textContent;
+ const esc = function(s) {
+ return s.replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ };
+ const codeBlocks = [];
+ let html = esc(text);
+ html = html.replace(/
+ html = html
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/^### (.+)$/gm, '
' + code + '');
+ });
+ el.innerHTML = html;
+});
diff --git a/internal/viewer/templates/session.html b/internal/viewer/templates/session.html
index 8afa663..9858bcd 100644
--- a/internal/viewer/templates/session.html
+++ b/internal/viewer/templates/session.html
@@ -231,35 +231,7 @@
{{end}}
-
+