Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ jobs:
- name: Scan build artifacts for secrets
run: gitleaks detect --config .gitleaks.toml --source ./build --no-git --verbose --redact

- name: Generate SBOM
run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json --output-format json

- name: Upload SBOM as release artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.json
if-no-files-found: error

- name: Version and Publish
uses: changesets/action@v1
with:
Expand Down
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
node_modules/
build/
node_modules
build
dist/
coverage/

Expand Down Expand Up @@ -29,6 +29,9 @@ coverage/
.automaker/authority/
.automaker/settings.json

# SBOM (generated artifact)
sbom.json

# Reports (generated artifacts)
protoLabs.report.html
*.report.html
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,12 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`LOCAL.md`](./LOCAL.md) for full

---

## Compliance

HELiXiR generates a [CycloneDX](https://cyclonedx.org/) Software Bill of Materials (SBOM) as part of every release. The `sbom.json` artifact is attached to each GitHub Release and lists all runtime and development dependencies with their versions, licenses, and package identifiers — suitable for enterprise security audits and supply-chain compliance reviews.

---

## Contributing

See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
Expand Down
1 change: 0 additions & 1 deletion build

This file was deleted.

1 change: 0 additions & 1 deletion node_modules

This file was deleted.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"files": [
"build",
"!build/**/*.map",
"packages/core/src",
"src/skills",
"README.md",
"CHANGELOG.md"
Expand Down
54 changes: 51 additions & 3 deletions packages/core/src/shared/error-handling.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { relative } from 'path';

export enum ErrorCategory {
VALIDATION = 'VALIDATION',
INVALID_INPUT = 'INVALID_INPUT',
Expand All @@ -18,26 +20,72 @@ export class MCPError extends Error {
}
}

/**
* Sanitizes an error message to prevent information disclosure:
* - Absolute paths that start with projectRoot are replaced with relative paths.
* - All other absolute paths (Unix or Windows) are replaced with "[path redacted]".
* - Regex pattern details in Zod/validation messages are stripped.
*/
export function sanitizeErrorMessage(message: string, projectRoot: string): string {
// Replace absolute paths that start with projectRoot with relative equivalents.
// We do this first (before the blanket redaction) so project-relative paths stay readable.
let sanitized = message;

if (projectRoot) {
// Normalize projectRoot to ensure no trailing slash
const normalizedRoot = projectRoot.replace(/\/+$/, '');
// Match the projectRoot prefix (possibly followed by more path characters)
const escapedRoot = normalizedRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const projectRootRegex = new RegExp(escapedRoot + '(/[^\\s]*)?', 'g');
sanitized = sanitized.replace(projectRootRegex, (match, rest) => {
// Compute the path relative to projectRoot
const relativePath = relative(normalizedRoot, match);
return relativePath || '.';
});
}

// Replace any remaining Unix absolute paths (starting with /)
// Matches paths like /foo/bar/baz (no whitespace)
sanitized = sanitized.replace(/(?<!\w)(\/[^\s]+)/g, '[path redacted]');

// Replace Windows absolute paths (e.g. C:\foo\bar)
sanitized = sanitized.replace(/[A-Za-z]:\\[^\s]*/g, '[path redacted]');

// Strip regex pattern details from Zod validation messages.
// Patterns like: /someRegex/, "Invalid regex: /pattern/"
sanitized = sanitized.replace(/Invalid regex:[^;,\n]*/gi, 'Invalid regex: [pattern redacted]');
sanitized = sanitized.replace(/\/[^/\s]{2,}\/[gimsuy]*/g, '[pattern redacted]');

return sanitized;
}

/**
* Wraps an unknown thrown value into an MCPError with an appropriate category.
* Inference rules (applied before falling back to UNKNOWN):
* - SyntaxError / TypeError → VALIDATION
* - Error with .code ENOENT → FILESYSTEM
* - Error with .code EACCES → FILESYSTEM
*
* When projectRoot is provided, absolute paths in error messages are sanitized:
* - Paths under projectRoot become relative paths
* - All other absolute paths are replaced with "[path redacted]"
* Zod/regex pattern details in VALIDATION errors are also stripped.
*/
export function handleToolError(error: unknown): MCPError {
export function handleToolError(error: unknown, projectRoot: string = ''): MCPError {
if (error instanceof MCPError) {
return error;
}

if (error instanceof SyntaxError || error instanceof TypeError) {
return new MCPError(error.message, ErrorCategory.VALIDATION);
const message = sanitizeErrorMessage(error.message, projectRoot);
return new MCPError(message, ErrorCategory.VALIDATION);
}

if (error instanceof Error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT' || nodeError.code === 'EACCES') {
return new MCPError(error.message, ErrorCategory.FILESYSTEM);
const message = sanitizeErrorMessage(error.message, projectRoot);
return new MCPError(message, ErrorCategory.FILESYSTEM);
}
return new MCPError(error.message, ErrorCategory.UNKNOWN);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export async function handleBenchmarkCall(

return createErrorResponse(`Unknown benchmark tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
2 changes: 1 addition & 1 deletion packages/core/src/tools/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function handleBundleCall(
}
return createErrorResponse(`Unknown bundle tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/cdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function handleCdnCall(
}
return createErrorResponse(`Unknown CDN tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ export async function handleComponentCall(

return createErrorResponse(`Unknown component tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ export async function handleDiscoveryCall(

return createErrorResponse(`Unknown discovery tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function handleFrameworkCall(
}
return createErrorResponse(`Unknown framework tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ export async function handleHealthCall(

return createErrorResponse(`Unknown health tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export async function handleLibraryCall(

return createErrorResponse(`Unknown library tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export async function handleSafetyCall(

return createErrorResponse(`Unknown safety tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function handleScaffoldCall(

return createErrorResponse(`Unknown scaffold tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, _config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function handleTokenCall(

return createErrorResponse(`Unknown token tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function handleTypeScriptCall(

return createErrorResponse(`Unknown TypeScript tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
21 changes: 12 additions & 9 deletions packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ The path can be relative to the workspace root or absolute.

## Commands

| Command | Description |
|---------|-------------|
| Command | Description |
| --------------------------- | ------------------------------------------------------ |
| `Helixir: Run Health Check` | Guides you to run a health check via your AI assistant |

## Extension Settings

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `helixir.configPath` | `string` | `""` | Path to `mcpwc.config.json`. Empty = workspace root. |
| Setting | Type | Default | Description |
| -------------------- | -------- | ------- | ---------------------------------------------------- |
| `helixir.configPath` | `string` | `""` | Path to `mcpwc.config.json`. Empty = workspace root. |

## How It Works

Expand All @@ -58,24 +58,27 @@ The server reads your `custom-elements.json` and exposes 30+ tools that AI model

The helixir server is configured via environment variables passed by the extension:

| Variable | Description |
|----------|-------------|
| `MCP_WC_PROJECT_ROOT` | Set to your workspace folder automatically |
| `MCP_WC_CONFIG_PATH` | Set when `helixir.configPath` is configured |
| Variable | Description |
| --------------------- | ------------------------------------------- |
| `MCP_WC_PROJECT_ROOT` | Set to your workspace folder automatically |
| `MCP_WC_CONFIG_PATH` | Set when `helixir.configPath` is configured |

Additional configuration (token path, component prefix, health history dir) belongs in `mcpwc.config.json`. See the [helixir documentation](https://github.com/bookedsolidtech/helixir) for the full config reference.

## Troubleshooting

**MCP server not appearing in AI assistant tools**

- Verify VS Code ≥ 1.99.0 is installed
- Confirm your workspace contains a `custom-elements.json`
- Check the Output panel → Helixir for error messages

**"No workspace folder" error from Run Health Check**

- Open a folder (not just a file) in VS Code — the extension uses the workspace folder as the project root

**Server starts but returns no components**

- Ensure `custom-elements.json` exists at the workspace root or configure `helixir.configPath`
- Regenerate the manifest: `npm run analyze:cem` (or your CEM generation script)

Expand Down
Loading
Loading