Email josanderson25@gmail.com with "graft security" in the subject. Include what you did, what happened, and which version or commit you were on. Please do not open a public issue for anything exploitable.
You will get an acknowledgement within 72 hours and an assessment within a week.
These are the assumptions the design rests on. A deployment that breaks one is outside what the controls below cover.
-
Git is authoritative for authored content, and commit access is a trusted privilege. Content in the repository has been through whatever review the repository requires.
-
Agents are semi-trusted. They may author content and invoke functions. They may not decide their own destructive operations, and no agent runtime token should carry
approvals:decide. -
The operator credential is separate from the runtime credential. Graft works single-credential, and
graft harden <role>splits them. The container applies the split by default wherever it owns its own database. The split is defence in depth beneath the application-level controls, not a substitute. -
A stolen runtime credential can rewrite and hide content. The hardened role holds
INSERT/UPDATEoncontent_indexbecause projection needs it, so raw SQL under that credential can change any document or setdeleted. The human gate on MCPdelete_contentis an application control against an agent misusing the tool. It is not a database control against a stolen credential, and no grant list short of refusing content writes entirely would make it one.The approval gate is the exception, deliberately. It holds even against a stolen runtime credential: no
UPDATEonapprovals, and a column-scopedINSERTthat cannot namestatusordecided_by. Both halves are needed. WithholdingUPDATEalone leaves the credential able to file a row that is already approved, which is cheaper than flipping a pending one.
| Control | Where |
|---|---|
| Anonymous MCP callers refused unless explicitly allowed | @usegraft/mcp, graft serve |
| Approval decider derived from the verified caller, never from input | @usegraft/db, every deciding surface |
| A requester can never decide their own approval | decideApproval |
| Per-route scopes on the Studio API and MCP tools | @usegraft/studio, @usegraft/mcp |
| Path containment with symlink refusal on every filesystem sink | @usegraft/compiler |
| Rate identity from the connection peer, not a client header | @usegraft/core |
| Authored MDX refused unless the renderer opts into full MDX | @usegraft/mdx-safety |
| Executable MDX refused at compile, across the whole content tree | graft compile, mdxTrust |
| Host validation and cross-origin refusal on the local Studio | graft serve, graft studio |
Runtime credential has no UPDATE on approvals, enforced by Postgres |
graft harden, container by default |
The decisions behind these live in docs/adr/, each stating the
premise it depends on. If you are reporting something that shows a premise is
false, say which one — that is the most useful kind of report.
- Do not set
GRAFT_MCP_ALLOW_ANONYMOUS=1on anything reachable from a network. - Give agent tokens
content:writeat most. Neverapprovals:decide. - Run
graft harden <role>and serve under that role. The all-in-one container already does;GRAFT_MODE=serveneedsGRAFT_HARDEN=1because that database is yours, not the container's. - Set
trustedProxyHopsto the number of proxies you actually run. The default,0, ignoresx-forwarded-forentirely, which is correct when nothing is in front of you. - Leave
MdxBodyattrust: "restricted"unless every author has commit access.mdxTrustin graft.config.ts is the compile-side half of the same decision, and the two have to agree.
Pre-1.0: only the latest minor receives fixes.
Most leftover Dependabot rows are transitive pins. This repository now
overrides browserslist to 4.28.8, path-to-regexp@6.1.0 to 6.3.0,
launch-editor to 2.14.1, and the old esbuild pins to 0.28.2.
Left for an operator to dismiss
- zizmor
artipackedon.github/workflows/release.yml. The changesets job must keep checkout credentials so it can pushchangeset-release/<branch>.persist-credentials: falsewould break the release. The finding is ignored in-file (zizmor: ignore[artipacked]) and must be dismissed on the Security tab; this token cannot do that. - vite 5.4.21 (vitest 3's runner), if Dependabot still flags it after the
override pass. 5.4.21 is the last 5.4 patch. Moving vitest across a major
to pick up Vite 6+ is a separate change. The alert is the test runner's
dev server, not
graft serve.