@@ -1656,33 +1656,13 @@ async fn collect_changes_from_worktree(
16561656 }
16571657 // New/untracked files
16581658 "??" | "A " | " A" | "AM" => {
1659- match tokio:: fs:: symlink_metadata ( & full_path) . await {
1660- Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1661- changes. push ( read_file_change ( "add" , file_path, & full_path) . await ?) ;
1662- }
1663- Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1664- warn ! (
1665- "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1666- file_path
1667- ) ;
1668- }
1669- _ => { }
1670- }
1659+ push_file_change_skipping_symlinks ( & mut changes, "add" , file_path, & full_path)
1660+ . await ?;
16711661 }
16721662 // Modified files
16731663 " M" | "M " | "MM" => {
1674- match tokio:: fs:: symlink_metadata ( & full_path) . await {
1675- Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1676- changes. push ( read_file_change ( "edit" , file_path, & full_path) . await ?) ;
1677- }
1678- Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1679- warn ! (
1680- "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1681- file_path
1682- ) ;
1683- }
1684- _ => { }
1685- }
1664+ push_file_change_skipping_symlinks ( & mut changes, "edit" , file_path, & full_path)
1665+ . await ?;
16861666 }
16871667 // Renamed files - format is "R old_path -> new_path"
16881668 // For "RM" (renamed + modified), we emit both a rename and an edit change.
@@ -1704,36 +1684,22 @@ async fn collect_changes_from_worktree(
17041684
17051685 // If status is "RM" (renamed + modified), also emit content
17061686 if status_code == "RM" {
1707- let new_full_path = worktree_path. join ( new_path. trim ( ) ) ;
1708- match tokio:: fs:: symlink_metadata ( & new_full_path) . await {
1709- Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1710- changes. push ( read_file_change ( "edit" , new_path. trim ( ) , & new_full_path) . await ?) ;
1711- }
1712- Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1713- warn ! (
1714- "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1715- new_path. trim( )
1716- ) ;
1717- }
1718- _ => { }
1719- }
1687+ let new_path_trimmed = new_path. trim ( ) ;
1688+ let new_full_path = worktree_path. join ( new_path_trimmed) ;
1689+ push_file_change_skipping_symlinks (
1690+ & mut changes,
1691+ "edit" ,
1692+ new_path_trimmed,
1693+ & new_full_path,
1694+ )
1695+ . await ?;
17201696 }
17211697 }
17221698 }
17231699 // Other statuses - try to handle as edit if file exists
17241700 _ => {
1725- match tokio:: fs:: symlink_metadata ( & full_path) . await {
1726- Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1727- changes. push ( read_file_change ( "edit" , file_path, & full_path) . await ?) ;
1728- }
1729- Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1730- warn ! (
1731- "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1732- file_path
1733- ) ;
1734- }
1735- _ => { }
1736- }
1701+ push_file_change_skipping_symlinks ( & mut changes, "edit" , file_path, & full_path)
1702+ . await ?;
17371703 }
17381704 }
17391705 }
@@ -1780,18 +1746,7 @@ async fn collect_changes_from_diff_tree(
17801746 } ) ) ;
17811747 } else if status_code == "A" {
17821748 // Added file
1783- match tokio:: fs:: symlink_metadata ( & full_path) . await {
1784- Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1785- changes. push ( read_file_change ( "add" , file_path, & full_path) . await ?) ;
1786- }
1787- Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1788- warn ! (
1789- "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1790- file_path
1791- ) ;
1792- }
1793- _ => { }
1794- }
1749+ push_file_change_skipping_symlinks ( & mut changes, "add" , file_path, & full_path) . await ?;
17951750 } else if status_code. starts_with ( 'R' ) && parts. len ( ) >= 3 {
17961751 // Renamed file: R100\told_path\tnew_path
17971752 let old_path = file_path;
@@ -1811,57 +1766,59 @@ async fn collect_changes_from_diff_tree(
18111766 // If the file was also modified (similarity < 100), emit an edit with content
18121767 let new_full_path = worktree_path. join ( new_path) ;
18131768 if status_code != "R100" {
1814- match tokio:: fs:: symlink_metadata ( & new_full_path) . await {
1815- Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1816- changes. push ( read_file_change ( "edit" , new_path, & new_full_path) . await ?) ;
1817- }
1818- Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1819- warn ! (
1820- "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1821- new_path
1822- ) ;
1823- }
1824- _ => { }
1825- }
1769+ push_file_change_skipping_symlinks (
1770+ & mut changes,
1771+ "edit" ,
1772+ new_path,
1773+ & new_full_path,
1774+ )
1775+ . await ?;
18261776 }
18271777 } else if status_code. starts_with ( 'C' ) && parts. len ( ) >= 3 {
18281778 // Copied file: C100\tsrc_path\tdest_path
18291779 let dest_path = parts[ 2 ] ;
18301780 validate_single_path ( dest_path) ?;
18311781
18321782 let dest_full_path = worktree_path. join ( dest_path) ;
1833- match tokio:: fs:: symlink_metadata ( & dest_full_path) . await {
1834- Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1835- changes. push ( read_file_change ( "add" , dest_path, & dest_full_path) . await ?) ;
1836- }
1837- Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1838- warn ! (
1839- "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1840- dest_path
1841- ) ;
1842- }
1843- _ => { }
1844- }
1783+ push_file_change_skipping_symlinks ( & mut changes, "add" , dest_path, & dest_full_path)
1784+ . await ?;
18451785 } else {
18461786 // Modified or other — read current content
1847- match tokio:: fs:: symlink_metadata ( & full_path) . await {
1848- Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1849- changes. push ( read_file_change ( "edit" , file_path, & full_path) . await ?) ;
1850- }
1851- Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1852- warn ! (
1853- "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1854- file_path
1855- ) ;
1856- }
1857- _ => { }
1858- }
1787+ push_file_change_skipping_symlinks ( & mut changes, "edit" , file_path, & full_path)
1788+ . await ?;
18591789 }
18601790 }
18611791
18621792 Ok ( changes)
18631793}
18641794
1795+ /// Push a file change into `changes`, skipping symlinks with a warning.
1796+ ///
1797+ /// Centralizes the "regular file → read & push; symlink → warn & skip; other → ignore"
1798+ /// logic used in multiple places when collecting changes from a worktree or diff tree.
1799+ /// Uses `symlink_metadata` so symlinks are detected without being followed — this is
1800+ /// the primary defense against symlink-following exfiltration attacks in Stage 3.
1801+ async fn push_file_change_skipping_symlinks (
1802+ changes : & mut Vec < serde_json:: Value > ,
1803+ change_type : & str ,
1804+ file_path : & str ,
1805+ full_path : & std:: path:: Path ,
1806+ ) -> anyhow:: Result < ( ) > {
1807+ match tokio:: fs:: symlink_metadata ( full_path) . await {
1808+ Ok ( meta) if meta. file_type ( ) . is_file ( ) => {
1809+ changes. push ( read_file_change ( change_type, file_path, full_path) . await ?) ;
1810+ }
1811+ Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
1812+ warn ! (
1813+ "Skipping symlink in worktree: {} (symlink-following attack prevention)" ,
1814+ file_path
1815+ ) ;
1816+ }
1817+ _ => { }
1818+ }
1819+ Ok ( ( ) )
1820+ }
1821+
18651822/// Read a file and produce an ADO push change entry.
18661823/// Handles both text (rawtext) and binary (base64encoded) content.
18671824async fn read_file_change (
0 commit comments