feat: FUSE mount for relayfile VFS with permission enforcement#13
feat: FUSE mount for relayfile VFS with permission enforcement#13khaliqgant merged 19 commits intomainfrom
Conversation
Adds a FUSE-based filesystem mount backed by the relayfile HTTP API. Every syscall (open, read, write, readdir, stat) becomes an API call with a scoped Bearer token. Ignored files return ENOENT, readonly files return EPERM on write. Includes local LRU cache with TTL and WebSocket-based cache invalidation for real-time multi-agent sync. - internal/mountfuse/: fs.go, dir.go, file.go, client.go, cache.go, wsinvalidate.go - cmd/relayfile-mount: --fuse flag and --mode=fuse support - Build tag //go:build !nofuse for conditional compilation - Tests for cache and FUSE operations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
publish.yml now creates both sdk-v* and v* tags. The v* tag triggers release.yml which builds Go binaries, publishes npm SDK, and pushes Docker images — matching the relay repo's auto-release pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wsinvalidate.go: Fix WebSocket endpoint path /fs/events/ws -> /fs/ws dir.go: Return fuse.FOPEN_KEEP_CACHE instead of raw open flags file.go: Remove duplicate putFile call before invalidate file.go: Add RLock/RUnlock in fillEntry for thread safety Co-Authored-By: My Senior Dev <dev@myseniordev.com>
…empty The bulk write endpoint doesn't persist the semantics field, so ACL markers store permissions in the file content as JSON. The aclGetFile function now falls back to parsing content when semantics.permissions is empty. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wsinvalidate.go: backoff reset after stable connection, ws/wss scheme support file.go: writeGen generation counter to prevent flush/Write race condition webhooks.ts: move nextRevision() after size check to avoid wasting revisions dir.go, file.go fillEntry, wsinvalidate.go endpoint: previously resolved Co-Authored-By: My Senior Dev <dev@myseniordev.com>
When a write is denied (403) or a file is reverted, the mount client now writes to .relay/permissions-denied.log with timestamp, action, file path, and reason. Agents can check this log for details. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a readonly file's hash differs from the tracked hash (agent used chmod to bypass 444 and wrote to it), the sync client now fetches the original content from relayfile and overwrites the local file, restoring both content and chmod 444 permissions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The mapError change correctly maps HTTP 429 to syscall.EAGAIN (retryable) instead of syscall.EIO (fatal), but the test still expected EIO. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Group 1 - HTTP API security/ACL hardening: - Log warnings when using default dev secrets (server.go) - Add ACL rule value validation with regex patterns (acl.go) - Extend aclCheckPath for tree/query_files routes (server.go) - Document TOCTOU-safe single-snapshot ACL resolution (server.go) - Add 12 path normalization tests and 18 validation tests (acl_test.go) Group 2 - SDK/core package security: - Make webhook signature verification required by default (webhooks.ts) - Move bearer token from WebSocket URL to auth message (sync.ts) Group 3 - FUSE mount reliability: - Add inode cache size limit with eviction (fs.go) - Add WS read limit, auth error detection, max failure cap (wsinvalidate.go) - Map HTTP 429 to EAGAIN instead of EIO (client.go) - Enforce maxFileBytes limit on write buffer (file.go) Group 4 - Mount command cleanup: - Cancel derived context on FUSE exit to clean up WS (fuse_mount.go) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test expected the bearer token in the WebSocket URL query string, but commit 1f87f7b moved it to a post-open auth message for security. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d root version - Rewrite scopeGrantsWrite to recognize manage/wildcard scopes (plane:resource:action:path segments), matching server-side scopeMatches - Remove ReadOnly OR-latch so permissions reflect fresh scope evaluation - Add conflicted entry after applyWriteDenied to prevent pullRemote override - Skip .relay directory in scanLocalFiles walk - Add version field to root package.json for CI npm version compatibility Fixes: 3000084339, 3000241814, 3000241892, 3000241978 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…coverage - fsnotify file watcher for instant local change detection (replaces polling) - Path-aware scopeMatchesPath in auth.go (fixes .env leak — token with relayfile:fs:read:/src/app.ts no longer grants read to all files) - canReadPath in mount client filters pulls by token scopes - .agentdeny command filter via shell preexec hook - Readonly revert in pushSingleFile when hash differs - Full test coverage: scope matching, read filtering, write rejection, agentdeny, fsnotify watcher - Fixed TestWriteRejectionRevertsFile to use read-only scopes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
internal/mountsync/syncer.go
Outdated
| if exists && tracked.ReadOnly { | ||
| return s.revertReadonlyFile(ctx, remotePath, localPath, tracked, "") |
There was a problem hiding this comment.
🟡 Missing saveState() after revertReadonlyFile in HandleLocalChange Remove/Rename path
When a readonly file is locally deleted (Remove/Rename), revertReadonlyFile modifies s.state.Files[remotePath] (setting ReadOnly, Dirty, etc.) but the caller returns its result directly without calling s.saveState(). This means the reverted state is held only in memory and lost if the process restarts before the next timed sync cycle. Compare with the Write/Create path at line 454-458 which correctly calls s.saveState() after handleLocalWriteOrCreate.
Affected code path
Lines 459-462 return directly from revertReadonlyFile without saving:
case op&(fsnotify.Remove|fsnotify.Rename) != 0:
tracked, exists := s.state.Files[remotePath]
if exists && tracked.ReadOnly {
return s.revertReadonlyFile(ctx, remotePath, localPath, tracked, "")
}But the non-readonly path at lines 464-467 correctly saves state.
| if exists && tracked.ReadOnly { | |
| return s.revertReadonlyFile(ctx, remotePath, localPath, tracked, "") | |
| if exists && tracked.ReadOnly { | |
| if err := s.revertReadonlyFile(ctx, remotePath, localPath, tracked, ""); err != nil { | |
| return err | |
| } | |
| return s.saveState() |
Was this helpful? React with 👍 or 👎 to provide feedback.
| run: npm ci | ||
|
|
There was a problem hiding this comment.
🔴 Version bump uses root package.json starting at 0.0.0, producing incorrect version numbers
The publish workflow's version bump step was changed to operate on the root package.json (which has "version": "0.0.0" per package.json:3) instead of packages/sdk/package.json (previously used). When a relative bump like patch is selected, npm version patch on the root will produce 0.0.1 instead of incrementing from the actual published version of the packages (e.g., core is at 0.1.0). This means the first non-custom-version publish from this workflow would reset all package versions to an unexpectedly low number like 0.0.1.
Prompt for agents
In .github/workflows/publish.yml, the version bump step at line 91-92 reads CURRENT_VERSION from the root package.json which is at 0.0.0. It should either: (1) read the current version from one of the actual published packages (e.g. packages/core/package.json or packages/sdk/package.json) before bumping, or (2) set the root package.json version to match the current published version, or (3) always derive the version from an existing package. The key change needed is around line 91: CURRENT_VERSION should come from a package with the real published version, and npm version should be run against that package or the root version should be seeded correctly.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const envelopeStorage = getWebhookStorage(storage); | ||
| const correlationId = input.correlationId?.trim() ?? ""; | ||
|
|
||
| const requireSignature = options.requireSignature === true; |
There was a problem hiding this comment.
🔴 requireSignature documented default is true (fail-closed) but code defaults to false (fail-open)
The JSDoc on requireSignature states "Defaults to true (fail-closed)" but the implementation at line 121 uses options.requireSignature === true, which evaluates to false when the option is undefined (the default). This means callers who omit requireSignature — trusting the documented default — will get fail-open behavior where webhooks are accepted without signature verification, directly contradicting the documented security guarantee.
| const requireSignature = options.requireSignature === true; | |
| const requireSignature = options.requireSignature !== false; |
Was this helpful? React with 👍 or 👎 to provide feedback.
| if strings.HasSuffix(scopePath, "*") && strings.HasPrefix(filePath, scopeDir) { | ||
| return true | ||
| } |
There was a problem hiding this comment.
🔴 Bare * wildcard in scopeMatchesPath matches across directory boundaries
When a scope path ends with * (but not /*), the check at line 263 does strings.HasPrefix(filePath, scopeDir) where scopeDir has the trailing * stripped. For a scope like relayfile:fs:read:/src*, scopeDir becomes /src, so HasPrefix("/srcevil/secret.txt", "/src") returns true — granting unintended access to paths outside the intended /src directory tree. The /* variant (line 260) correctly appends / to prevent this, but the bare * variant does not.
Example of unintended match
Scope: relayfile:fs:read:/src*
- Line 255:
scopeDir = TrimSuffix("/src*", "/*")→"/src*"(unchanged) - Line 256:
scopeDir = TrimSuffix("/src*", "*")→"/src" - Line 263:
HasPrefix("/srcevil/secret.txt", "/src")→true← unintended match
| if strings.HasSuffix(scopePath, "*") && strings.HasPrefix(filePath, scopeDir) { | |
| return true | |
| } | |
| if strings.HasSuffix(scopePath, "*") && (strings.HasPrefix(filePath, scopeDir+"/") || filePath == scopeDir) { | |
| return true | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
hanwen/go-fuse/v2ENOENT, readonly files →EPERMon writeIf-Matchrevision headers, 409 → retryNew files
internal/mountfuse/— fs.go, dir.go, file.go, client.go, cache.go, wsinvalidate.gocmd/relayfile-mount/fuse_mount.go— FUSE runner with//go:build !nofusetagcmd/relayfile-mount/main.go—--fuseflag and--mode=fuse|pollsupportHow it works
Test plan
go build ./internal/mountfuse/...passesgo build ./cmd/relayfile-mount/...passesgo test ./internal/mountfuse/... -shortpasses🤖 Generated with Claude Code