I genuinely hadn't considered just using the child objects instead of the CharacterBody3D itself. That makes a lot more sense, and if I declare in physics_process, then it still respects collision. I could probably make my code a lot more concise, but here is what I ended up using, if anybody has the same query as me:
func _physics_process(delta):
#handle leaning
rotation.z = lerp(rotation.z, 0.0, lean_transition_speed * delta)
player_capsule.position.x = lerp(player_capsule.position.x, 0.0, lean_transition_speed * delta)
head.position.x = lerp(head.position.x, 0.0, lean_transition_speed * delta)
if Input.is_action_pressed("Lean_left"):
rotation.z = lerp(rotation.z, 0.4, lean_transition_speed * delta)
player_capsule.position.x = lerp(player_capsule.position.x, -2.0, lean_transition_speed * delta)
head.position.x = lerp(head.position.x, -2.0, lean_transition_speed * delta)
if Input.is_action_pressed("Lean_right"):
rotation.z = lerp(rotation.z, -0.4, lean_transition_speed * delta)
player_capsule.position.x = lerp(player_capsule.position.x, 2.0, lean_transition_speed * delta)
head.position.x = lerp(head.position.x, 2.0, lean_transition_speed * delta)
move_and_slide()
Thank you!