GitHub App mode keeps the RSA key in the server environment and persists only the installation ID inside the encrypted credential store. Every operation mints a fresh installation token with Contents/Metadata read permissions. Installation selection is operator-only and verifies App ownership, suspension, and token issuance. The server operator is trusted for every installation of the configured App; separate tenants require separate app/server configuration. Repository selection validates the branch remotely. API redirects are refused. OAuth mode remains available with its existing broader scope.
Automatic Slack messages require saved opt-in settings, can filter successful rehearsals, and contain structured counts/evidence IDs without assertions, logs, model text, or raw execution errors. Delivery is bounded and never changes a controller outcome. Disconnect clears notification preferences. Last automatic delivery status is memory-only, with no retry queue. Manual Slack posts use the same structured summary and safe transport errors. Webhook redirects are refused.
Status: no known CRITICAL or HIGH findings open under the threat model below. This is not a claim of being "fully secure" - no honest audit can make that claim. Last remediation: 2026-11-01, after a strict multi-agent audit (3 CRITICAL, 23 HIGH/MEDIUM/LOW findings).
In scope: untrusted repository content (prompt injection, malicious files, symlinks, hostile compose files), a malicious or mistaken model, unauthenticated network callers, and secret leakage into model context, reports, or logs.
Out of scope: an attacker with local filesystem or Docker-daemon access to the host running HandoffProof (they already own the machine), compromise of the Meta API or of pinned upstream images, and physical access.
- Only
internal/controller(+internal/drills) issues pass/fail and writes evidence. The model proposes maps, diagnoses, and repair diffs; it never sets verdicts. - Protected paths (
acceptance/**,internal/controller/**,internal/drills/**,internal/evidence/**,internal/agent,internal/scan,internal/report,internal/score,cmd,prompts,evidence/**,.manifest.sha256,scripts/manifest.sh) are hash-frozen in.manifest.sha256and verified in Go (ManifestVerify) before and after drills and reruns. The verifier never executes the script it verifies. - Repair diffs apply only to a sandbox copy, only under
app/, and only afterChangedPaths/DiffTouchesProtectedscreening (rename/copy/quoted/case/./normalization, traversal rejection).
- C1 - unsanitized app compose executed. The controller now validates the app
compose against a denylist (privileged, docker.sock,
network_mode: host, devices,cap_add, host pid/ipc, host bind mounts) and generates its own override: drill sandboxes publish no ports and run oninternal: truenetworks; limits (memory/CPU/pids,no-new-privileges) are forced. Only Playwright reruns republish, bound to127.0.0.1only. Verified: all four drills pass in this mode. - C2 - unauthenticated console could launch Docker drills. The console binds
127.0.0.1:8787by default and always requires a token (HANDOFF_CONSOLE_TOKEN, or a random one printed at startup). Sessions use an HttpOnly, SameSite=Strict cookie holding only the token's SHA-256. POSTs require anX-HP-Requestheader (CSRF), APIs are rate-limited (240/min/client), jobs expire after 30 minutes and time out at 15. Verified live: 401 unauthenticated, 403 without the header, 200 with token; covered bycmd/handoffproof/serve_test.go. - C3 - demo stack exposed on 0.0.0.0, shared network. All published ports bind
127.0.0.1viaHANDOFF_*_PORT; services carry resource limits andno-new-privileges. Sandbox traffic never leaves the internal network: the controller reaches services withdocker exec(execFetch), not host ports.
- H1 - secret files could enter model context. New
internal/secretpathclassifier denies by meaning: any.envvariant, key material (.pem/.key/.p12/.keystore/...), credential stores (.npmrc,.pgpass,credentials.json, service-account files, private keys). Enforced in the static scanner, the agent'sread_file/list_files/search_code, and citation checks. Only.env.example/.sample/.template/.distpass. - H2 - redaction was an allowlist of spellings.
agent.Redactnow classifies by credential shape: private keys, AWS/GitHub/Slack/API-key and JWT shapes, anyAuthorizationvalue, generic scheme connection strings, and quoted or shortkey=valuecredentials under any field name (JSON keys, camelCase, long names). Provider error text is redacted and truncated before surfacing. Regression tests probe variants that were not in the original pattern list (internal/agent/redact_test.go). - Symlink escapes. Scanner and agent walks never follow symlinks;
Executor.safePathresolves symlinks and requires the real path to stay inside the real workspace; the sandbox copy fails closed on any symlink in the app tree. - H3 - diff parser bypasses.
ChangedPathshandles rename/copy lines, quoted paths,./prefixes,/dev/null, and case-insensitive dedupe; protected-prefix checks run on normalized paths. Tests:TestChangedPathsEdgeCases,TestDiffTouchesProtected. - H4 - host environment leaked to compose.
composeEnvBase()passes an allowlist (PATH/HOME and tool basics) plus explicit per-run variables; the host's full environment (including API keys) is never inherited. - H5 - predictable sandbox tokens. Mail/Admin/Scheduler tokens are crypto-random
per run (
genToken); nothing depends on documented dev defaults inside sandboxes. - H6 - unbounded resources. Compose limits in both dev and sandbox modes; sink caps bodies at 1 MB and stores at most 1,000 messages; demo app caps JSON at 1 MB and uploads at 1 MB with input validation; outbound mail fetch times out at 5 s.
- H7 - integrity claims were asserted, not measured. Report
HashMatchis nowManifestVerify(root) == nilat render time, and the assessment prompt receives controller-measured before/after hashes plus both verify results.CheckCitationsresolves every citedevidence_idagainst real controller records - fabricated IDs fail (unknown_ids); citations escaping the workspace, pointing at symlinks, or at credential files are invalid. - H8 - console XSS via innerHTML. Every data-driven interpolation in
cmd/handoffproof/web/index.htmlpasses throughesc(); responses carry CSP (frame-ancestors 'none',base-uri 'none'),nosniff,no-referrer,X-Frame-Options: DENY, andCache-Control: no-storeon APIs. Report HTML ishtml/template-escaped and every free-text field is redacted (internal/report/report.go). - H10/H11 - sink and demo-app abuse surface. Sink
GET /messagesrequires the admin token wheneverMAIL_SINK_REQUIRE_AUTH=1(always set in sandboxes); admin comparisons are constant-time;/healthno longer discloses message counts. Demo app validates registration input and caps payload sizes. - H12 - boundary coverage. Evidence package 0% → 100%, score 2.2% → 73.1%, scan 91.7%, secretpath 100%; console auth/CSRF/rate-limit tests added. Controller coverage (11.3%) is mostly Docker-bound paths, exercised by the four live drills instead of unit tests.
- H13 - supply chain. Demo image builds with
npm cifrom the committed lockfile; both images run asUSER node; CI workflows declarepermissions: contents: read.
Security headers and job TTL/timeout on the console; report redaction extended to all fields; mail-sink timing-safe admin checks and quota caps; demo input validation; sandbox teardown errors logged instead of swallowed; free-port failures clean up the sandbox directory; Go-native manifest verification (no shelling out to the verified script).
-
Copy link asks the server (
POST /api/share, console auth plus theX-HP-Requestheader) to mint a read-only share link. The id is 128 random bits held only in server memory, valid for 24 hours, capped at 50 live links, and forgotten on restart. Anyone with the link can read the report and the console's GET views with no sign-in, cookie, or token typing - treat the link itself as a read credential and send it only to intended readers. A share id authorizes GET requests only: it cannot run drills, post to Slack, or mint more links, and unknown ids get 401. -
Download PDF uses the report's themed print stylesheet. The report's evidence table is paginated on screen, but print output expands every row and hides the pager, so a saved PDF is the complete record set.
-
Add to Slack / Post to Slack uses Slack OAuth v2 with only the
incoming-webhookscope. Connecting, posting, and disconnecting require a console operator; read-only share links can do none of them. The OAuth callback accepts only a one-time (10-minute) state value matching the HttpOnlyhp_slack_statecookie and the server-side state record. Only the channel webhook Slack returns is stored - AES-GCM encrypted in a server-side file outside the repo (HANDOFF_SLACK_STORE, key fromHANDOFF_SLACK_ENCRYPTION_KEYor an auto-generated key file beside it) - and OAuth bot/user access tokens are discarded, never stored. Disconnect deletes the stored webhook (it does not uninstall the Slack app). Posting sends only a server-built summary (evidence counts, pass/fail totals, manifest integrity, up to six controller evidence IDs); webhook URLs, tokens, links, and model text are never posted or returned to the browser. Write endpoints require console auth plus theX-HP-Requestheader and the same 240/min rate limit as every other API.HANDOFF_SLACK_WEBHOOKremains as an operator-managed fallback only. -
Connect GitHub uses GitHub's OAuth web application flow with PKCE (S256) and the
reposcope. Connecting, listing repositories, saving the repository/branch, and disconnecting require a console operator plus theX-HP-Requestheader for writes. The callback accepts only a one-time (10-minute) state matching the HttpOnlyhp_github_statecookie. The access/refresh tokens are AES-GCM encrypted in a server-side file outside the repo (HANDOFF_GITHUB_STORE, key fromHANDOFF_GITHUB_ENCRYPTION_KEYor an auto-generated key file beside it) and are never returned to the browser; status responses expose only account, repository, branch, and scope. A chosen repository is verified against the GitHub API before it is saved. Disconnect deletes the stored tokens (it does not revoke the OAuth app in GitHub). Settings shows integration status to console viewers, but non-operators receive no working write controls and read-only share links remain GET-only. -
Bring Your Own App imports run only for console operators. Imported source is copied with secret-path denial, symlink rejection, and size/file caps; GitHub archives are size-capped, path-checked, and accepted only from the GitHub API/codeload hosts. Profiles carry no secret values and no shell commands; wizard environment values are AES-GCM encrypted (
HANDOFF_APPLICATIONS_STORE/HANDOFF_APPLICATIONS_ENCRYPTION_KEY). Compose files are rejected for privileged mode, Docker socket, host mounts, host networking, devices, or extra capabilities; services are verified against the parsed Compose model; sandboxes publish no ports, use an internal network, and carry resource limits. SQL workflow steps accept a singleSELECT COUNTonly, and backup commands are allowlisted argv without a shell. Verdicts remain controller-issued evidence records. Bring Your Own App runs only applications the operator trusts, and that Compose validation is text-based and not a complete isolation guarantee.
- Set
HANDOFF_CONSOLE_TOKEN(16+ chars) beforehandoffproof serve; keep the default loopback bind. Expose beyond loopback only behind your own TLS proxy. - Keep
.envout of the repository (git-ignored). The Meta key is read from the environment only and is never logged. - Run
bash scripts/check.shbefore use; it re-verifies the protected-path manifest. Re-freeze withbash scripts/manifest.sh createonly for reviewed controller changes. - Treat every report, diagnosis, and diff as untrusted output until the controller rerun passes. The console states this in its footer; keep it that way.
- Prompt injection can still influence model text (never verdicts, evidence, or accepted diffs - those gates are deterministic).
- Pattern redaction is best-effort: the deny-by-meaning path classifier is the hard boundary; redaction is defense in depth, not a proof.
- Docker and npm supply chains are trusted at their pinned tags/lockfiles; image digest pinning is a recommended follow-up.
- The console token is a single shared secret (no per-user accounts); suitable for a single-operator handover tool, not a multi-tenant service.