@@ -60,6 +60,21 @@ function workspacePermissionSatisfies(
6060 return permissionSatisfies ( permission , requireWrite ? 'write' : 'read' )
6161}
6262
63+ /**
64+ * Chat-scoped `output` rows belong to a PRIVATE chat: workspace membership
65+ * alone is not enough — the caller must also be the row's owner (stamped from
66+ * the chat owner at creation and re-stamped on fork/duplicate). Without this,
67+ * any workspace member who learns a file id or storage key could read another
68+ * member's agent outputs, even though listing outputs requires chat ownership.
69+ * Non-output contexts pass through unchanged.
70+ */
71+ function outputOwnershipSatisfied (
72+ record : { context : string ; uploadedBy : string } ,
73+ userId : string
74+ ) : boolean {
75+ return record . context !== 'output' || record . uploadedBy === userId
76+ }
77+
6378/**
6479 * Lookup workspace file by storage key from database
6580 * @param key Storage key to lookup
@@ -68,15 +83,17 @@ function workspacePermissionSatisfies(
6883async function lookupWorkspaceFileByKey (
6984 key : string ,
7085 options ?: { includeDeleted ?: boolean }
71- ) : Promise < { workspaceId : string ; uploadedBy : string } | null > {
86+ ) : Promise < { workspaceId : string ; uploadedBy : string ; context : string } | null > {
7287 try {
7388 const { includeDeleted = false } = options ?? { }
7489 // Priority 1: Check new workspaceFiles table. Look up by key across
7590 // WORKSPACE_FILE_LOOKUP_CONTEXTS (`workspace` + `output`): both share the
76- // `workspace/<id>/...` key shape and authorize by workspace membership. Filtering
77- // to `workspace` alone made `output` files unservable (broken previews); scoping to
78- // this explicit set (rather than dropping the filter) keeps outputs servable while
79- // leaving upload (`mothership`) authorization and the owner-scoped contexts untouched.
91+ // `workspace/<id>/...` key shape. `workspace` rows authorize by workspace
92+ // membership; `output` rows additionally require ownership (see
93+ // outputOwnershipSatisfied). Filtering to `workspace` alone made `output`
94+ // files unservable (broken previews); scoping to this explicit set (rather
95+ // than dropping the filter) keeps outputs servable while leaving upload
96+ // (`mothership`) authorization and the owner-scoped contexts untouched.
8097 const fileRecord = await getFileMetadataByKey ( key , WORKSPACE_FILE_LOOKUP_CONTEXTS , {
8198 includeDeleted,
8299 } )
@@ -85,6 +102,7 @@ async function lookupWorkspaceFileByKey(
85102 return {
86103 workspaceId : fileRecord . workspaceId || '' ,
87104 uploadedBy : fileRecord . userId ,
105+ context : fileRecord . context ,
88106 }
89107 }
90108
@@ -107,6 +125,7 @@ async function lookupWorkspaceFileByKey(
107125 return {
108126 workspaceId : legacyFile . workspaceId ,
109127 uploadedBy : legacyFile . uploadedBy ,
128+ context : 'workspace' ,
110129 }
111130 }
112131 } catch ( legacyError ) {
@@ -181,8 +200,15 @@ export async function verifyFileAccess(
181200 return true
182201 }
183202
184- // 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
185- if ( inferredContext === 'workspace' || inferredContext === 'mothership' ) {
203+ // 1. Workspace / mothership / chat-output files: check database first (most
204+ // reliable for both local and cloud). `output` shares the workspace key
205+ // shape; explicitly routing it here (instead of letting it fall through to
206+ // verifyRegularFileAccess) keeps its ownership rule applied on every path.
207+ if (
208+ inferredContext === 'workspace' ||
209+ inferredContext === 'mothership' ||
210+ inferredContext === 'output'
211+ ) {
186212 return await verifyWorkspaceFileAccess ( cloudKey , userId , customConfig , isLocal , requireWrite )
187213 }
188214
@@ -242,6 +268,14 @@ async function verifyWorkspaceFileAccess(
242268 // Priority 1: Check database (most reliable, works for both local and cloud)
243269 const workspaceFileRecord = await lookupWorkspaceFileByKey ( cloudKey )
244270 if ( workspaceFileRecord ) {
271+ if ( ! outputOwnershipSatisfied ( workspaceFileRecord , userId ) ) {
272+ logger . warn ( 'Chat output file access denied: caller is not the owning chat user' , {
273+ userId,
274+ workspaceId : workspaceFileRecord . workspaceId ,
275+ cloudKey,
276+ } )
277+ return false
278+ }
245279 const permission = await getUserEntityPermissions (
246280 userId ,
247281 'workspace' ,
@@ -671,6 +705,14 @@ async function verifyRegularFileAccess(
671705 // This handles legacy files that might not have metadata
672706 const workspaceFileRecord = await lookupWorkspaceFileByKey ( cloudKey )
673707 if ( workspaceFileRecord ) {
708+ if ( ! outputOwnershipSatisfied ( workspaceFileRecord , userId ) ) {
709+ logger . warn ( 'Chat output file access denied: caller is not the owning chat user' , {
710+ userId,
711+ workspaceId : workspaceFileRecord . workspaceId ,
712+ cloudKey,
713+ } )
714+ return false
715+ }
674716 const permission = await getUserEntityPermissions (
675717 userId ,
676718 'workspace' ,
0 commit comments