Skip to content

Commit a1c795b

Browse files
mitchdennyMitch DennyCopilot
authored andcommitted
Replace Playwright MCP server with Playwright CLI installation (#14569)
* Replace Playwright MCP server with Playwright CLI installation Replace the Playwright MCP server configuration in `aspire agent init` with a secure Playwright CLI installation workflow. Instead of writing MCP server configuration to each agent environment's config file, the new approach: - Resolves the @playwright/cli package version from npm registry - Downloads the package tarball via `npm pack` - Verifies supply chain integrity (SHA-512 SRI hash comparison) - Runs `npm audit signatures` for provenance verification - Installs globally from the verified tarball - Runs `playwright-cli install --skills` to generate skill files New abstractions: - INpmRunner/NpmRunner: npm CLI command runner (resolve, pack, audit, install) - IPlaywrightCliRunner/PlaywrightCliRunner: playwright-cli command runner - PlaywrightCliInstaller: orchestrates the secure install flow This removes ~400 lines of per-scanner MCP config writing code (different JSON formats for VS Code, Claude Code, Copilot CLI, and OpenCode) and replaces it with a single global CLI install. Fixes #14430 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Pin Playwright CLI version range to 0.1.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add npm provenance verification and break-glass config - Add INpmProvenanceChecker/NpmProvenanceChecker for SLSA attestation verification - Return rich ProvenanceVerificationResult with gate-specific outcome enum - Fix AuditSignaturesAsync with temp-project approach for global tools - Add disablePlaywrightCliPackageValidation break-glass config option - Add security design document (docs/specs/safe-npm-tool-install.md) - Verify SRI integrity, Sigstore attestations, and source repository provenance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix markdownlint: add language to fenced code block Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve version resolution and provenance verification - Change version range from exact 0.1.1 to >=0.1.1 for future versions - Add playwrightCliVersion config override for pinning specific versions - Verify workflow path (.github/workflows/publish.yml) in provenance - Verify SLSA build type (GitHub Actions) to confirm OIDC token issuer - Add BuildType to NpmProvenanceData, WorkflowMismatch and BuildTypeMismatch outcomes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add tests for version pinning and default version range Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add E2E test for Playwright CLI installation via agent init Verifies the full lifecycle: project creation, aspire agent init with Claude Code environment, Playwright CLI installation with npm provenance verification, and skill file generation. Marked as OuterloopTest since it requires npm and network access. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Show status spinner during Playwright CLI installation Wrap the installation work in IInteractionService.ShowStatusAsync to display a spinner with 'Installing Playwright CLI...' status text while the npm operations are in progress. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Mirror playwright-cli skill files to all detected agent environments After playwright-cli install --skills creates files in .claude/skills/, the installer now mirrors the playwright-cli skill directory to all other detected agent environment skill directories (.github/skills/, .opencode/skill/, etc.) so every configured environment has identical skill files. Stale files in target directories are also removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update security design doc to match implementation - Step 4 now documents all three provenance gates: source repository, workflow path, and build type (with OIDC issuer implication) - Added table of verified fields with expected values - Updated implementation constants to include new fields - Added configuration section documenting break-glass keys - Updated verification diagram with workflow/build type checks - Step 7 now documents skill file mirroring across environments - Future improvements reflects experimental Sigstore branch status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Verify workflow ref matches package version in provenance Add Gate 6 to provenance verification: check that the workflow ref (git tag) in the SLSA attestation matches refs/tags/v{version}. This ensures the build was triggered from the expected release tag, not an arbitrary branch or commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use callback-based workflow ref validation in provenance checker Make the workflow ref validation configurable per-package by accepting a Func<WorkflowRefInfo, bool> callback instead of hardcoding the refs/tags/v{version} format. The ref is parsed into a WorkflowRefInfo record (Raw, Kind, Name) and the caller decides what's valid. PlaywrightCliInstaller validates Kind=tags and Name=v{version}. Other packages can use different tag conventions without modifying the provenance checker. Addresses review feedback from DamianEdwards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Replace npm provenance checking with Sigstore verification Use the Sigstore and Tuf NuGet packages (0.2.0) to cryptographically verify npm attestation bundles in-process, replacing the previous approach of shelling out to 'npm audit signatures'. - Add SigstoreNpmProvenanceChecker implementing INpmProvenanceChecker using SigstoreVerifier with CertificateIdentity.ForGitHubActions - Remove the npm audit signatures step from PlaywrightCliInstaller - Keep existing NpmProvenanceChecker but no longer register in DI - Add optional sriIntegrity parameter to INpmProvenanceChecker for digest-based bundle verification - Update safe-npm-tool-install.md spec to reflect new verification flow - Temporarily add nuget.org to NuGet.config for Sigstore/Tuf packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refactor SigstoreNpmProvenanceChecker for clarity Break the monolithic VerifyProvenanceAsync method into focused methods: - FetchAttestationJsonAsync: fetches attestation JSON from npm registry - ParseAttestation: parses JSON in a single pass, extracting both the Sigstore bundle and provenance data (eliminates duplicate JSON parsing) - ParseProvenanceFromStatement: extracts provenance fields from in-toto statement - VerifySigstoreBundleAsync: cryptographic Sigstore verification - VerifyProvenanceFields: field-level provenance checks (source repo, workflow, build type, workflow ref) Removes dependency on NpmProvenanceChecker.ParseProvenance() which was re-parsing the same JSON and iterating attestations a second time. Adds NpmAttestationParseResult type to carry both bundle and provenance data from a single parse pass. Adds comprehensive unit tests for ParseAttestation, ParseProvenanceFromStatement, and VerifyProvenanceFields covering success and failure cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add libsodium to nuget-org package source mapping libsodium is a transitive dependency of NSec.Cryptography (used by Sigstore) and needs to be mapped to the nuget-org source for CI restore to succeed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mitch Denny <mitch@mitchdeny.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7182c6a commit a1c795b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+3603
-392
lines changed

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@
132132
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
133133
<PackageVersion Include="StreamJsonRpc" Version="2.22.23" />
134134
<PackageVersion Include="Semver" Version="3.0.0" />
135+
<PackageVersion Include="Sigstore" Version="0.2.0" />
136+
<PackageVersion Include="Tuf" Version="0.2.0" />
135137
<PackageVersion Include="Microsoft.DevTunnels.Connections" Version="1.3.12" />
136138
<!-- Open Telemetry -->
137139
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="$(AzureMonitorOpenTelemetryExporterVersion)" />

NuGet.config

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
<add key="dotnet10" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json" />
2121
<add key="dotnet-libraries" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-libraries/nuget/v3/index.json" />
2222
<add key="dotnet9-transport" value="https://dnceng.pkgs.visualstudio.com/public/_packaging/dotnet9-transport/nuget/v3/index.json" />
23+
<!-- TEMPORARY: nuget.org for Sigstore/Tuf packages until synced to internal feed -->
24+
<add key="nuget-org" value="https://api.nuget.org/v3/index.json" />
2325
</packageSources>
2426
<packageSourceMapping>
2527
<packageSource key="dotnet9-transport">
@@ -43,6 +45,13 @@
4345
<packageSource key="dotnet-eng">
4446
<package pattern="*" />
4547
</packageSource>
48+
<!-- TEMPORARY: Sigstore/Tuf packages from nuget.org until synced to internal feed -->
49+
<packageSource key="nuget-org">
50+
<package pattern="Sigstore" />
51+
<package pattern="Tuf" />
52+
<package pattern="NSec.Cryptography" />
53+
<package pattern="libsodium" />
54+
</packageSource>
4655
</packageSourceMapping>
4756
<disabledPackageSources>
4857
<!--Begin: Package sources managed by Dependency Flow automation. Do not edit the sources below.-->
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# Safe npm Global Tool Installation
2+
3+
## Overview
4+
5+
The Aspire CLI installs the `@playwright/cli` npm package as a global tool during `aspire agent init`. Because this tool runs with the user's full privileges, we must verify its authenticity and provenance before installation. This document describes the verification process, the threat model, and the reasoning behind each step.
6+
7+
## Threat Model
8+
9+
### What we're protecting against
10+
11+
1. **Registry compromise** — An attacker gains write access to the npm registry and publishes a malicious version of `@playwright/cli`
12+
2. **Publish token theft** — An attacker steals a maintainer's npm publish token and publishes a tampered package
13+
3. **Man-in-the-middle** — An attacker intercepts the network request and substitutes a different tarball
14+
4. **Dependency confusion** — A malicious package with a similar name is installed instead of the intended one
15+
16+
### What we're NOT protecting against
17+
18+
- Compromise of the legitimate source repository (`microsoft/playwright-cli`) itself
19+
- Compromise of the GitHub Actions build infrastructure (Sigstore OIDC provider)
20+
- Compromise of the Sigstore transparency log infrastructure
21+
- Malicious code introduced through legitimate dependencies of `@playwright/cli`
22+
23+
### Trust anchors
24+
25+
Our verification chain relies on these trust anchors:
26+
27+
| Trust anchor | What it provides | How it's protected |
28+
|---|---|---|
29+
| **npm registry** | Package metadata, tarball hosting | HTTPS/TLS, npm's infrastructure security |
30+
| **Sigstore (Fulcio + Rekor)** | Cryptographic attestation signatures | Public CA with OIDC federation, append-only transparency log, verified in-process via Sigstore .NET library with TUF trust root |
31+
| **GitHub Actions OIDC** | Builder identity claims in Sigstore certificates | GitHub's infrastructure security |
32+
| **Hardcoded expected values** | Package name, version range, expected source repository | Code review, our own release process |
33+
34+
## Verification Process
35+
36+
### Step 1: Resolve package version and metadata
37+
38+
**Action:** Run `npm view @playwright/cli@{versionRange} version` and `npm view @playwright/cli@{version} dist.integrity` to get the resolved version and the registry's SRI integrity hash. The default version range is `>=0.1.1`, which resolves to the latest published version at or above 0.1.1. This can be overridden to a specific version via the `playwrightCliVersion` configuration key.
39+
40+
**What this establishes:** We know the exact version we intend to install and the hash the registry claims for its tarball.
41+
42+
**Trust basis:** npm registry over HTTPS/TLS.
43+
44+
**Limitations:** If the registry is compromised, both the version and hash could be attacker-controlled. This step alone is insufficient — it only establishes what the registry *claims*.
45+
46+
### Step 2: Check if already installed at a suitable version
47+
48+
**Action:** Run `playwright-cli --version` and compare against the resolved version.
49+
50+
**What this establishes:** Whether installation can be skipped entirely (already up-to-date or newer).
51+
52+
**Trust basis:** The previously-installed binary. If the user's system is compromised, this could be spoofed, but that's outside our threat model.
53+
54+
### Step 3: Verify Sigstore attestation and provenance metadata
55+
56+
**Action:**
57+
1. Fetch the attestation bundle from `https://registry.npmjs.org/-/npm/v1/attestations/@playwright/cli@{version}`
58+
2. Find the attestation with `predicateType: "https://slsa.dev/provenance/v1"` (SLSA Build L3 provenance)
59+
3. Extract the Sigstore bundle from the `bundle` field of the attestation
60+
4. Cryptographically verify the Sigstore bundle using the `SigstoreVerifier` from the [Sigstore .NET library](https://github.com/mitchdenny/sigstore-dotnet), with a `VerificationPolicy` configured for `CertificateIdentity.ForGitHubActions("microsoft", "playwright-cli")`
61+
5. Base64-decode the DSSE envelope payload to extract the in-toto statement
62+
6. Verify the following fields from the provenance predicate:
63+
64+
| Field | Location in payload | Expected value | What it proves |
65+
|---|---|---|---|
66+
| **Source repository** | `predicate.buildDefinition.externalParameters.workflow.repository` | `https://github.com/microsoft/playwright-cli` | The package was built from the legitimate source code |
67+
| **Workflow path** | `predicate.buildDefinition.externalParameters.workflow.path` | `.github/workflows/publish.yml` | The build used the expected CI pipeline, not an ad-hoc or attacker-injected workflow |
68+
| **Build type** | `predicate.buildDefinition.buildType` | `https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1` | The build ran on GitHub Actions, which implicitly confirms the OIDC token issuer is `https://token.actions.githubusercontent.com` |
69+
| **Workflow ref** | `predicate.buildDefinition.externalParameters.workflow.ref` | Validated via caller-provided callback (for `@playwright/cli`: kind=`tags`, name=`v{version}`) | The build was triggered from a version tag matching the package version, not an arbitrary branch or commit. The tag format is package-specific — different packages may use different conventions (e.g., `v0.1.1`, `0.1.1`, `@scope/pkg@0.1.1`). The ref is parsed into structured components (`WorkflowRefInfo`) and the caller provides a validation callback. |
70+
71+
**What this establishes:** That the Sigstore bundle is cryptographically authentic — the signing certificate was issued by Sigstore's Fulcio CA, the signature is recorded in the Rekor transparency log, and the OIDC identity in the certificate matches the `microsoft/playwright-cli` GitHub Actions workflow. Additionally, the provenance metadata confirms the package was built from the expected repository, workflow, CI system, and version tag.
72+
73+
**Trust basis:** Sigstore's public key infrastructure via the `Sigstore` and `Tuf` .NET libraries. The TUF trust root is automatically downloaded and verified. Even if the npm registry is compromised, an attacker cannot forge valid Sigstore signatures — they would need to compromise Fulcio (the Sigstore CA) or obtain a valid OIDC token from GitHub Actions for the legitimate repository's workflow. Since the Sigstore verification and provenance field checking happen on the same attestation bundle in a single operation, there is no TOCTOU gap between signature verification and content inspection.
74+
75+
**Why we verify all provenance fields:** Checking only the Sigstore certificate identity (GitHub Actions + repository) is necessary but not sufficient. An attacker with write access to the repo could introduce a malicious workflow (e.g., `.github/workflows/evil.yml`). By also verifying the workflow path, build type, and workflow ref, we ensure the package was built by the specific expected CI pipeline from a release tag.
76+
77+
**Additional fields extracted but not directly verified:** The provenance parser also extracts `runDetails.builder.id` from the attestation. This is available in the `NpmProvenanceData` result for logging and diagnostics but is not currently used as a verification gate.
78+
79+
### Step 4: Download and verify tarball integrity
80+
81+
**Action:**
82+
1. Run `npm pack @playwright/cli@{version}` to download the tarball
83+
2. Compute SHA-512 hash of the downloaded tarball
84+
3. Compare against the SRI integrity hash obtained in Step 1
85+
86+
**What this establishes:** That the tarball we have on disk is bit-for-bit identical to what the npm registry published for this version.
87+
88+
**Trust basis:** Cryptographic hash comparison (SHA-512). If the hash matches, the content is the same regardless of how it was delivered.
89+
90+
**Relationship to Step 3:** The Sigstore attestations verified in Step 3 are bound to the package version and its published content. The integrity hash in the registry packument is the canonical identifier for the tarball content. By verifying our tarball matches this hash, we establish that our tarball is the same artifact that the Sigstore attestations cover.
91+
92+
### Step 5: Install globally from verified tarball
93+
94+
**Action:** Run `npm install -g {tarballPath}` to install the verified tarball as a global tool.
95+
96+
**What this establishes:** The tool is installed and available on the user's PATH.
97+
98+
**Trust basis:** All preceding verification steps have passed. The tarball content has been verified against the registry's published hash (Step 4), the Sigstore attestations for that content are cryptographically valid (Step 3), and the attestations confirm the correct source repository, workflow, and build system (Step 3).
99+
100+
### Step 6: Generate and mirror skill files
101+
102+
**Action:** Run `playwright-cli install --skills` to generate agent skill files in the primary skill directory (`.claude/skills/playwright-cli/`), then mirror the skill directory to all other detected agent environment skill directories (e.g., `.github/skills/playwright-cli/`, `.opencode/skill/playwright-cli/`). The mirror is a full sync — files are created, updated, and stale files are removed so all environments have identical skill content.
103+
104+
**What this establishes:** The Playwright CLI skill files are available for all configured agent environments.
105+
106+
## Verification Chain Summary
107+
108+
```text
109+
┌──────────────────────────────┐
110+
│ Hardcoded expectations │
111+
│ • Package: @playwright/cli │
112+
│ • Version range: >=0.1.1 │
113+
│ • Source: microsoft/ │
114+
│ playwright-cli │
115+
│ • Workflow: .github/ │
116+
│ workflows/publish.yml │
117+
│ • Build type: GitHub Actions │
118+
│ workflow/v1 │
119+
└──────────────┬────────────────┘
120+
121+
┌──────────────▼────────────────┐
122+
│ Step 1: Resolve version + │
123+
│ integrity hash from registry │
124+
└──────────────┬────────────────┘
125+
126+
┌────────────────────┼────────────────────┐
127+
│ │
128+
┌──────────▼──────────────┐ ┌─────────▼─────────┐
129+
│ Step 3: Sigstore verify │ │ Step 4: npm pack │
130+
│ + provenance checks │ │ + SHA-512 check │
131+
│ (in-process via Sigstore │ │ (tarball │
132+
│ .NET library + TUF) │ │ integrity) │
133+
└──────────┬───────────────┘ └─────────┬─────────┘
134+
│ │
135+
│ Attestation is authentic + │ Tarball matches
136+
│ built from expected repo + │ published hash
137+
│ expected pipeline │
138+
└────────────────────┬────────────────────┘
139+
140+
┌──────────────▼────────────────┐
141+
│ Step 5: npm install -g │
142+
│ (from verified tarball) │
143+
└───────────────────────────────┘
144+
```
145+
146+
## Residual Risks
147+
148+
### 1. Time-of-check-to-time-of-use (TOCTOU)
149+
150+
**Risk:** The package could be replaced on the registry between our verification steps and the global install.
151+
152+
**Mitigation:** We verify the SHA-512 hash of the tarball we actually install (Step 4), and we install from the local tarball file (not from the registry again). The verified tarball is the same file that gets installed.
153+
154+
### 2. Transitive dependency attacks
155+
156+
**Risk:** `@playwright/cli` has dependencies that could be compromised.
157+
158+
**Mitigation:** The `--ignore-scripts` flag prevents execution of install scripts. However, the dependencies' code runs when the tool is invoked. This is partially mitigated by Sigstore attestations covering the dependency tree, but comprehensive supply chain verification of all transitive dependencies is out of scope.
159+
160+
## Implementation Constants
161+
162+
```csharp
163+
internal const string PackageName = "@playwright/cli";
164+
internal const string VersionRange = ">=0.1.1";
165+
internal const string ExpectedSourceRepository = "https://github.com/microsoft/playwright-cli";
166+
internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml";
167+
internal const string ExpectedBuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1";
168+
internal const string NpmRegistryAttestationsBaseUrl = "https://registry.npmjs.org/-/npm/v1/attestations";
169+
internal const string SlsaProvenancePredicateType = "https://slsa.dev/provenance/v1";
170+
```
171+
172+
## Configuration
173+
174+
Two break-glass configuration keys are available via `aspire config set`:
175+
176+
| Key | Effect |
177+
|---|---|
178+
| `disablePlaywrightCliPackageValidation` | When `"true"`, skips all Sigstore, provenance, and integrity checks. Use only for debugging npm service issues. |
179+
| `playwrightCliVersion` | When set, overrides the version range and pins to the specified exact version. |
180+
181+
## Future Improvements
182+
183+
1. **Pinned tarball hash** — Ship a known-good SRI hash with each Aspire release, eliminating the need to trust the registry for the hash at all.

src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ internal sealed class AgentEnvironmentScanContext
1010
{
1111
private readonly List<AgentEnvironmentApplicator> _applicators = [];
1212
private readonly HashSet<string> _skillFileApplicatorPaths = new(StringComparer.OrdinalIgnoreCase);
13+
private readonly HashSet<string> _skillBaseDirectories = new(StringComparer.OrdinalIgnoreCase);
1314

1415
/// <summary>
1516
/// Gets the working directory being scanned.
@@ -24,31 +25,11 @@ internal sealed class AgentEnvironmentScanContext
2425
public required DirectoryInfo RepositoryRoot { get; init; }
2526

2627
/// <summary>
27-
/// Gets or sets a value indicating whether a Playwright applicator has been added.
28+
/// Gets or sets a value indicating whether a Playwright CLI applicator has been added.
2829
/// This is used to ensure only one applicator for Playwright is added across all scanners.
2930
/// </summary>
3031
public bool PlaywrightApplicatorAdded { get; set; }
3132

32-
/// <summary>
33-
/// Stores the Playwright configuration callbacks from each scanner.
34-
/// These will be executed if the user selects to configure Playwright.
35-
/// </summary>
36-
private readonly List<Func<CancellationToken, Task>> _playwrightConfigurationCallbacks = [];
37-
38-
/// <summary>
39-
/// Adds a Playwright configuration callback for a specific environment.
40-
/// </summary>
41-
/// <param name="callback">The callback to execute if Playwright is configured.</param>
42-
public void AddPlaywrightConfigurationCallback(Func<CancellationToken, Task> callback)
43-
{
44-
_playwrightConfigurationCallbacks.Add(callback);
45-
}
46-
47-
/// <summary>
48-
/// Gets all registered Playwright configuration callbacks.
49-
/// </summary>
50-
public IReadOnlyList<Func<CancellationToken, Task>> PlaywrightConfigurationCallbacks => _playwrightConfigurationCallbacks;
51-
5233
/// <summary>
5334
/// Checks if a skill file applicator has already been added for the specified path.
5435
/// </summary>
@@ -82,4 +63,19 @@ public void AddApplicator(AgentEnvironmentApplicator applicator)
8263
/// Gets the collection of detected applicators.
8364
/// </summary>
8465
public IReadOnlyList<AgentEnvironmentApplicator> Applicators => _applicators;
66+
67+
/// <summary>
68+
/// Registers a skill base directory for an agent environment (e.g., ".claude/skills", ".github/skills").
69+
/// These directories are used to mirror skill files across all detected agent environments.
70+
/// </summary>
71+
/// <param name="relativeSkillBaseDir">The relative path to the skill base directory from the repository root.</param>
72+
public void AddSkillBaseDirectory(string relativeSkillBaseDir)
73+
{
74+
_skillBaseDirectories.Add(relativeSkillBaseDir);
75+
}
76+
77+
/// <summary>
78+
/// Gets the registered skill base directories for all detected agent environments.
79+
/// </summary>
80+
public IReadOnlyCollection<string> SkillBaseDirectories => _skillBaseDirectories;
8581
}

0 commit comments

Comments
 (0)