Skip to content

Commit 94ddb09

Browse files
dsarnoclaude
andauthored
harden(clients): preserve file mode (0600) + flush on atomic config writes (#545)
* harden(clients): preserve file mode on atomic config writes, default new configs to 0600 McpAtomicWrite created the tmp file with the umask default (0644) and used DirAccess.copy_absolute for the backup, neither of which preserves the prior file's permissions. The Claude Code strategy writes directly into ~/.claude.json, which the Claude CLI creates 0600 (it holds OAuth creds + history); every "Configure" click silently relaxed it to 0644 and left a 0644 .backup, leaking it on shared machines. Capture the prior file's POSIX mode before replacing it and re-apply it to the swapped-in file and the backup, so a 0600 config stays 0600 (and a 0644 config stays 0644 — we preserve, we don't clamp). Brand-new configs default to owner-only 0600 since these files routinely carry tokens. The mode is set on the tmp inode before the rename so the target is never briefly world-readable. On platforms without POSIX permissions (Windows) get/set_unix_permissions no-op and the logic is inert. GDScript tests (run against live Godot 4.6.3, real on-disk chmod): preserve 0600 on rewrite, backup inherits 0600, new file defaults to 0600, and a 0644 file stays 0644. Skipped on Windows. Full suite: 1365/1382 passed, 0 failed. Addresses advisory GHSA-gw6h-44vw-g5rc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(clients): flush the temp file before the atomic rename store_string buffers in Godot's FileAccess; close() flushes, but doing the flush() explicitly before the rename makes the ordering intent clear and ensures the bytes are handed to the OS before the swap. Godot exposes no fsync, so durability against power loss still depends on the OS cache — the comment documents that residual. Pairs with the permission-preservation fix on this branch (atomic config writes). Addresses review finding TC-7 (#531). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(clients): address Copilot review on atomic-write permission mode - Clarify why get_unix_permissions == 0 maps to the 0600 floor rather than "preserving" 0000: these are config files the plugin must read/write, 0000 is unusable, re-applying it would lock the owner out, and 0600 never widens access. (A genuinely-0000 file can't reach a rewrite anyway — the config strategies' read-first guard refuses it first.) Copilot's literal "preserve 0000" suggestion would regress: _apply_mode skips mode 0, leaving the file at copy_absolute's umask default (potentially 0644). - _apply_mode now captures set_unix_permissions' return and push_warning()s on a real failure (not the Windows ERR_UNAVAILABLE no-op), so permission hardening on a sensitive config no longer fails completely silently. Live Godot 4.6.3: clients suite 97/97. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(clients): close the temp-file world-readable window before writing secrets Copilot re-review: the temp file was created at the umask default, the config contents written, and only then chmod'd — leaving a brief window where secrets sat on disk world-readable under path+".tmp". Now chmod the still-empty temp inode to the target mode BEFORE store_string, so the contents never land under a permissive mode. A chmod issued while the FileAccess handle is open does not reliably stick inside the editor, so the post-close apply is kept as the authoritative one (the rename then preserves the mode). Documented the small residual window on the .backup copy (copy_absolute can't be created pre-chmod'd; it duplicates already-0600 bytes in the user's own dir, chmod'd immediately). Live Godot 4.6.3: clients suite 97/97. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cff4c3f commit 94ddb09

2 files changed

Lines changed: 183 additions & 2 deletions

File tree

plugin/addons/godot_ai/clients/_atomic_write.gd

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,61 @@ static func write(path: String, content: String) -> bool:
2020
if DirAccess.make_dir_recursive_absolute(dir_path) != OK:
2121
return false
2222

23+
# Decide the permission mode the final file (and its backup) must carry
24+
# BEFORE we replace anything. A rewrite must preserve the prior file's
25+
# mode: the Claude CLI creates ~/.claude.json as 0600 (it holds OAuth
26+
# creds + history), and a naive FileAccess write + DirAccess copy would
27+
# silently relax that to the umask default (0644) and leak it on shared
28+
# machines. A brand-new config defaults to owner-only 0600 since these
29+
# files routinely carry tokens. On platforms without POSIX permissions
30+
# (Windows) the get/set calls no-op and this logic is inert. See #297
31+
# finding TC-1.
32+
var had_original := FileAccess.file_exists(path)
33+
var target_mode := _resolve_target_mode(path, had_original)
34+
2335
var tmp_path := path + ".tmp"
2436
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
2537
if file == null:
2638
return false
39+
# Lock the temp inode down BEFORE writing any bytes. FileAccess.open creates
40+
# it at the umask default (often 0644); chmod'ing the still-empty file first
41+
# means the config contents are never on disk under a world-readable mode in
42+
# the create->chmod gap. rename preserves the inode mode, so the swapped-in
43+
# file lands correct and is never briefly world-readable under the target name.
44+
_apply_mode(tmp_path, target_mode)
2745
file.store_string(content)
46+
# Push Godot's internal buffer out to the OS before the rename. Godot
47+
# exposes no fsync, so the bytes aren't guaranteed durable on the physical
48+
# disk until the OS flushes its own cache — a power loss in that window can
49+
# still lose the data. But flush() ensures the rename can't be ordered ahead
50+
# of the write at the application layer, which is the failure this guards.
51+
file.flush()
2852
file.close()
53+
# Re-assert the mode on the closed inode. The pre-write chmod above closes
54+
# the world-readable window; this second apply is the authoritative one
55+
# (a chmod issued while the FileAccess handle is still open doesn't reliably
56+
# stick inside the editor) and guarantees the final mode before the rename,
57+
# which preserves it.
58+
_apply_mode(tmp_path, target_mode)
2959

3060
# Best-effort: snapshot the prior file before we touch the target so we
3161
# can restore on a failed swap. The backup is also kept on success as a
32-
# one-shot rollback aid for the user.
62+
# one-shot rollback aid for the user — give it the same (preserved) mode
63+
# so a 0600 config's backup isn't itself a world-readable copy.
64+
#
65+
# copy_absolute creates the backup at the umask default and we can only
66+
# chmod it afterward, so there's a sub-millisecond window where the backup
67+
# carries default perms. Accepted: it duplicates bytes already sitting at
68+
# `path` (which the caller created 0600) inside the user's own config dir,
69+
# and Godot exposes no API to create the copy pre-chmod'd. Not worth
70+
# reimplementing copy by hand to shave that window.
3371
var backup_path := path + ".backup"
34-
var had_original := FileAccess.file_exists(path)
3572
var backup_made := false
3673
if had_original:
3774
DirAccess.remove_absolute(backup_path)
3875
if DirAccess.copy_absolute(path, backup_path) == OK:
3976
backup_made = true
77+
_apply_mode(backup_path, target_mode)
4078

4179
if DirAccess.rename_absolute(tmp_path, path) == OK:
4280
return true
@@ -46,6 +84,9 @@ static func write(path: String, content: String) -> bool:
4684
# removes the original before writing the new bytes, so a failure here
4785
# leaves the user's prior config in place rather than nuking it.
4886
if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content):
87+
# copy_absolute creates the destination with the default mode, so
88+
# re-apply the preserved/owner-only mode after the copy lands.
89+
_apply_mode(path, target_mode)
4990
DirAccess.remove_absolute(tmp_path)
5091
return true
5192

@@ -59,6 +100,7 @@ static func write(path: String, content: String) -> bool:
59100
# and the false return value tells the caller the swap didn't
60101
# complete.
61102
DirAccess.copy_absolute(backup_path, path)
103+
_apply_mode(path, target_mode)
62104
elif not had_original and FileAccess.file_exists(path):
63105
# No prior file existed but copy_absolute landed partial bytes at
64106
# `path`. Remove them so the failure leaves nothing on disk rather
@@ -76,6 +118,38 @@ static func write(path: String, content: String) -> bool:
76118
return false
77119

78120

121+
static func _resolve_target_mode(path: String, had_original: bool) -> int:
122+
# Preserve the prior file's POSIX mode on a rewrite; default a brand-new
123+
# config (or any case we can't read a mode for) to owner read+write (0600).
124+
#
125+
# get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a
126+
# genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a
127+
# missed case: these are config files the plugin must read and write, 0000 is
128+
# unusable, and re-applying 0000 would lock the owner out next run. 0600 is
129+
# still owner-only so this never widens access. (A genuinely-0000 file can't
130+
# reach a rewrite through the config strategies anyway — their read-first
131+
# guard fails to open it and refuses the write before we get here.)
132+
if had_original:
133+
var existing := FileAccess.get_unix_permissions(path)
134+
if existing > 0:
135+
return existing
136+
return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER
137+
138+
139+
static func _apply_mode(path: String, mode: int) -> void:
140+
# Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms
141+
# without POSIX permissions (Windows); that's expected and ignored so the
142+
# write still works there. mode <= 0 should never happen (resolve always
143+
# returns >0) but is guarded so a future caller can't chmod a file to nothing.
144+
if mode <= 0:
145+
return
146+
var err := FileAccess.set_unix_permissions(path, mode)
147+
# Surface a real chmod failure (not the Windows no-op) so permission
148+
# hardening on a sensitive config doesn't fail completely silently.
149+
if err != OK and err != ERR_UNAVAILABLE:
150+
push_warning("MCP | could not set permissions on %s (error %d)" % [path, err])
151+
152+
79153
static func _written_size_matches(path: String, content: String) -> bool:
80154
# `store_string` writes UTF-8 bytes with no BOM and no newline translation,
81155
# so the byte length on disk must match `to_utf8_buffer().size()` exactly.

test_project/tests/test_clients.gd

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,113 @@ func test_atomic_write_preserves_existing_file_when_swap_fails() -> void:
14011401
DirAccess.remove_absolute(backup_path)
14021402

14031403

1404+
# ----- atomic write: permission preservation (#297 finding TC-1) -----
1405+
#
1406+
# The Claude CLI creates ~/.claude.json as 0600 (it holds OAuth creds). A
1407+
# rewrite must preserve that mode rather than relaxing it to the umask default,
1408+
# and the .backup must not become a world-readable copy of a private file.
1409+
# These bits don't exist on Windows, so the suite skips there.
1410+
1411+
const _PERM_MASK := 0x1FF # 0o777 — the rwx bits for owner/group/other
1412+
1413+
1414+
func _owner_only_mode() -> int:
1415+
return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER
1416+
1417+
1418+
func test_atomic_write_preserves_restrictive_mode_on_rewrite() -> void:
1419+
if OS.get_name() == "Windows":
1420+
skip("POSIX file permissions are unavailable on Windows")
1421+
return
1422+
var path := _scratch_dir.path_join("perm_preserve_0600.txt")
1423+
var f := FileAccess.open(path, FileAccess.WRITE)
1424+
f.store_string("secret v1")
1425+
f.close()
1426+
var owner_only := _owner_only_mode()
1427+
assert_eq(
1428+
FileAccess.set_unix_permissions(path, owner_only), OK, "test setup: chmod 0600 must succeed"
1429+
)
1430+
1431+
assert_true(McpAtomicWrite.write(path, "secret v2"))
1432+
1433+
assert_eq(
1434+
FileAccess.get_unix_permissions(path) & _PERM_MASK,
1435+
owner_only,
1436+
"a rewrite must preserve the prior 0600 mode, not relax it to 0644",
1437+
)
1438+
DirAccess.remove_absolute(path + ".backup")
1439+
1440+
1441+
func test_atomic_write_backup_inherits_restrictive_mode() -> void:
1442+
if OS.get_name() == "Windows":
1443+
skip("POSIX file permissions are unavailable on Windows")
1444+
return
1445+
var path := _scratch_dir.path_join("perm_backup_0600.txt")
1446+
var f := FileAccess.open(path, FileAccess.WRITE)
1447+
f.store_string("secret v1")
1448+
f.close()
1449+
var owner_only := _owner_only_mode()
1450+
assert_eq(FileAccess.set_unix_permissions(path, owner_only), OK, "test setup: chmod 0600")
1451+
1452+
assert_true(McpAtomicWrite.write(path, "secret v2"))
1453+
1454+
var backup_path := path + ".backup"
1455+
assert_true(FileAccess.file_exists(backup_path), "backup must exist")
1456+
assert_eq(
1457+
FileAccess.get_unix_permissions(backup_path) & _PERM_MASK,
1458+
owner_only,
1459+
"the .backup of a 0600 file must itself be 0600, not a world-readable copy",
1460+
)
1461+
DirAccess.remove_absolute(backup_path)
1462+
1463+
1464+
func test_atomic_write_new_file_defaults_to_owner_only() -> void:
1465+
if OS.get_name() == "Windows":
1466+
skip("POSIX file permissions are unavailable on Windows")
1467+
return
1468+
var path := _scratch_dir.path_join("perm_new_file.txt")
1469+
# No prior file: nothing to preserve, so a fresh config defaults to 0600
1470+
# regardless of the process umask.
1471+
assert_false(FileAccess.file_exists(path), "test setup: target must not pre-exist")
1472+
1473+
assert_true(McpAtomicWrite.write(path, "fresh token config"))
1474+
1475+
assert_eq(
1476+
FileAccess.get_unix_permissions(path) & _PERM_MASK,
1477+
_owner_only_mode(),
1478+
"a brand-new config must default to owner-only 0600",
1479+
)
1480+
1481+
1482+
func test_atomic_write_preserves_relaxed_mode_on_rewrite() -> void:
1483+
## We preserve the prior mode — we do NOT force 0600 on a file that was
1484+
## already group/other-readable (e.g. a 0644 cursor config). This proves
1485+
## the fix is "preserve", not "clamp everything to 0600".
1486+
if OS.get_name() == "Windows":
1487+
skip("POSIX file permissions are unavailable on Windows")
1488+
return
1489+
var path := _scratch_dir.path_join("perm_preserve_0644.txt")
1490+
var f := FileAccess.open(path, FileAccess.WRITE)
1491+
f.store_string("public v1")
1492+
f.close()
1493+
var relaxed := (
1494+
FileAccess.UNIX_READ_OWNER
1495+
| FileAccess.UNIX_WRITE_OWNER
1496+
| FileAccess.UNIX_READ_GROUP
1497+
| FileAccess.UNIX_READ_OTHER
1498+
) # 0644
1499+
assert_eq(FileAccess.set_unix_permissions(path, relaxed), OK, "test setup: chmod 0644")
1500+
1501+
assert_true(McpAtomicWrite.write(path, "public v2"))
1502+
1503+
assert_eq(
1504+
FileAccess.get_unix_permissions(path) & _PERM_MASK,
1505+
relaxed,
1506+
"a 0644 file must stay 0644 — preserve the prior mode, don't clamp to 0600",
1507+
)
1508+
DirAccess.remove_absolute(path + ".backup")
1509+
1510+
14041511
# ----- handler -----
14051512

14061513
func test_handler_rejects_unknown_client() -> void:

0 commit comments

Comments
 (0)