Skip to content

Commit a85e844

Browse files
committed
fix(environment): clamp zip entry modes during skill extraction
Skill zips normalized by older fc-safari builds carry entries with no mode bits: a directory entry reads back as 0666 (no execute bit), and honoring it verbatim creates an un-traversable staging dir — every file inside then fails with EACCES (prod skill-load failures, 2026-06-11). Clamp dirs to owner-rwx and files to owner-rw so such archives still install; archives with sane modes are unaffected.
1 parent c9eeefa commit a85e844

2 files changed

Lines changed: 49 additions & 2 deletions

File tree

environment/environment.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,11 @@ func (e *Environment) unzipSkill(data []byte, dest string) error {
11841184
}
11851185

11861186
if f.FileInfo().IsDir() {
1187-
if err := os.MkdirAll(absTarget, f.Mode()); err != nil {
1187+
// Clamp to owner-rwx: zips normalized by older fc-safari builds
1188+
// carry dir entries with no mode bits (read back as 0666 — no
1189+
// execute), and honoring that verbatim creates a directory we
1190+
// cannot extract files into (EACCES on every child).
1191+
if err := os.MkdirAll(absTarget, f.Mode().Perm()|0o700); err != nil {
11881192
return fmt.Errorf("failed to create directory %s: %w", cleanName, err)
11891193
}
11901194
continue
@@ -1211,7 +1215,9 @@ func (e *Environment) extractZipFile(f *zip.File, targetPath string) error {
12111215
_ = rc.Close()
12121216
}()
12131217

1214-
dst, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
1218+
// Clamp to owner-rw so a mode-less zip entry can't produce a file the
1219+
// runner itself cannot re-read (same normalization gap as directories).
1220+
dst, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode().Perm()|0o600)
12151221
if err != nil {
12161222
return err
12171223
}

environment/environment_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,44 @@ func TestSyncSkill_BadZipKeepsExistingInstall(t *testing.T) {
625625
assert.NotContains(t, e.Name(), ".installing-", "staging dir must be cleaned up on failure")
626626
}
627627
}
628+
629+
// TestSyncSkill_ModelessDirEntryZip reproduces the prod skill-load failure
630+
// (sess_f7Nt..., 2026-06-11): fc-safari's zip normalization rewrote entries
631+
// without mode bits, so an explicit directory entry reads back as 0666 — no
632+
// execute bit. Honoring that verbatim created an un-traversable staging dir
633+
// and every file inside failed with EACCES. The extractor must clamp to
634+
// owner-rwx so such archives (already persisted in S3) still install.
635+
func TestSyncSkill_ModelessDirEntryZip(t *testing.T) {
636+
ws := newTestEnvironment(t)
637+
638+
var buf bytes.Buffer
639+
w := zip.NewWriter(&buf)
640+
// CreateHeader without SetMode — exactly what the buggy normalizer emitted.
641+
_, err := w.CreateHeader(&zip.FileHeader{Name: "references/"})
642+
require.NoError(t, err)
643+
f, err := w.CreateHeader(&zip.FileHeader{Name: "references/worked-example.md", Method: zip.Deflate})
644+
require.NoError(t, err)
645+
_, err = f.Write([]byte("example"))
646+
require.NoError(t, err)
647+
f, err = w.CreateHeader(&zip.FileHeader{Name: "SKILL.md", Method: zip.Deflate})
648+
require.NoError(t, err)
649+
_, err = f.Write([]byte("# skill"))
650+
require.NoError(t, err)
651+
require.NoError(t, w.Close())
652+
653+
res, err := ws.SyncSkill(context.Background(), &protocol.SyncSkillArgs{
654+
SkillName: "modeless",
655+
Checksum: "csum-modeless",
656+
ZipData: base64.StdEncoding.EncodeToString(buf.Bytes()),
657+
})
658+
require.NoError(t, err)
659+
require.True(t, res.Success)
660+
661+
dir := filepath.Join(ws.Root(), "skills", "modeless")
662+
info, err := os.Stat(filepath.Join(dir, "references"))
663+
require.NoError(t, err)
664+
assert.NotZero(t, info.Mode().Perm()&0o700, "staging dirs must stay owner-traversable")
665+
body, err := os.ReadFile(filepath.Join(dir, "references", "worked-example.md"))
666+
require.NoError(t, err)
667+
assert.Equal(t, "example", string(body))
668+
}

0 commit comments

Comments
 (0)