Skip to content

Commit f87707a

Browse files
wbrezaCopilot
andcommitted
feat: plan indicator dot and preview pane for session plans (#21)
Add cyan dot indicator and plan preview for Copilot CLI plan.md files: - Data layer: ScanPlans() detects plan.md existence, ReadPlanContent() reads plan content with 64KB cap, path traversal protection - Styles: PlanIndicatorStyle (BrightCyan bold), IconPlan() filled dot - Session list: cyan plan dot rendered after attention dot in each row - Preview panel: plan view mode toggled with 'v' key, Esc to return - Integration: plan scan piggybacks on attention scan tick cycle, plan content loaded on session selection Co-authored-by: Copilot <[email protected]>
1 parent df94090 commit f87707a

14 files changed

Lines changed: 678 additions & 12 deletions

File tree

go.mod

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/UserExistsError/conpty v0.1.4
77
github.com/charmbracelet/bubbles v1.0.0
88
github.com/charmbracelet/bubbletea v1.3.10
9-
github.com/charmbracelet/lipgloss v1.1.0
9+
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
1010
github.com/creack/pty v1.1.24
1111
github.com/github/copilot-sdk/go v0.1.32
1212
github.com/lucasb-eyer/go-colorful v1.3.0
@@ -16,28 +16,40 @@ require (
1616
)
1717

1818
require (
19+
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
1920
github.com/atotto/clipboard v0.1.4 // indirect
2021
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
22+
github.com/aymerick/douceur v0.2.0 // indirect
2123
github.com/charmbracelet/colorprofile v0.4.3 // indirect
24+
github.com/charmbracelet/glamour v1.0.0 // indirect
2225
github.com/charmbracelet/x/ansi v0.11.6 // indirect
2326
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
27+
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
2428
github.com/charmbracelet/x/term v0.2.2 // indirect
2529
github.com/clipperhouse/displaywidth v0.11.0 // indirect
2630
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
31+
github.com/dlclark/regexp2 v1.11.5 // indirect
2732
github.com/dustin/go-humanize v1.0.1 // indirect
2833
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2934
github.com/google/jsonschema-go v0.4.2 // indirect
3035
github.com/google/uuid v1.6.0 // indirect
36+
github.com/gorilla/css v1.0.1 // indirect
3137
github.com/mattn/go-isatty v0.0.20 // indirect
3238
github.com/mattn/go-localereader v0.0.1 // indirect
3339
github.com/mattn/go-runewidth v0.0.21 // indirect
40+
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
3441
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3542
github.com/muesli/cancelreader v0.2.2 // indirect
43+
github.com/muesli/reflow v0.3.0 // indirect
3644
github.com/ncruces/go-strftime v1.0.0 // indirect
3745
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
3846
github.com/rivo/uniseg v0.4.7 // indirect
3947
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
48+
github.com/yuin/goldmark v1.7.13 // indirect
49+
github.com/yuin/goldmark-emoji v1.0.6 // indirect
4050
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
51+
golang.org/x/net v0.38.0 // indirect
52+
golang.org/x/term v0.36.0 // indirect
4153
golang.org/x/text v0.35.0 // indirect
4254
modernc.org/libc v1.70.0 // indirect
4355
modernc.org/mathutil v1.7.1 // indirect

go.sum

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to=
22
github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
3+
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
4+
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
35
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
46
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
57
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
68
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
9+
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
10+
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
711
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
812
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
913
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
1014
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
1115
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
1216
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
17+
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
18+
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
1319
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
1420
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
21+
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
22+
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
1523
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
1624
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
1725
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
1826
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
27+
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
28+
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
1929
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
2030
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
2131
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
@@ -24,6 +34,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
2434
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
2535
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
2636
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
37+
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
38+
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
2739
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2840
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
2941
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -38,6 +50,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
3850
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
3951
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4052
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
53+
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
54+
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
4155
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
4256
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
4357
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -46,33 +60,48 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
4660
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
4761
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
4862
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
63+
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
4964
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
5065
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
66+
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
67+
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
5168
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
5269
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
5370
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
5471
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
72+
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
73+
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
5574
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
5675
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
5776
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
5877
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
5978
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
6079
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
80+
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
81+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
6182
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
6283
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
6384
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
6485
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
86+
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
87+
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
88+
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
89+
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
6590
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
6691
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
6792
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
6893
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
94+
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
95+
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
6996
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
7097
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
7198
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
7299
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
73100
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
74101
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
75102
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
103+
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
104+
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
76105
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
77106
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
78107
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=

internal/data/plans.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Package data — plans.go provides functions to detect and read Copilot CLI
2+
// plan.md files from session-state directories.
3+
package data
4+
5+
import (
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"regexp"
10+
"runtime"
11+
"strings"
12+
)
13+
14+
// maxPlanFileSize caps the number of bytes read from a plan.md file to
15+
// prevent memory exhaustion from adversarially large files.
16+
const maxPlanFileSize = 64 * 1024 // 64 KB
17+
18+
// sessionIDPattern matches safe session ID values (UUIDs, hex strings, and
19+
// similar tokens). Replicates the pattern from platform/shell.go to avoid
20+
// an import cycle.
21+
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)
22+
23+
// ScanPlans checks which of the given session IDs have a plan.md file in
24+
// their session-state directory. Returns a map of session ID → true for
25+
// sessions that have a plan. Sessions without a plan are omitted from the
26+
// map (not set to false) for efficiency.
27+
func ScanPlans(sessionIDs []string) map[string]bool {
28+
stateDir := sessionStatePath()
29+
if stateDir == "" {
30+
return nil
31+
}
32+
33+
result := make(map[string]bool, len(sessionIDs)/4) // expect ~25% to have plans
34+
for _, id := range sessionIDs {
35+
if !sessionIDPattern.MatchString(id) {
36+
continue
37+
}
38+
planPath := filepath.Join(stateDir, id, "plan.md")
39+
info, err := os.Lstat(planPath)
40+
if err != nil {
41+
continue
42+
}
43+
// Only accept regular files (no symlinks, devices, etc.).
44+
if info.Mode().IsRegular() && info.Size() > 0 {
45+
result[id] = true
46+
}
47+
}
48+
return result
49+
}
50+
51+
// ScanAllPlans reads the session-state directory and returns a map of
52+
// session ID → true for every session that has a non-empty plan.md file.
53+
// Unlike ScanPlans, this does not require a pre-filtered list of IDs —
54+
// it discovers sessions from the filesystem directly, matching the
55+
// pattern used by ScanAttention.
56+
func ScanAllPlans() map[string]bool {
57+
stateDir := sessionStatePath()
58+
if stateDir == "" {
59+
return nil
60+
}
61+
62+
entries, err := os.ReadDir(stateDir)
63+
if err != nil {
64+
return nil
65+
}
66+
67+
result := make(map[string]bool, len(entries)/4)
68+
for _, e := range entries {
69+
if !e.IsDir() {
70+
continue
71+
}
72+
id := e.Name()
73+
if !sessionIDPattern.MatchString(id) {
74+
continue
75+
}
76+
planPath := filepath.Join(stateDir, id, "plan.md")
77+
info, err := os.Lstat(planPath)
78+
if err != nil {
79+
continue
80+
}
81+
if info.Mode().IsRegular() && info.Size() > 0 {
82+
result[id] = true
83+
}
84+
}
85+
return result
86+
}
87+
88+
// ReadPlanContent reads the plan.md file for the given session ID.
89+
// Returns the plan content as a string, capped at maxPlanFileSize bytes.
90+
// Returns an empty string and an error if the file cannot be read or the
91+
// session ID is invalid.
92+
func ReadPlanContent(sessionID string) (string, error) {
93+
path, err := PlanFilePath(sessionID)
94+
if err != nil {
95+
return "", err
96+
}
97+
98+
// Use Lstat to reject symlinks before reading.
99+
info, err := os.Lstat(path)
100+
if err != nil {
101+
return "", err
102+
}
103+
if !info.Mode().IsRegular() {
104+
return "", &os.PathError{Op: "read", Path: path, Err: os.ErrInvalid}
105+
}
106+
107+
f, err := os.Open(path)
108+
if err != nil {
109+
return "", err
110+
}
111+
defer f.Close()
112+
113+
// Read up to maxPlanFileSize bytes using LimitReader to handle
114+
// short reads correctly (f.Read is not guaranteed to fill the buffer).
115+
content, err := io.ReadAll(io.LimitReader(f, maxPlanFileSize))
116+
if err != nil {
117+
return "", err
118+
}
119+
return string(content), nil
120+
}
121+
122+
// PlanFilePath returns the absolute path to the plan.md file for the
123+
// given session ID. Returns an error if the session ID is invalid or
124+
// the session-state directory cannot be resolved.
125+
func PlanFilePath(sessionID string) (string, error) {
126+
if !sessionIDPattern.MatchString(sessionID) {
127+
return "", &os.PathError{Op: "open", Path: sessionID, Err: os.ErrInvalid}
128+
}
129+
130+
stateDir := sessionStatePath()
131+
if stateDir == "" {
132+
return "", &os.PathError{Op: "open", Path: sessionID, Err: os.ErrNotExist}
133+
}
134+
135+
path := filepath.Join(stateDir, sessionID, "plan.md")
136+
137+
// Verify the resolved path is still under the session-state directory
138+
// to prevent path traversal via crafted session IDs like "a/../../../etc".
139+
cleaned := filepath.Clean(path)
140+
if runtime.GOOS == "windows" {
141+
if !strings.HasPrefix(strings.ToLower(cleaned), strings.ToLower(stateDir+string(filepath.Separator))) {
142+
return "", &os.PathError{Op: "open", Path: sessionID, Err: os.ErrPermission}
143+
}
144+
} else {
145+
if !strings.HasPrefix(cleaned, stateDir+string(filepath.Separator)) {
146+
return "", &os.PathError{Op: "open", Path: sessionID, Err: os.ErrPermission}
147+
}
148+
}
149+
150+
return path, nil
151+
}

0 commit comments

Comments
 (0)