Sanboxed Apps Reading Extended Security Information (ACL)

My custom filesystem kernel extension stores ACLs as an extended attribute, com.apple.system.Security.

Sanboxed apps such as TextEdit, Pages, etc., running as a non-privileged process, fail to save modified contents when permissive ACLs are in use.

Running them as a privileged process, does allow for file changes to be saved though.

Non-sandboxed apps, such as VSCode, and command line programs are not susceptible to this behaviour.

APFS, on the other hand, seems to handle ACLs as an ATTR_CMN_EXTENDED_SECURITY filesystem attribute, rather than as an EA. In this case, sandboxed apps have no trouble accessing the ACL data.

I implemented a minimal PoC within my custom kext to verify this. I construct an ACL in memory allowing a given user to write,append,delete file contents, and return it that via vnop_getattr. This allows the file contents to be modified and saved by sandboxed apps.

Can you please confirm if my findings are accurate and sandboxed apps fail to read the com.apple.system.Security EA by design?

Also, Is it an accurate assumption, that ACLs should be handled either as an EA, or an ATTR_CMN_EXTENDED_SECURITY, but not both?

Thanks.

Can you please confirm if my findings are accurate and sandboxed apps fail to read the com.apple.system.Security EA by design?

No, I don't think that's correct. You can see the code here but I believe that xattr was set up as the "fallback" storage mechanism which the VFS system uses if the can't retrieve the data through ATTR_CMN_EXTENDED_SECURITY. As far as directly reading the xattr, the vfs layer is what blocks that, not the sandbox, through this check.

However, my bigger question here is what you mean by "read". ACL enforcement and validation happens in the kernel, not user space, so a process doesn't really "read" its ACL, whether or not it's stored in an xattr. I don't know what's going on here, but the general theory you're describing doesn't really make sense to me. Have you tried starting with a minimal test app that's sandboxed and directly access the file? I'd start with our basic app template with the sandbox enabled, then use the File Access Entitlement to hard code its access to a specific file.

Related to that point:

I construct an ACL in memory allowing a given user to write,append,delete file contents, and return it that via vnop_getattr.

Be aware that the way most document based apps interact with their documents isn't through the standard "open-> read-> write-> close" Unix "cycle". Most document based apps use some form of safe save semantics where they copy the original, modify the copy, then exchange (ideally, atomically), the original with their modified copy.

Also, Is it an accurate assumption, that ACLs should be handled either as an EA, or an ATTR_CMN_EXTENDED_SECURITY, but not both?

Well, strictly speaking, it looks you could actually "mix" them but the bigger issue is basically "why bother". If the file system is going to handle them within it's metadata, then setting com.apple.system.Security is just extra work without any value.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Thanks for your response.

When evaluating KAUTH_FILESEC_XATTR via getxattr(), xattr_entitlement_check() succeeds if the calling process is that of the superuser, or if it carries the FILESEC_ACCESS_ENTITLEMENT entitlement.

TextEdit does not cary the FILESEC_ACCESS_ENTITLEMENT:

% codesign -d --entitlements - --xml /System/Applications/TextEdit.app | plutil -p -
Executable=/System/Applications/TextEdit.app/Contents/MacOS/TextEdit
{
  "com.apple.application-identifier" => "com.apple.TextEdit"
  "com.apple.developer.ubiquity-container-identifiers" => [
    0 => "com.apple.TextEdit"
  ]
  "com.apple.private.hid.client.event-dispatch.internal" => true
  "com.apple.security.app-sandbox" => true
  "com.apple.security.files.user-selected.executable" => true
  "com.apple.security.files.user-selected.read-write" => true
  "com.apple.security.print" => true
}

This explains the behaviour I'm observing, i.e. running TextEdit as a regular user fails to write file changes and returning EPERM, and running TextEdit as a superuser succeeds in writing the file contents.

xattr(1) behaves the same way:

% ls -le /Volumes/myfs/f.txt 
-r--r--r--@ 1 user  staff  0 Aug 27 18:39 /Volumes/myfs/f.txt
 0: user:user allow write,delete,append

% xattr /Volumes/myfs/f.txt
com.apple.TextEncoding
xattr: [Errno 1] Operation not permitted: '/Volumes/myfs/f.txt'

% sudo xattr /Volumes/myfs/f.txt
com.apple.TextEncoding
com.apple.system.Security

What I need clarified is this, if my custom filesystem kext handles ACL data as the KAUTH_FILESEC_XATTR, which it does in the current implementation, then sandboxed apps won't be able to save modified file contents, because they fail the xattr_entitlement_check(). Is my only option is this case to handle ACL data through ATTR_CMN_EXTENDED_SECURITY? Which the PoC I wrote confirmed as working.

When evaluating KAUTH_FILESEC_XATTR via getxattr(), xattr_entitlement_check() succeeds if the calling process is that of the superuser, or if it carries the FILESEC_ACCESS_ENTITLEMENT entitlement.

Sure, but that's actually what makes what you're describing confusing. I don't know why TextEdit would be trying to access that xattr. It's certainly not intentional and I don't think it's part of the process of actually validating the security state. That should be going through a completely different path.

The core problem here is that it's still not clear to me what's actually failing here. The problem here is that the comparison here shouldn't actually be "the same":

(1)

xattr(1) behaves the same way:

xattr is directly accessing the xattr which is then failing, exactly as documented. This is what an app that was explicitly trying to view or modify the ACL would do, which is what it’s set up to fail.

(2)

This explains the behaviour I'm observing, i.e. running TextEdit as a regular user fails to write file changes and returning EPERM, and running TextEdit as a superuser succeeds in writing the file contents.

TextEdit isn't (or shouldn't be) trying to view the ACL, it’s just trying to do “its work". Now, it's POSSIBLE that's failing because it's safe save semantics mean that it ends up trying to do a copy, which then fails due to the ACL. However, I'd then also expect "cp" and other similar tools to fail for the same reason. However, that would also mean that this ISN'T about sandbox'ing, it's about straight Unix permission enforcement.

That leads me back to what I suggested here:

Have you tried starting with a minimal test app that's sandboxed and directly access the file?

At this point, you still don't actually know what's failing inside TextEdit (or any other app), which makes it hard to know what your options are.

Is my only option in this case to handle ACL data through ATTR_CMN_EXTENDED_SECURITY?

I'm not sure. It's possible the xattr-based approach just doesn't support our safe-save semantics. That seems like a serious issue; however, it's possible as ACLs aren't all that widely used, and when they are used, they tend to be attached to directories, not individual files. However, it's also possible that something more complex is going on that’s specifically tied to your implementation. One question related to that point— where's this ACL data originally coming from? Is it possible that YOU ended up calling "getxattr" yourself as part of the permission check?

I don't think that would happen in a traditional block or network file system, but it might be an issue if you're doing some kind of filter file system.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Is it possible that YOU ended up calling "getxattr" yourself as part of the permission check?

No, I do not call getxattr explicitly. Both setxattr and getxattr are called by the system and my filesystem just stores and retrieves the ACL EA from the server.

I've built a test app with the following entitlements enabled:

% codesign -d --entitlements - --xml SandboxedFileEditor.app | plutil -p -
Executable=SandboxedFileEditor.app/Contents/MacOS/SandboxedFileEditor
{
  "com.apple.developer.applesignin" => {
    "com.apple.security.app-sandbox" => true
    "com.apple.security.files.bookmarks.app-scope" => true
    "com.apple.security.files.user-selected.read-write" => true
  }
  "com.apple.security.app-sandbox" => true
  "com.apple.security.files.user-selected.read-write" => true
  "com.apple.security.get-task-allow" => true
}

I was able to successfully modify and save the contents of the test file residing on my custom filesystem:

% cat /Volumes/myfs/f.txt         
line1
line2

With the following POSIX and ACL permissions set:

% ls -le /Volumes/myfs/f.txt 
-r--r--r--@ 1 user  staff  6 Sep  3 17:55 /Volumes/myfs/f.txt
 0: user:user allow write,delete,append

And the ACL being stored as a com.apple.system.Security EA:

% sudo xattr /Volumes/myfs/f.txt
com.apple.TextEncoding
com.apple.quarantine
com.apple.system.Security

I still don't understand how to debug TextEdit and Pages not being permitted to save the modified file contents.

In the system log, I've found the following error message:

2026-08-14 16:50:41.313445+0300 0x2718a    Error       0x62db7              442    0    Finder: (Foundation) [com.apple.foundation.filecoordination:claims] 73600188-34F7-4F6C-A697-34A74474169C grantAccessClaim reply is an error: Error Domain=NSCocoaErrorDomain Code=513 "The document “f1.txt” could not be autosaved. You don’t have permission." UserInfo={NSUnderlyingError=0x83891b900 {Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission to save the file “f1.txt” in the folder “." UserInfo={NSURL=file:///Volumes/myfs/f1.txt, NSUnderlyingError=0x83891b8d0 {Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission to save the file “f1.txt.sb-23e6090e-qqHT2x” in the folder “." UserInfo={NSURL=file:///Volumes/myfs/f1.txt.sb-23e6090e-qqHT2x, NSFilePath=/Volumes/myfs/f1.txt.sb-23e6090e-qqHT2x, NSUnderlyingError=0x83891bc00 {Error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"}}}}}, NSLocalizedDescription=The document “f1.txt” could not be autosaved. You don’t have permission., NSURL=file:///Volumes/myfs/f1.txt, NSLocalizedFailure

Do you have any further suggestions to make in this respect?

I still don't understand how to debug TextEdit and Pages not being permitted to save the modified file contents.

First, as a general comment, as a file system vendor, this isn't necessarily your problem to debug. The file system’s role is to enforce the permissions that have been applied to it, not to ensure that those configurations are actually "useful". There can be some grey area if you're specifically creating that permission configuration or defining the mechanism used to control it, but if the user set it up, then it's their job to debug it.

In any case, moving to here:

In the system log, I've found the following error message:

So, the most useful data point here is this:

"You don’t have permission to save the file “f1.txt.sb-23e6090e-qqHT2x” in the folder"

In a previous post, I said:

Now, it's POSSIBLE that's failing because it's safe save semantics mean that it ends up trying to do a copy, which then fails due to the ACL.

Assuming you don't have an unusual approach to file naming[1], f1.txt.sb-23e6090e-qqHT2x is the temporary file TextEdit was trying to create so that it could write the new contents to your server. Once it had finished writing the data out, it would then have replaced f1.txt with f1.txt.sb-23e6090e-qqHT2x, completing the save. However, I don't think it actually supports in-place editing, so if it can't write that file, it will fail the save.

What are the permissions of the parent directory, and do they allow object creation?

[1] The extension sb-23e6090e-qqHT2x was "random" data attached to make the name unique.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

What are the permissions of the parent directory, and do they allow object creation?

The parent directory that houses the test file is the root of the filesystem which uses the most liberal POSIX permissions and has no ACL permissions set:

% ls -led /Volumes/myfs/
drwxrwxrwx  8 root  wheel  15 Sep  4 16:53 /Volumes/myfs/

I've been able to identify the point of failure in TextEdit using dtrace(1) via the syscall provider.

When replacing the original file, /Volumes/myfs/f.txt, with the temporary copy, TextEdit queries the presence of the com.apple.system.Security EA in the copy, /Volumes/myfs/f.txt.sb-1f18ed33-4luthh/f.txt. It gets ENOATTR returned, because the attribute isn't there.

Next, the original file is queried to get the size of the EA.

Finally, an attempt is made to create the security EA for the copy. It's this call that returns EPERM.

Extended attributes are queried and set using the following execution path, NSDocument saveToURL -> NSDocument writeSafelyToURL -> NSFileManager replaceItemAtURL -> CoreServicesInternal _URLReplaceObject -> CoreServicesInternal TransferExtendedAttributes -> libsystem_kernel.dylib getxattr or libsystem_kernel.dylibsetxattr`

See the file attached, dtrace-textedit-xattr.txt, for the diagnostics emitted by dtrace(1).

I guess, the only options available to me are, apps like TextEdit, Pages and possibly some other apps like that as well shouldn't be used to modify files when ACLs are stored as the system security EA, or the ACL implementation in my filesystem kext should be redone using the ATTR_CMN_EXTENDED_SECURITY VFS file metadata attribute.

When replacing the original file, /Volumes/myfs/f.txt, with the temporary copy, TextEdit queries the presence of the com.apple.system.Security EA in the copy, /Volumes/myfs/f.txt.sb-1f18ed33-4luthh/f.txt. It gets ENOATTR returned, because the attribute isn't there.

Looking at our code, this sequence is part of our "generic" xattr transfer engine. It works just like you've described above— it checks of the EA exists and copies it if it doesn't.

However, the problem here is that this transfer happens AFTER we set the permission configuration of the destination, including setting ACLs using filesec_set_property. In other words, the problem above isn't that set failed, it's that getxattr didn't return the acl attribute which should have been created by an earlier acl set.

Are you seeing an ACL get on the source? Or the ACL set on the destination?

I guess, the only options available to me are, apps like TextEdit, Pages and possibly some other apps like that as well shouldn't be used to modify files when ACLs are stored as the system security EA,

Not exactly. The thing to keep in mind here is that ACL shouldn't be modified through the xattr interface AT ALL. As is done in a few other places, the architectural "idea" is to basically:

  1. Store the data in an xattr (because it's convenient).
  2. Route modifications/access to that data through the "normal" API route.
  3. Block modification to the xattr (because #2 is the way you're supposed to access the data).

That's what _URLReplaceObject is doing, except that it took the "shortcut" of checking for xattrs on the destination instead of explicitly skipping "com.apple.system.Security".

[1] There's actually a different function called "xattr_preserve_for_intent" (see "man xattr_name_with_flags"), which it uses to determine which xattrs it should ignore. If you're curious, you can actually look at its implementation here.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Are you seeing an ACL get on the source? Or the ACL set on the destination?

I was able to map this syscall returning ENOATTR:

2  => getxattr('/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt', 'com.apple.system.Security', value:0, size:0, position:0, options:1) TextEdit-3772
3  <= getxattr('/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt', 'com.apple.system.Security', value:0, size:0, position:0, options:1) -> -1 (93) TextEdit-3772 ;ENOATTR

To this vnop_getxattr call in my kext, with args->a_size=0 returned:

vnop_getxattr: TextEdit-3772 -> getxattr_rpc('/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt', 'com.apple.system.Security', fffffe2fbb998180, uio_resid(EAvalue):762)
vnop_getxattr: TextEdit-3772 <- getxattr_rpc('/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt', 'com.apple.system.Security', fffffe2fbb998180, uio_resid(EAvalue):762) -> 93 ;ENOATTR
vnop_getxattr: TextEdit-3772 '/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt' 'com.apple.system.Security' *args->a_size=0
vnop_getxattr: TextEdit-3772 '/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt' 'com.apple.system.Security' <- 93 ;ENOATTR

The next syscall succeeds in returning the com.apple.system.Security EA, with the EA size of 512 bytes returned. This differs from the size returned by my kext being 68 bytes. See below:

3  => getxattr('/Volumes/myfs/f.txt', 'com.apple.system.Security', value:16de4d390, size:512, position:0, options:1) TextEdit-3772
0  <= getxattr('/Volumes/myfs/f.txt', 'com.apple.system.Security', value:16de4d390, size:512, position:0, options:1) -> 512 (0) TextEdit-3772
vnop_getxattr: TextEdit-3772 -> getxattr_rpc('/Volumes/myfs/f.txt', 'com.apple.system.Security', fffffecff9e3ba80, uio_resid(EAvalue):512)
vnop_getxattr: TextEdit-3772 <- getxattr_rpc('/Volumes/myfs/f.txt', 'com.apple.system.Security', fffffecff9e3ba80, uio_resid(EAvalue):488) -> 68
vnop_getxattr: TextEdit-3772 '/Volumes/myfs/f.txt' 'com.apple.system.Security' *args->a_size=68
vnop_getxattr: TextEdit-3772 '/Volumes/myfs/f.txt' 'com.apple.system.Security' <- 0 ; KERN_SUCCESS

This syscall never calls into the vnop_setxattr in my kext:

0  => setxattr('/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt', 'com.apple.system.Security', value:16de4d390, size:512, position:0, options:1) TextEdit-3772
0  <= setxattr('/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt', 'com.apple.system.Security', value:16de4d390, size:512, position:0, options:1) -> -1 (1) TextEdit-3772 ;EPERM

EPERM possibly being returned from setxattr via the following execution path:

if (xattr_protected(sactx->attrname) &&
	(error = xattr_entitlement_check(sactx->attrname, ctx, true)) != 0) {
	goto out;
}

OK. SO, the critical thing to look at here is what exactly happened on that file leading up to the getxattr. The problem here is that a few different things don't really make sense. More specifically, this sequence within our code would have been to set the ACL, then look at the xattr list. That means this should never have happened:

vnop_getxattr: TextEdit-3772 '/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt' 'com.apple.system.Security' *args->a_size=0
vnop_getxattr: TextEdit-3772 '/Volumes/myfs/f.txt.sb-94c724e7-egshwF/f.txt' 'com.apple.system.Security' <- 93 ;ENOATTR

...since setting the acl should have created the xattr above.

There are also a few issues here:

The next syscall succeeds in returning the com.apple.system.Security EA, with the EA size of 512 bytes returned.

  1. getxattr should have failed on both files, due to this entitlement check.

  2. TransferExtendedAttributes calls xattr_preserve_for_intent and com.apple.security is specifically configured as XATTR_FLAG_NEVER_PRESERVE. That means it should never have touched that xattr on either file.

  3. Even if neither of those points held, the fact that it existed on the new file should have meant that it got skipped also.

What system version are you testing on? All the code for _URLReplaceObject hasn't changed in any recent version, but it's possible that an old enough system version might be doing something different.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Thanks for looking into this.

What system version are you testing on?

% sw_vers   
ProductName:		macOS
ProductVersion:		26.6.2
BuildVersion:		25G83

% xcodebuild -version
Xcode 26.6
Build version 17F113
Sanboxed Apps Reading Extended Security Information (ACL)
 
 
Q