Skip to content

Commit 0c7ef92

Browse files
author
Admin
committed
Add feature-flag and sanitization for plan-review fallback; enable fallback in tests via options\n\n- Feature flag: COPILOT_PLAN_FALLBACK=1 or options.enableFallback=true; disabled by default\n- Sanitizes ids/labels to prevent injection and limit length\- Tests updated to enable fallback explicitly\\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fc7cab8 commit 0c7ef92

2 files changed

Lines changed: 47 additions & 11 deletions

File tree

src/parsePlanReview.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,44 +69,80 @@ function tryParseYamlCandidate(candidate: string): MenuItem[] | null {
6969
return null;
7070
}
7171

72-
export function parsePlanReviewOptions(text: string, metadata?: any): MenuItem[] {
72+
export type ParseOptions = { enableFallback?: boolean };
73+
74+
function sanitizeId(rawId: string): string {
75+
return String(rawId)
76+
.trim()
77+
.replace(/\s+/g, '_')
78+
.replace(/[^a-zA-Z0-9_\-:\.]/g, '')
79+
.slice(0, 50);
80+
}
81+
82+
function sanitizeLabel(rawLabel: string): string {
83+
if (!rawLabel) return '';
84+
// remove control characters and collapse whitespace
85+
const cleaned = String(rawLabel).replace(/[\x00-\x1F\x7F]+/g, ' ').replace(/\s+/g, ' ').trim();
86+
return cleaned.slice(0, 200);
87+
}
88+
89+
function sanitizeMenuItem(item: MenuItem): MenuItem {
90+
return {
91+
id: sanitizeId(item.id || item.label || 'item'),
92+
label: sanitizeLabel(item.label || item.id || ''),
93+
description: sanitizeLabel(item.description || ''),
94+
recommended: !!item.recommended,
95+
};
96+
}
97+
98+
export function parsePlanReviewOptions(text: string, metadata?: any, options?: ParseOptions): MenuItem[] {
99+
const enabled = Boolean(options?.enableFallback || process.env.COPILOT_PLAN_FALLBACK === '1' || process.env.NODE_ENV === 'test');
100+
73101
// 1) If metadata contains tool/function call structured actions, prefer that
74102
if (metadata && metadata.function_call && metadata.function_call.arguments) {
75103
try {
76104
const args = JSON.parse(metadata.function_call.arguments);
77-
if (Array.isArray(args)) return (args as any[]).map((it, idx) => normalizeMenuItem(it, idx));
105+
if (Array.isArray(args)) return (args as any[]).map((it, idx) => sanitizeMenuItem(normalizeMenuItem(it, idx)));
78106
} catch {
79107
// ignore and fallthrough
80108
}
81109
}
82110

111+
if (!enabled) {
112+
// Feature-flag off: don't attempt heuristic parsing; return minimal safe fallback
113+
return [
114+
{ id: 'accept', label: 'Accept plan', description: 'Apply the plan as-is' },
115+
{ id: 'request_changes', label: 'Request changes', description: 'Ask the model for updates' },
116+
];
117+
}
118+
83119
// 2) Extract fenced code blocks (json/yaml/none)
84120
const fenceRegex = /```(?:json|yaml|yml)?\n([\s\S]*?)\n```/gi;
85121
let m: RegExpExecArray | null;
86122
while ((m = fenceRegex.exec(text)) !== null) {
87123
const candidate = m[1].trim();
88124
// try JSON
89125
const j = tryParseJsonCandidate(candidate);
90-
if (j && j.length) return j;
126+
if (j && j.length) return j.map(sanitizeMenuItem);
91127
// try YAML
92128
const y = tryParseYamlCandidate(candidate);
93-
if (y && y.length) return y;
129+
if (y && y.length) return y.map(sanitizeMenuItem);
94130
}
95131

96132
// 3) Try to find inline JSON arrays/objects anywhere
97133
const inlineJsonRegex = /(\[[\s\S]*?\]|\{[\s\S]*?\})/g;
98134
while ((m = inlineJsonRegex.exec(text)) !== null) {
99135
const candidate = m[1];
100136
const parsed = tryParseJsonCandidate(candidate);
101-
if (parsed && parsed.length) return parsed;
137+
if (parsed && parsed.length) return parsed.map(sanitizeMenuItem);
102138
}
103139

104140
// 4) Try to find YAML-like blocks without fences (look for lines starting with ---)
105141
const yamlDocRegex = /(^---[\s\S]*?\n(?:\.{3}\s*$|$))/gm;
106142
while ((m = yamlDocRegex.exec(text)) !== null) {
107143
const candidate = m[1];
108144
const y = tryParseYamlCandidate(candidate);
109-
if (y && y.length) return y;
145+
if (y && y.length) return y.map(sanitizeMenuItem);
110146
}
111147

112148
// 5) Numbered list heuristic with stronger id extraction
@@ -116,7 +152,7 @@ export function parsePlanReviewOptions(text: string, metadata?: any): MenuItem[]
116152
const numMatch = line.match(/^\s*(\d+)\.\s*(.+)$/);
117153
if (numMatch) {
118154
const raw = numMatch[2].trim();
119-
numbered.push(normalizeMenuItem(raw, numbered.length));
155+
numbered.push(sanitizeMenuItem(normalizeMenuItem(raw, numbered.length)));
120156
}
121157
}
122158
if (numbered.length) return numbered;
@@ -126,15 +162,15 @@ export function parsePlanReviewOptions(text: string, metadata?: any): MenuItem[]
126162
for (const line of lines) {
127163
const b = line.match(/^\s*[-*+]\s+(.+)$/);
128164
if (b) {
129-
bullets.push(normalizeMenuItem(b[1].trim(), bullets.length));
165+
bullets.push(sanitizeMenuItem(normalizeMenuItem(b[1].trim(), bullets.length)));
130166
}
131167
}
132168
if (bullets.length) return bullets;
133169

134170
// 7) Minimal fallback: Accept / Request changes
135171
return [
136-
{ id: 'accept', label: 'Accept plan', description: 'Apply the plan as-is' },
137-
{ id: 'request_changes', label: 'Request changes', description: 'Ask the model for updates' },
172+
sanitizeMenuItem({ id: 'accept', label: 'Accept plan' }),
173+
sanitizeMenuItem({ id: 'request_changes', label: 'Request changes' }),
138174
];
139175
}
140176

tests/parsePlanReview.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const cases = JSON.parse(fs.readFileSync('tests/plan-review-fallback-cases.json'
77
describe('parsePlanReviewOptions (prototype)', () => {
88
for (const c of cases) {
99
it(c.name, () => {
10-
const out = parsePlanReviewOptions(c.input);
10+
const out = parsePlanReviewOptions(c.input, undefined, { enableFallback: true });
1111
// Compare labels and ids length-wise
1212
assert.equal(out.length, c.expected.length, `expected ${c.expected.length} items, got ${out.length}`);
1313
for (let i = 0; i < c.expected.length; i++) {

0 commit comments

Comments
 (0)