Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/CI_Execution.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
provardx-ci-execution:
strategy:
matrix:
os: ${{ fromJSON(inputs.OS && format('[{0}]', inputs.OS) || '["ubuntu-latest", "macos-latest"]') }}
os: ${{ fromJSON(inputs.OS && format('[{0}]', inputs.OS) || '["ubuntu-latest", "macos-latest", "windows-latest"]') }}
nodeversion: [20]
runs-on: ${{ matrix.os }}
steps:
Expand Down Expand Up @@ -99,6 +99,8 @@ jobs:
run: |
sf plugins link .
yarn prepack
- name: Unit tests
run: node node_modules/.bin/mocha "test/**/*.test.ts" --timeout 30000
- name: MCP smoke test
timeout-minutes: 5
env:
Expand Down
15 changes: 9 additions & 6 deletions .github/workflows/DeployManual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,21 @@ jobs:
else
# Auto-extract from git log since the previous tag
if [ "${{ github.event_name }}" = "release" ]; then
# For a release event HEAD is already the new tag; find the one before it
PREV=$(git tag --sort=-creatordate | awk -v tag="${GITHUB_REF_NAME}" '$0==tag{found=1;next} found{print;exit}')
# Release event: HEAD is the new tagfind the nearest ancestor tag before it
PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || git tag --sort=-version:refname | tail -1)
else
# For a manual dispatch use the most recent existing tag
PREV=$(git tag --sort=-creatordate | head -1)
# Manual dispatch: find the nearest ancestor tag from HEAD
# (git describe respects branch ancestry; avoids pulling in commits from sibling branches)
PREV=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true)
Comment on lines +68 to +73
fi

RANGE="${PREV:+${PREV}..}HEAD"

RAW=$(git log --pretty=format:"%s" $RANGE \
RAW=$(git log --pretty=format:"%s" "$RANGE" \
| sed 's/^[A-Z][A-Z0-9]*-[0-9]*: //' \
| grep -Ev "^(Merge |chore)" \
| grep -Ev "^(Merge |chore|bump|increment)" \
| grep -Ev "\(ci\):" \
| grep -Eiv "review comments?|merge conflict|feedback items?|test fixtures?|pre-landing|address session|address PR|session [0-9]|resolving merge|PR #[0-9]|dot\.notation|server\.tool|registerTool|bump version|increment version|testCasePath|planitem|uuid lookup|coverage (count|skew)|buildTestCase|awk regex|deploy workflow" \
| head -20)

FEATS=$(printf '%s\n' "$RAW" | grep '^feat' | sed 's/^feat[^:]*: /• /' | head -8)
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp-pilot-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ PathPolicy: assertPathAllowed(filePath, allowedPaths)

This check runs before every file read and write, including all path-type input fields — not just output file paths. Symlinks are dereferenced so that a symlink inside an allowed directory cannot escape containment. The allowed roots are set at server startup via `--allowed-paths` and cannot be changed while the server is running.

On **Windows**, all path comparisons are performed case-insensitively. `fs.realpathSync` does not always canonicalize drive-letter case (e.g. `c:\` vs `C:\`), so the policy normalizes both the candidate path and the allowed roots to lowercase before comparing. This means `C:\Projects\MyProject` and `c:\projects\myproject` are treated as the same path for containment purposes.

### Audit log

All tool invocations are logged to **stderr** with a unique `requestId` per call. The log format is structured JSON:
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,8 @@ All file-system operations (read, write, generate) are restricted to the paths s

Symlinks are resolved via `fs.realpathSync` before the containment check, so a symlink inside an allowed directory that points outside it cannot bypass the restriction. For tools that accept multiple path inputs (such as `provar_ant_generate`'s `provar_home`, `project_path`, and `results_path`), all path fields are validated before any file operation occurs — not just the output path.

On **Windows**, path comparisons are performed case-insensitively to account for the fact that `fs.realpathSync` does not always canonicalize drive-letter case (e.g. `c:\` vs `C:\`). This means `C:\Projects\my-project` and `c:\projects\my-project` are treated as equivalent when checking against `--allowed-paths`.

---

## Available tools
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@provartesting/provardx-cli",
"description": "A plugin for the Salesforce CLI to orchestrate testing activities and report quality metrics to Provar Quality Hub",
"version": "1.5.0-beta.15",
"version": "1.5.0-beta.16",
"mcpName": "io.github.ProvarTesting/provar",
"license": "BSD-3-Clause",
"plugins": [
Expand Down
19 changes: 14 additions & 5 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,28 @@
"url": "https://github.com/ProvarTesting/provardx-cli",
"source": "github"
},
"version": "1.5.0-beta.15",
"version": "1.5.0-beta.16",
"packages": [
{
"registryType": "npm",
"identifier": "@provartesting/provardx-cli",
"version": "1.5.0-beta.15",
"version": "1.5.0-beta.16",
"transport": {
"type": "stdio"
},
"runtimeArguments": [
{ "type": "positional", "value": "provar" },
{ "type": "positional", "value": "mcp" },
{ "type": "positional", "value": "start" },
{
"type": "positional",
"value": "provar"
},
{
"type": "positional",
"value": "mcp"
},
{
"type": "positional",
"value": "start"
},
{
"type": "named",
"name": "--allowed-paths",
Expand Down
11 changes: 10 additions & 1 deletion src/mcp/security/pathPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,18 @@ export function assertPathAllowed(filePath: string, allowedPaths: string[]): voi
}
});

// Windows file paths are case-insensitive; fs.realpathSync does not always
// canonicalize drive-letter case (e.g. `c:\` vs `C:\`), so compare case-insensitively.
const isWindows = process.platform === 'win32';
const normalizeForCompare = (p: string): string => (isWindows ? p.toLowerCase() : p);
const resolvedKey = normalizeForCompare(resolved);

if (
resolvedAllowed.length > 0 &&
!resolvedAllowed.some((base) => resolved === base || resolved.startsWith(base + path.sep))
!resolvedAllowed.some((base) => {
const baseKey = normalizeForCompare(base);
return resolvedKey === baseKey || resolvedKey.startsWith(baseKey + path.sep);
})
) {
throw new PathPolicyError(
'PATH_NOT_ALLOWED',
Expand Down
48 changes: 43 additions & 5 deletions test/unit/mcp/pathPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, afterEach } from 'mocha';
import { describe, it, afterEach, before } from 'mocha';
import { assertPathAllowed, PathPolicyError } from '../../../src/mcp/security/pathPolicy.js';

const tmp = os.tmpdir();
Expand Down Expand Up @@ -41,9 +41,7 @@ describe('pathPolicy', () => {
});

it('allows nested paths inside allowedPaths', () => {
assert.doesNotThrow(() =>
assertPathAllowed(path.join(tmp, 'a', 'b', 'c', 'file.xml'), [tmp])
);
assert.doesNotThrow(() => assertPathAllowed(path.join(tmp, 'a', 'b', 'c', 'file.xml'), [tmp]));
});

it('rejects sibling directories that share a prefix', () => {
Expand All @@ -63,7 +61,11 @@ describe('pathPolicy', () => {

afterEach(() => {
if (symlinkDir) {
try { fs.rmSync(symlinkDir, { recursive: true, force: true }); } catch { /* ignore */ }
try {
fs.rmSync(symlinkDir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
});

Expand Down Expand Up @@ -98,4 +100,40 @@ describe('pathPolicy', () => {
assert.doesNotThrow(() => assertPathAllowed(real, [allowedDir]));
});
});

describe('Windows case-insensitive comparison', () => {
before(function () {
if (process.platform !== 'win32') this.skip();
});

it('allows a path with lowercase drive letter when allowed has uppercase', () => {
// Reproduces GitHub Copilot bug: agent sends `c:\...` but allowed path is `C:\...`
const allowed = tmp; // typically `C:\Users\<user>\AppData\Local\Temp`
const lowerDrive = allowed.charAt(0).toLowerCase() + allowed.slice(1);
assert.doesNotThrow(() => assertPathAllowed(path.join(lowerDrive, 'foo.java'), [allowed]));
});

it('allows a path with uppercase drive letter when allowed has lowercase', () => {
const upperDrive = tmp.charAt(0).toUpperCase() + tmp.slice(1);
const lowerAllowed = tmp.charAt(0).toLowerCase() + tmp.slice(1);
assert.doesNotThrow(() => assertPathAllowed(path.join(upperDrive, 'foo.java'), [lowerAllowed]));
});

it('allows a path with mixed-case directory segments', () => {
const mixed = tmp.toUpperCase();
assert.doesNotThrow(() => assertPathAllowed(path.join(mixed, 'foo.java'), [tmp]));
});

it('still rejects a sibling path that differs only after the prefix (case-insensitive)', () => {
const allowed = path.join(tmp, 'myproject');
const sibling = path.join(tmp.toUpperCase(), 'myproject-evil', 'secret.txt');
try {
assertPathAllowed(sibling, [allowed]);
assert.fail('Expected PathPolicyError to be thrown');
} catch (e) {
assert.ok(e instanceof PathPolicyError, 'Expected PathPolicyError');
assert.equal(e.code, 'PATH_NOT_ALLOWED');
}
});
});
});
Loading