Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6434f49
docs: secure Power Pages approvals
priyanshu92 Aug 6, 2026
3abedfb
Pin Power Pages Playwright MCP
priyanshu92 Aug 6, 2026
cef3b4f
Harden Playwright MCP root resolution
priyanshu92 Aug 6, 2026
e1b328b
Merge remote-tracking branch 'refs/remotes/origin/users/priyanshu92/r…
priyanshu92 Aug 6, 2026
fd3ae61
Harden export solution ZIP validation
priyanshu92 Aug 6, 2026
5e58408
Harden Power Pages command and URL handling
priyanshu92 Aug 6, 2026
da858e2
Merge PR 382 root hardening
priyanshu92 Aug 6, 2026
6136d27
address PR review feedback
priyanshu92 Aug 6, 2026
928bd48
address PR review feedback
priyanshu92 Aug 6, 2026
9294cd7
address PR review feedback
priyanshu92 Aug 6, 2026
566c2d0
Accept case-insensitive HTTPS schemes
priyanshu92 Aug 6, 2026
0a0e8e7
Close remaining Power Pages shell sinks
priyanshu92 Aug 6, 2026
83e730f
Make shell regression harness portable
priyanshu92 Aug 6, 2026
c09f2a0
Canonicalize detected host URLs
priyanshu92 Aug 6, 2026
34660d1
Merge PR #385 ZIP validation
priyanshu92 Aug 6, 2026
95c07be
address PR review feedback
priyanshu92 Aug 6, 2026
a8a13d4
Trigger stacked PR checks
priyanshu92 Aug 6, 2026
1f08be1
Run stacked PR checks
priyanshu92 Aug 6, 2026
af93501
Align integration request transport
priyanshu92 Aug 6, 2026
b8bac6b
Run final stacked PR checks
priyanshu92 Aug 6, 2026
25bb930
Separate integration header contract tests
priyanshu92 Aug 6, 2026
953c055
Merge PR 383 into safe ZIP validation
priyanshu92 Aug 6, 2026
04954fe
Merge updated safe ZIP validation layer
priyanshu92 Aug 6, 2026
2ddd2c9
address PR review feedback
priyanshu92 Aug 11, 2026
0bb7b7d
Merge PR 382 root hardening updates
priyanshu92 Aug 11, 2026
437333e
address PR review feedback
priyanshu92 Aug 11, 2026
fa5f35c
Merge final PR 382 containment fix
priyanshu92 Aug 11, 2026
04ca651
Merge final PR 383 tip into safe ZIP validation
priyanshu92 Aug 11, 2026
48afdd2
Merge final safe ZIP validation layer
priyanshu92 Aug 11, 2026
adc3c22
Merge commit '362eb38dfea65fef4dfa026f8cacb19ad77fe3af' into users/pr…
priyanshu92 Aug 11, 2026
614e962
Merge commit 'adc3c2294ee05dcdedbe439ee52be305ae1269ab' into users/pr…
priyanshu92 Aug 11, 2026
fd5fd58
Merge updated PR 382 base
priyanshu92 Aug 11, 2026
156559b
Merge latest PR 383 tip into safe ZIP validation
priyanshu92 Aug 11, 2026
27c9c61
Merge latest safe ZIP validation layer
priyanshu92 Aug 11, 2026
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
2 changes: 1 addition & 1 deletion plugins/power-pages/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"command": "node",
"args": [
"-e",
"const fs=require('node:fs'); const path=require('node:path'); const root=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.cwd(); const entry=path.resolve(root,'scripts','launch-playwright-mcp.js'); if(!fs.existsSync(entry)) throw new Error('Could not resolve Power Pages plugin root; set PLUGIN_ROOT or launch from the plugin root'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') throw new Error('Power Pages Playwright MCP launcher did not export launch()'); mod.launch();"
"const fs=require('node:fs'); const path=require('node:path'); const fail=(message)=>{throw new Error('[Power Pages Playwright MCP] '+message);}; const declaredRoot=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT; if(!declaredRoot) fail('PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set; refusing to resolve the launcher from the current working directory.'); if(!path.isAbsolute(declaredRoot)) fail('Declared plugin root must be an absolute path: '+declaredRoot+'.'); let root; try{root=fs.realpathSync(declaredRoot);}catch(error){fail('Declared plugin root is invalid: '+declaredRoot+' ('+(error.code||error.message)+').');} let rootStat; try{rootStat=fs.statSync(root);}catch(error){fail('Could not inspect declared plugin root: '+root+' ('+(error.code||error.message)+').');} if(!rootStat.isDirectory()) fail('Declared plugin root is not a directory: '+declaredRoot+'.'); const candidate=path.resolve(root,'scripts','launch-playwright-mcp.js'); let entry; try{entry=fs.realpathSync(candidate);}catch(error){fail('Launcher was not found under the declared plugin root: '+candidate+'.');} const relative=path.relative(root,entry); if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative)) fail('Resolved launcher escapes the declared plugin root: '+entry+'.'); let entryStat; try{entryStat=fs.statSync(entry);}catch(error){fail('Could not inspect resolved launcher: '+entry+' ('+(error.code||error.message)+').');} if(!entryStat.isFile()) fail('Resolved launcher is not a file: '+entry+'.'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') fail('Launcher did not export launch(): '+entry+'.'); mod.launch();"
]
},
"microsoft-learn": {
Expand Down
39 changes: 14 additions & 25 deletions plugins/power-pages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,8 @@ The plugin ships with two MCP servers configured in `.mcp.json` — they start a
| **playwright** | Headless browser automation for live previews and runtime tests |
| **microsoft-learn** | Grounded search/fetch over official Microsoft Learn docs |

The plugin host must provide an absolute `PLUGIN_ROOT` (GitHub Copilot) or `CLAUDE_PLUGIN_ROOT` (Claude Code). The Playwright bootstrap resolves its launcher only from that declared plugin root and never from the workspace working directory.

## Typical Workflow

A common end-to-end workflow looks like this:
Expand Down Expand Up @@ -465,33 +467,20 @@ A common end-to-end workflow looks like this:

Steps can be run independently — you don't need to follow this exact order. Each skill checks its own prerequisites and will tell you if something is missing. If something goes wrong, `/diagnose-deployment` pattern-matches deployment errors and `/report-issue` opens a pre-filled GitHub issue.

## Running Without Interruption

The plugin invokes multiple tools during a session. To reduce approval prompts:

**Option 1 — Permission mode (recommended)**

```jsonc
// .claude/settings.json
{
"defaultMode": "acceptEdits",
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(git *)",
"Bash(pac *)",
"Bash(az *)",
"Bash(node *)"
]
}
}
```
## Runtime approvals

**Option 2 — Auto-accept all**
Keep your AI host's runtime approval prompts enabled while using this plugin.
Plugin scripts run on your workstation with the filesystem access and cloud sign-in state available to your user account.
A script that invokes `pac` or `az` may therefore act on Power Platform environments, Dataverse data, and Azure tenants that you can access.

```bash
claude --dangerously-skip-permissions
```
Before approving a command, check the executable, script path, arguments, and target environment.
Pay particular attention to commands that read or change project files, environment configuration, tenant resources, or business data.
Do not grant blanket approval to command families such as `node`, `npm`, `git`, `pac`, or `az`.

If your host supports command-specific allow rules, use them only for an exact plugin script path that you have inspected and expect to run.
Keep approval prompts for commands whose arguments or environment variables select a project, environment, tenant, or data source.
Permission features and rule syntax vary by host and version, so follow the documentation for your host.
Suppressing an approval prompt does not sandbox a script, restrict the programs it can start, or guarantee that the command is safe.

## ALM prompts you may see

Expand Down
91 changes: 68 additions & 23 deletions plugins/power-pages/scripts/launch-playwright-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,95 @@
// then falls back to Playwright's bundled Chromium.
// Self-contained — no external dependencies required.

const { spawn } = require('child_process');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const path = require('path');
const { detectBrowser } = require('./lib/detect-browser');

function quoteShellArg(value, platform = process.platform) {
const argument = String(value);

if (platform === 'win32') {
if (argument.includes('"')) {
throw new Error('Cannot quote an argument containing double quotes for cmd.exe.');
}

return `"${argument}"`;
}

return `'${argument.replace(/'/g, "'\\''")}'`;
}
const PLAYWRIGHT_MCP_VERSION = '0.0.78';
const PLAYWRIGHT_MCP_PACKAGE = `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`;

function buildMcpArgs(browser, {
configPath = path.join(__dirname, 'playwright-mcp-fullscreen.config.json'),
platform = process.platform,
} = {}) {
// Marketplace installs copy only this plugin directory and do not run npm install,
// so a lockfile would not materialize a local executable. Keep the runtime package
// immutable, and disable lifecycle scripts while npx prepares the reviewed version.
return [
'-y',
'@playwright/mcp@latest',
'--yes',
'--ignore-scripts',
`--package=${PLAYWRIGHT_MCP_PACKAGE}`,
'playwright-mcp',
'--browser',
browser,
'--config',
quoteShellArg(configPath, platform),
configPath,
];
}

function resolveNpxCli({
execPath = process.execPath,
platform = process.platform,
existsSync = fs.existsSync,
} = {}) {
// Windows exposes npx as a .cmd shim that cannot run with shell:false. Invoking
// npm's JavaScript entrypoint through Node preserves raw argv on every platform.
const pathApi = platform === 'win32' ? path.win32 : path.posix;
const nodeDir = pathApi.dirname(execPath);
const candidates = [
pathApi.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js'),
pathApi.join(nodeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js'),
];
const match = candidates.find((candidate) => existsSync(candidate));

if (!match) {
throw new Error(
'Could not locate npm/bin/npx-cli.js beside the current Node installation. Install Node.js with npm before starting the Playwright MCP server.',
);
}

return match;
}

function launch({ browser = detectBrowser(), spawnFn = spawn, onExit = (code) => process.exit(code || 0) } = {}) {
const child = spawnFn('npx', buildMcpArgs(browser), {
function launch({
browser = detectBrowser(),
npxCliPath,
resolveNpxCliFn = resolveNpxCli,
spawnFn = spawn,
exitFn = (code) => process.exit(code),
writeError = (message) => process.stderr.write(message),
} = {}) {
let resolvedNpxCliPath = npxCliPath;
if (resolvedNpxCliPath === undefined) {
try {
resolvedNpxCliPath = resolveNpxCliFn();
} catch (error) {
writeError(`Failed to start Playwright MCP: ${error.message}\n`);
exitFn(1);
return null;
}
}

const child = spawnFn(process.execPath, [resolvedNpxCliPath, ...buildMcpArgs(browser)], {
stdio: 'inherit',
shell: true,
shell: false,
});

child.on('exit', onExit);
child.once('error', (error) => {
writeError(`Failed to start Playwright MCP: ${error.message}\n`);
exitFn(1);
});
child.once('exit', (code) => exitFn(code ?? 1));
return child;
}

if (require.main === module) {
launch();
}

module.exports = { buildMcpArgs, launch, quoteShellArg };
module.exports = {
PLAYWRIGHT_MCP_PACKAGE,
buildMcpArgs,
launch,
resolveNpxCli,
};
46 changes: 5 additions & 41 deletions plugins/power-pages/scripts/lib/check-solution-installed.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,51 +12,15 @@ const helpers = require('./validation-helpers');
const UNIQUE_NAME_RE = /^[A-Za-z0-9_]+$/;

/**
* Validates and normalizes a Dataverse environment URL before it is passed
* to anything that interpolates it into a shell command (notably
* helpers.getAuthToken, which calls `az account get-access-token --resource
* "${url}"` via execSync). Returns the URL's `origin` only (scheme + host +
* optional port) so path, query, fragment, and userinfo are all stripped.
*
* Throws on:
* - non-string / empty input
* - input that `new URL()` can't parse
* - non-https protocol (Dataverse refuses http and we don't want file: etc.)
* - URLs with embedded userinfo (https://user:pass@host) — credentials in
* URLs are a smell and can confuse downstream tooling
*
* The normalized origin is safe to interpolate into a shell command because
* URL.origin only contains scheme, host, and port — characters that the
* URL spec disallows from carrying shell metacharacters.
* Compatibility wrapper for existing callers. The shared validation helper
* owns the trust policy so token acquisition and authenticated requests use
* the same public and sovereign cloud endpoint allowlist.
*
* @param {unknown} envUrl
* @returns {string} sanitized origin, e.g. "https://contoso.crm.dynamics.com"
* @throws Error with a human-readable message on rejection
* @returns {string} validated Dataverse origin
*/
function sanitizeEnvUrl(envUrl) {
if (typeof envUrl !== 'string' || envUrl.trim() === '') {
throw new Error('envUrl must be a non-empty string.');
}

let parsed;
try {
parsed = new URL(envUrl);
} catch {
throw new Error(`envUrl is not a valid URL: "${envUrl}".`);
}

if (parsed.protocol !== 'https:') {
throw new Error(`envUrl must use https (got "${parsed.protocol}").`);
}

if (parsed.username || parsed.password) {
throw new Error('envUrl must not contain userinfo (username/password). Authentication uses the Azure CLI token, not credentials in the URL.');
}

// url.origin is the scheme + host + port — no path, no query, no fragment.
// For "https://contoso.crm.dynamics.com:443/api/data/v9.2/?x=1#anchor"
// it returns "https://contoso.crm.dynamics.com:443".
return parsed.origin;
return helpers.validateDataverseEnvironmentUrl(envUrl, 'envUrl');
}

/**
Expand Down
Loading
Loading