Skip to content

Commit 10cbff3

Browse files
committed
fix(agent-context): harden context file validation
1 parent abcaecc commit 10cbff3

5 files changed

Lines changed: 354 additions & 10 deletions

File tree

extensions/agent-context/scripts/bash/update-agent-context.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,21 @@ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
150150
exit 1
151151
fi
152152
done
153+
if ! "$_python" - "$PROJECT_ROOT" "$CONTEXT_FILE" <<'PY'
154+
import sys
155+
from pathlib import Path
156+
157+
root = Path(sys.argv[1]).resolve()
158+
target = (root / sys.argv[2]).resolve(strict=False)
159+
try:
160+
target.relative_to(root)
161+
except ValueError:
162+
sys.exit(1)
163+
PY
164+
then
165+
echo "agent-context: context file path resolves outside the project root; got '$CONTEXT_FILE'." >&2
166+
exit 1
167+
fi
153168
done
154169
unset _cf_parts _seg
155170

extensions/agent-context/scripts/powershell/update-agent-context.ps1

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,66 @@ function Test-ConfigObject {
5252
return $false
5353
}
5454

55+
function Resolve-ContextPath {
56+
param(
57+
[Parameter(Mandatory = $true)][string]$Root,
58+
[Parameter(Mandatory = $true)][string]$RelativePath
59+
)
60+
61+
$rootFull = [System.IO.Path]::GetFullPath($Root)
62+
$segments = $RelativePath -split '/'
63+
$resolved = $rootFull
64+
65+
foreach ($segment in $segments) {
66+
if ([string]::IsNullOrWhiteSpace($segment) -or $segment -eq '.') {
67+
continue
68+
}
69+
70+
$candidate = [System.IO.Path]::GetFullPath((Join-Path $resolved $segment))
71+
if (Test-Path -LiteralPath $candidate) {
72+
$item = Get-Item -LiteralPath $candidate -Force
73+
if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
74+
$target = $item.Target
75+
if ($target -is [System.Array]) {
76+
$target = $target[0]
77+
}
78+
if ($target) {
79+
if ([System.IO.Path]::IsPathRooted($target)) {
80+
$candidate = [System.IO.Path]::GetFullPath($target)
81+
} else {
82+
$candidate = [System.IO.Path]::GetFullPath(
83+
(Join-Path (Split-Path -Parent $candidate) $target)
84+
)
85+
}
86+
}
87+
}
88+
}
89+
$resolved = $candidate
90+
}
91+
92+
return $resolved
93+
}
94+
95+
function Test-IsSubPath {
96+
param(
97+
[Parameter(Mandatory = $true)][string]$Root,
98+
[Parameter(Mandatory = $true)][string]$Path
99+
)
100+
101+
$comparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) {
102+
[System.StringComparison]::OrdinalIgnoreCase
103+
} else {
104+
[System.StringComparison]::Ordinal
105+
}
106+
$rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd(
107+
[System.IO.Path]::DirectorySeparatorChar,
108+
[System.IO.Path]::AltDirectorySeparatorChar
109+
)
110+
$pathFull = [System.IO.Path]::GetFullPath($Path)
111+
return $pathFull.Equals($rootFull, $comparison) -or
112+
$pathFull.StartsWith($rootFull + [System.IO.Path]::DirectorySeparatorChar, $comparison)
113+
}
114+
55115
$ErrorActionPreference = 'Stop'
56116
$DefaultStart = '<!-- SPECKIT START -->'
57117
$DefaultEnd = '<!-- SPECKIT END -->'
@@ -143,8 +203,8 @@ if (-not (Test-ConfigObject -Object $Options)) {
143203

144204
$ConfiguredContextFiles = Get-ConfigValue -Object $Options -Key 'context_files'
145205
$ContextFiles = @()
146-
if ($ConfiguredContextFiles -is [System.Array]) {
147-
foreach ($item in $ConfiguredContextFiles) {
206+
if ($null -ne $ConfiguredContextFiles) {
207+
foreach ($item in @($ConfiguredContextFiles)) {
148208
if ($item -is [string] -and -not [string]::IsNullOrWhiteSpace($item)) {
149209
$ContextFiles += $item.Trim()
150210
}
@@ -163,16 +223,25 @@ if ($ContextFiles.Count -eq 0) {
163223
}
164224

165225
foreach ($ContextFile in $ContextFiles) {
166-
# Reject absolute paths and '..' path segments in context files
226+
# Reject absolute paths, backslash separators, and '..' path segments in context files
167227
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
168228
Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'."
169229
exit 1
170230
}
231+
if ($ContextFile.Contains('\')) {
232+
Write-Warning "agent-context: context files must not contain backslash separators; got '$ContextFile'."
233+
exit 1
234+
}
171235
$cfSegments = $ContextFile -split '[/\\]'
172236
if ($cfSegments -contains '..') {
173237
Write-Warning "agent-context: context files must not contain '..' path segments; got '$ContextFile'."
174238
exit 1
175239
}
240+
$resolvedTarget = Resolve-ContextPath -Root $ProjectRoot -RelativePath $ContextFile
241+
if (-not (Test-IsSubPath -Root $ProjectRoot -Path $resolvedTarget)) {
242+
Write-Warning "agent-context: context file path resolves outside the project root; got '$ContextFile'."
243+
exit 1
244+
}
176245
}
177246

178247
$MarkerStart = $DefaultStart

src/specify_cli/agents.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -412,14 +412,29 @@ def resolve_skill_placeholders(
412412
ac_cfg = _load_agent_context_config(project_root)
413413
context_files = ac_cfg.get("context_files")
414414
if isinstance(context_files, list):
415-
context_file_values = [
416-
value.strip()
417-
for value in context_files
418-
if isinstance(value, str) and value.strip()
419-
]
415+
context_file_values = []
416+
seen: set[str] = set()
417+
for value in context_files:
418+
if not isinstance(value, str):
419+
continue
420+
candidate = value.strip()
421+
if not candidate or candidate in seen:
422+
continue
423+
context_file_values.append(
424+
IntegrationBase._validate_context_file_path(
425+
project_root, candidate
426+
)
427+
)
428+
seen.add(candidate)
420429
context_file = ", ".join(dict.fromkeys(context_file_values))
421430
if not context_file:
422-
context_file = ac_cfg.get("context_file") or ""
431+
configured_context_file = ac_cfg.get("context_file")
432+
if isinstance(configured_context_file, str):
433+
candidate = configured_context_file.strip()
434+
if candidate:
435+
context_file = IntegrationBase._validate_context_file_path(
436+
project_root, candidate
437+
)
423438
if not context_file:
424439
context_file = init_opts.get("context_file") or ""
425440
body = body.replace("__CONTEXT_FILE__", context_file)

src/specify_cli/integrations/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ def _resolve_context_files(self, project_root: Path) -> list[str]:
759759
def _context_file_display(self, project_root: Path) -> str:
760760
"""Return human-readable context file target(s) for templates."""
761761
if not self._agent_context_extension_enabled(project_root):
762-
return self.context_file
762+
return self.context_file or ""
763763
return ", ".join(self._resolve_context_files(project_root))
764764

765765
@staticmethod

0 commit comments

Comments
 (0)