Skip to content

Commit 356bc47

Browse files
committed
fix(web/shell): #107 review — an agent's command cannot write .GIT/config or .VSCODE/tasks.json
The protected paths of shellCommit.js (.git, .vscode, .levelcode, .cursor, .husky, .github/workflows) were matched as typed; only the instruction-file pattern ignored case. On a disk that ignores case — the usual folder from the computer: APFS on a Mac, NTFS on Windows — `.GIT/config` is `.git/config`, so an agent's `printf '[core]\n\tfsmonitor = sh evil.sh\n' > .GIT/config` went to the review as an ordinary file and was applied ("1 file changed, ready for Keep/Undo: ~.GIT/config"), reproduced against 187fd21. isProtected now matches the folded path (foldCase): NFC, as the shell folds names in its own commit (web/shell/src/commit.js), then upper case before lower case, so that U+017F (long s) and U+0131 (dotless i) land on s and i too; U+212A (the Kelvin sign) is K once normalised. Wider than any one disk, which only refuses names nobody would choose. The visitor's own terminal (web/shell/src/commit.js) has no protected list, by design ("the user's own terminal is not held to this"), so there was nothing to fold there. Test (test/shellCommit.test.js): "protected paths are refused in any letter case" — the reported command, each protected name in other cases and as a delete, the three Unicode letters, and names that only look similar (.GITIGNORE, my.GIT/x) still allowed. It fails on 187fd21 (the .GIT/config write is not refused) and passes here.
1 parent 720f5a3 commit 356bc47

2 files changed

Lines changed: 38 additions & 3 deletions

File tree

‎extensions/levelcode-ai/shellCommit.js‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
* What is refused, and why:
2020
* - outside the workspace folders (a mount is the boundary)
2121
* - repository / tool metadata and instruction files (a script can hide `rm -rf .git`, or write
22-
* .vscode/settings.json or AGENTS.md, which later
22+
* (in any letter case: .GIT is .git on most disks) .vscode/settings.json or AGENTS.md, which later
2323
* steer the agent with the user's authority)
2424
* - binary, non-UTF-8, BOM or over 1 MiB (the review snapshots text; Undo would write
2525
* mangled bytes back)
@@ -37,7 +37,8 @@
3737
'use strict';
3838

3939
/**
40-
* Never changed by a command, in either direction. Matched against the path RELATIVE to its workspace folder.
40+
* Never changed by a command, in either direction. Matched against the path RELATIVE to its workspace folder, folded
41+
* (foldCase): written in lower case, they match every spelling a disk that ignores case takes for the same name.
4142
* The user's own terminal is not held to this (it is the user typing); an agent's command is.
4243
*/
4344
const PROTECTED = [
@@ -114,8 +115,20 @@ function reviewPathFor(abs, folders) {
114115
return { rel, review: folders.length > 1 ? best.name + '/' + rel : rel };
115116
}
116117

118+
/**
119+
* A name as a disk that ignores case sees it. The usual folder from the computer is on one (APFS on a Mac, NTFS on
120+
* Windows), and there `.GIT/config` IS `.git/config`: matched as typed, a command wrote the repository's config by
121+
* spelling it in capitals. NFC first, as the shell folds names in its own commit (web/shell/src/commit.js); then
122+
* upper case before lower case, so that the letters lower-casing alone leaves as they are — U+017F (long s) and U+0131
123+
* (dotless i), which upper-case to S and I — land on the plain letter too (U+212A, the Kelvin sign, is K once
124+
* normalised). Wider than any one disk, which only ever refuses a name nobody would choose. Letter case and Unicode
125+
* form only: a name a disk treats as another for some other reason is not folded here.
126+
* @param {string} s
127+
*/
128+
function foldCase(s) { return s.normalize('NFC').toUpperCase().toLowerCase(); }
129+
117130
/** @param {string} rel */
118-
function isProtected(rel) { return PROTECTED.some((re) => re.test(rel)); }
131+
function isProtected(rel) { const folded = foldCase(rel); return PROTECTED.some((re) => re.test(folded)); }
119132

120133
/**
121134
* @typedef {import('./host').Change} Change

‎extensions/levelcode-ai/test/shellCommit.test.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,28 @@ const ids = (out) => out.failed.map((f) => f.c.path).sort();
161161
for (const p of ['gitlab.md', 'src/vscode.js', 'docs/AGENTS.txt', '.github/ISSUE_TEMPLATE/bug.md', 'README.md']) { assert.ok(!isProtected(p), p + ' is not protected'); }
162162
});
163163

164+
await test('protected paths are refused in any letter case: on a disk that ignores case (the usual folder from the computer) .GIT/config IS .git/config', async () => {
165+
// The reported command: printf '[core]\n\tfsmonitor = sh evil.sh\n' > .GIT/config — it was applied.
166+
const r = rig({ '/ws/.GIT/config': '[core]\n' });
167+
const out = await commitShellChanges([W('/ws/.GIT/config', '[core]\n\tfsmonitor = sh evil.sh\n', '[core]\n')], r.ctx, r.io);
168+
assert.strictEqual(out.refused, true);
169+
assert.deepStrictEqual(r.edits, []);
170+
assert.match(out.notes[0], /refused: \.GIT\/config — repository metadata/);
171+
const folded = ['.Git/hooks/pre-commit', 'sub/.GIT/config', '.VSCODE/tasks.json', '.VsCode/settings.json', '.LevelCode/mcp.json', '.CURSOR/rules/x.mdc',
172+
'.Husky/pre-commit', '.GITHUB/WORKFLOWS/ci.yml', '.github/Workflows/ci.yml', '.CursorRules', '.WINDSURFRULES',
173+
'.v\u017Fcode/settings.json', // U+017F, the long s: S in upper case
174+
'.github/wor\u212Aflows/ci.yml', // U+212A, the Kelvin sign: K once normalised
175+
'.g\u0131t/config']; // U+0131, the dotless i: I in upper case
176+
for (const p of folded) {
177+
assert.ok(isProtected(p), JSON.stringify(p) + ' should be protected');
178+
const w = rig();
179+
assert.strictEqual((await commitShellChanges([W('/ws/' + p, 'x')], w.ctx, w.io)).refused, true, p);
180+
const d = rig({ ['/ws/' + p]: 'x' });
181+
assert.strictEqual((await commitShellChanges([D('/ws/' + p, 'x')], d.ctx, d.io)).refused, true, p + ' (delete)');
182+
}
183+
for (const p of ['GIT/config', 'src/Git.js', '.GITIGNORE', '.Gitattributes', 'VSCODE/x', '.github/WORKFLOW.md', 'my.GIT/x', 'Agents.txt']) { assert.ok(!isProtected(p), p + ' is not protected'); }
184+
});
185+
164186
await test('outside the workspace folders: refused', async () => {
165187
const r = rig();
166188
const out = await commitShellChanges([W('/etc/passwd', 'x')], r.ctx, r.io);

0 commit comments

Comments
 (0)