Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: bundle assistant workflows with visor:// protocol, docs, and examples#421

Merged
buger merged 3 commits intomainfrom
feat/builtin-workflows
Mar 3, 2026
Merged

feat: bundle assistant workflows with visor:// protocol, docs, and examples#421
buger merged 3 commits intomainfrom
feat/builtin-workflows

Conversation

@buger
Copy link
Contributor

@buger buger commented Mar 2, 2026

Summary

  • Bundle assistant.yaml, code-talk.yaml, and intent-router.yaml in defaults/ so they ship with every visor install
  • Add visor:// (and backward-compat visor-ee://) URL scheme in workflow-registry.ts — resolves to defaults/ with no network fetch
  • Path traversal guard rejects visor://../../etc/passwd style attacks
  • Add comprehensive docs/assistant-workflows.md — skills, tools, intents, knowledge injection, composition patterns, deployment modes
  • Add 3 example configs: code-talk-workflow.yaml, code-talk-as-tool.yaml, intent-router-workflow.yaml
  • Add AI Assistant Framework section to README.md

Test plan

  • npm run build compiles cleanly
  • npm test — all 107 tests pass (0 failures)
  • Unit tests for visor:// resolution, visor-ee:// backward compat, and path traversal rejection
  • Manual: create config with imports: [visor://assistant.yaml] and verify it loads

🤖 Generated with Claude Code

…rkflows

Move assistant, code-talk, and intent-router workflow definitions from
visor-ee into the main repo so they ship with every visor install.
Add visor:// (and backward-compat visor-ee://) URL scheme resolution
in workflow-registry so configs can reference bundled workflows without
network fetches.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@probelabs
Copy link
Contributor

probelabs bot commented Mar 2, 2026

PR Overview: Bundle Assistant Workflows with visor:// Protocol

Summary

This PR introduces three production-ready assistant workflows bundled directly into the visor package, along with a custom visor:// URL protocol for importing these built-in workflows without network dependencies. This enables zero-config access to sophisticated AI assistant capabilities.

What This PR Accomplishes

1. Bundled Workflows (3,869 new lines)

Three comprehensive workflows are now included in the defaults/ directory:

  • defaults/assistant.yaml (2,141 lines) - High-level AI assistant workflow featuring:

    • Intent routing with AI-powered classification
    • Unified skills system bundling knowledge + tools with dependency management
    • Dynamic MCP server activation based on classified skills
    • Support for explicit markers (#skill, %intent)
    • Comprehensive test suite with 30+ test cases
  • defaults/code-talk.yaml (1,250 lines) - Multi-repository code exploration workflow:

    • Architecture-aware project routing
    • Git checkout for docs and code repositories
    • Confidence-scored answers with references
    • Support for PR review and version-specific queries
    • 8 test cases covering edge cases
  • defaults/intent-router.yaml (478 lines) - Lightweight intent classification:

    • AI-based intent and tag/skill classification
    • Request rewriting as short questions
    • Explicit marker support (#tag, %intent)
    • 6 test cases for routing scenarios

2. Custom URL Protocol

Added visor:// scheme resolution in src/workflow-registry.ts:

// Resolves visor://workflows/assistant.yaml to local package files
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');
  const defaultsDir = path.resolve(__dirname, '..', 'defaults');
  const filePath = path.resolve(defaultsDir, relativePath);
  // Security: Prevent path traversal
  if (!filePath.startsWith(defaultsDir + path.sep)) {
    throw new Error(`Invalid visor:// path: resolved path escapes defaults directory`);
  }
  const content = await fs.readFile(filePath, 'utf-8');
  return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

Key features:

  • Zero network fetch required for built-in workflows
  • Backward compatibility with visor-ee:// URLs
  • Path traversal protection
  • Comprehensive test coverage (3 new test cases)

3. Usage Example

Users can now reference built-in workflows in their configs:

imports:
  - visor://assistant.yaml

checks:
  chat:
    type: workflow
    workflow: assistant
    args:
      question: "{{ outputs['ask'].text }}"
      intents:
        - id: chat
          description: general Q&A
      skills:
        - id: jira
          description: needs Jira access
          knowledge: "Use jira_get_issue to fetch tickets."
          tools:
            jira:
              command: uvx
              args: [mcp-atlassian]

Files Changed

File Status Lines Description
src/workflow-registry.ts Modified +14 Added visor:// protocol handler with security checks
tests/unit/workflow-registry.test.ts Modified +48 Added 3 test cases for protocol resolution
defaults/assistant.yaml Added +2141 Main assistant workflow with unified skills
defaults/code-talk.yaml Added +1250 Code exploration workflow
defaults/intent-router.yaml Added +478 Intent classification workflow

Total: 5 files changed, 3,931 additions, 0 deletions

Architecture & Impact Assessment

Component Relationships

graph TD
    A[User Config] -->|imports: visor://assistant.yaml| B[WorkflowRegistry]
    B -->|detects visor:// protocol| C[Protocol Handler]
    C -->|resolves to local path| D[defaults/assistant.yaml]
    D -->|imports| E[defaults/intent-router.yaml]
    D -->|imports| F[defaults/code-talk.yaml]
    
    style B fill:#e1f5ff
    style C fill:#fff4e1
    style D fill:#e8f5e9
    style E fill:#f3e5f5
    style F fill:#f3e5f5
Loading

Key Technical Changes

1. Protocol Resolution (src/workflow-registry.ts:421-434)

  • Detects visor:// and visor-ee:// prefixes
  • Resolves relative paths from package defaults directory
  • Validates paths don't escape defaults directory (security)
  • Returns file content with metadata

2. Workflow Features

  • Unified Skills: Bundles knowledge + tools with requires dependency field
  • Intent Routing: AI-powered classification with explicit marker support
  • Dynamic Configuration: Runtime activation of MCP servers based on classified skills
  • Multi-Repo Support: Architecture-aware routing across multiple code repositories
  • Comprehensive Testing: 44+ test cases across all three workflows

Affected System Components

  • Workflow Import System: New protocol handler extends existing URL/file import logic
  • Distribution: Built-in workflows now ship with every npm install of visor
  • Configuration: Users can reference visor://assistant.yaml in their configs
  • Backward Compatibility: Existing visor-ee:// URLs continue to work

Scope Discovery & Context Expansion

Immediate Impact

  • Entry Points: src/workflow-registry.ts is the main integration point
  • Configuration Files: Users can now use visor://assistant.yaml in .visor.yaml
  • Tests: All 107 tests pass (0 failures), including 3 new tests for protocol resolution

Related Files to Review

  • defaults/ - Contains the three new bundled workflow files
  • examples/ - Example configs should demonstrate visor:// usage
  • docs/ - Documentation for visor:// protocol usage

Potential Follow-up Areas

  • Documentation updates for visor:// protocol usage
  • Migration guide for existing GitHub URL imports
  • Versioning strategy for bundled workflows

Testing Status

  • npm run build compiles cleanly
  • npm test - all 107 tests pass (0 failures)
  • ✅ Protocol resolution tests: 3 new test cases covering valid URLs, backward compat, and path traversal protection
  • ✅ Workflow test suites: 44+ test cases across all three bundled workflows
  • ⏳ Manual: Config with visor://assistant.yaml loading (pending)
  • ⏳ Backward compat: visor-ee:// resolution verification (pending)

Review Notes

The bundled workflows are substantial and well-tested. Key review areas:

  1. Protocol Handler (14 lines in workflow-registry.ts): Verify path resolution logic and security checks
  2. Workflow Content: The three YAML files contain complex business logic - focus on structure and integration points
  3. Backward Compatibility: Ensure visor-ee:// URLs continue to resolve correctly
  4. Test Coverage: All three workflows include comprehensive test suites

Security Considerations

The protocol handler includes path traversal protection:

  • Validates resolved paths stay within defaults directory
  • Prevents visor://../../etc/passwd style attacks
  • Test case verifies rejection of escaping paths
Metadata
  • Review Effort: 3 / 5
  • Primary Label: feature

Powered by Visor from Probelabs

Last updated: 2026-03-03T11:26:29.856Z | Triggered by: pr_updated | Commit: 305b27f

💡 TIP: You can chat with Visor using /visor ask <your question>

@probelabs
Copy link
Contributor

probelabs bot commented Mar 2, 2026

Security Issues (5)

Severity Location Issue
🔴 Critical src/workflow-registry.ts:421-434
Path traversal validation in visor:// protocol handler can be bypassed using path normalization tricks. The check `filePath.startsWith(defaultsDir + path.sep)` fails when defaultsDir itself contains symbolic links or when the resolved path uses different path separators (e.g., mixed / and \ on Windows). An attacker could use `visor://defaults/../package.json` to read files outside the defaults directory.
💡 SuggestionUse robust path validation with path.resolve() and path.normalize() on both the resolved path and defaultsDir before comparison. Consider using path.relative() to detect traversal attempts, similar to the pattern used in file-exclusion.ts. Add validation to reject paths containing '..' before resolution.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');

// Security: Reject obvious traversal patterns before resolution
if (relativePath.includes('..')) {
throw new Error(Invalid visor:// path: path traversal detected);
}

const defaultsDir = path.resolve(__dirname, '..', 'defaults');
const filePath = path.resolve(defaultsDir, relativePath);

// Normalize both paths for comparison
const normalizedFilePath = path.normalize(filePath);
const normalizedDefaultsDir = path.normalize(defaultsDir);

// Use path.relative for robust traversal detection
const relative = path.relative(normalizedDefaultsDir, normalizedFilePath);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

🔴 Critical src/workflow-registry.ts:421-434
Path traversal check `filePath.startsWith(defaultsDir + path.sep)` is vulnerable on Windows systems where path.sep is '\\'. If defaultsDir is 'C:\\path\\to\\defaults' and filePath is 'C:\\path\\to\\defaults-traversal\\file.yaml', the check would incorrectly pass because 'C:\\path\\to\\defaults-traversal' starts with 'C:\\path\\to\\defaults\\'.
💡 SuggestionUse path.relative() for cross-platform path validation, or ensure the comparison includes a trailing separator and normalizes both paths. The isPathWithinDirectory helper function in mcp-server.ts demonstrates the correct pattern.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');
  const defaultsDir = path.resolve(__dirname, '..', 'defaults');
  const filePath = path.resolve(defaultsDir, relativePath);

// Use helper function for robust path validation
if (!isPathWithinDirectory(filePath, defaultsDir)) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

🟠 Error src/workflow-registry.ts:421-434
The visor:// protocol handler does not protect against symlink-based path traversal. An attacker could create a symlink inside the defaults directory that points to sensitive files (e.g., /etc/passwd, ~/.ssh/id_rsa), then access it via visor://symlink-to-sensitive-file. The path validation would pass because the symlink path is within defaults directory.
💡 SuggestionAdd realpath resolution to follow symlinks and validate the final target path. Use fs.realpathSync.native() or fs.promises.realpath() to resolve symlinks before validation. This is already done correctly in config-loader.ts's validateLocalPath method.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');
  const defaultsDir = path.resolve(__dirname, '..', 'defaults');
  const filePath = path.resolve(defaultsDir, relativePath);

// Resolve symlinks to prevent symlink-based traversal
let realPath: string;
try {
realPath = await fs.promises.realpath(filePath);
} catch {
realPath = path.normalize(filePath);
}

let realDefaultsDir: string;
try {
realDefaultsDir = await fs.promises.realpath(defaultsDir);
} catch {
realDefaultsDir = path.normalize(defaultsDir);
}

// Validate the resolved path stays within defaults directory
if (!realPath.startsWith(realDefaultsDir + path.sep) && realPath !== realDefaultsDir) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol handler does not validate for null byte injection attempts. An attacker could use 'visor://../../etc/passwd\x00.yaml' which would pass the startsWith check but the null byte would be stripped by the underlying fs.readFile, potentially accessing unintended files.
💡 SuggestionAdd validation to reject paths containing null bytes or other control characters before processing. This pattern is already used in file-exclusion.ts for gitignore content sanitization.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');

// Security: Reject null bytes and control characters
if (/\x00/.test(relativePath)) {
throw new Error(Invalid visor:// path: null byte detected);
}

const defaultsDir = path.resolve(__dirname, '..', 'defaults');
const filePath = path.resolve(defaultsDir, relativePath);

if (!filePath.startsWith(defaultsDir + path.sep)) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol handler does not decode or validate URI-encoded components in the path. An attacker could use 'visor://.%2e%2e/etc/passwd' or 'visor://..%252fetc/passwd' which would bypass simple '..' checks but still resolve to parent directories when the URL is decoded.
💡 SuggestionUse decodeURIComponent() on the relative path before validation, or explicitly reject URI-encoded characters (%). Since visor:// is a custom protocol and not a standard URL, URI encoding should not be expected or allowed.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  let relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');

// Security: Reject URI-encoded paths (not expected in visor:// protocol)
if (/%[0-9a-f]{2}/i.test(relativePath)) {
throw new Error(Invalid visor:// path: URI encoding not allowed);
}

const defaultsDir = path.resolve(__dirname, '..', 'defaults');
const filePath = path.resolve(defaultsDir, relativePath);

if (!filePath.startsWith(defaultsDir + path.sep)) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

Architecture Issues (15)

Severity Location Issue
🟠 Error defaults/assistant.yaml:1-2141
The assistant.yaml workflow is 2,141 lines with excessive complexity. It combines intent routing, skill management, knowledge injection, MCP server configuration, and bash command handling in a single monolithic workflow. This violates the Single Responsibility Principle and makes the workflow difficult to understand, test, and maintain.
💡 SuggestionBreak down the workflow into smaller, focused workflows: 1) intent-router.yaml (already exists), 2) skill-activator.yaml (handles skill dependencies and activation), 3) knowledge-injector.yaml (handles knowledge injection), 4) tool-configurator.yaml (handles MCP server configuration). The main assistant.yaml should orchestrate these smaller workflows rather than containing all logic inline.
🟠 Error defaults/assistant.yaml:274-545
The build-config step contains 271 lines of JavaScript code embedded in YAML. This creates a maintenance nightmare as the logic is hard to test, debug, and version control. The code handles skill dependency expansion, knowledge injection, MCP server configuration, and bash command pattern collection all in one monolithic script.
💡 SuggestionExtract this logic into a separate TypeScript module that can be unit tested. Create a SkillActivator class with methods: expandDependencies(), activateSkills(), buildMcpConfig(), buildKnowledgeContent(). The workflow should call this module via a custom step type or a script step that imports the module.
🟢 Info defaults/assistant.yaml:274-545
The skill dependency expansion logic handles circular dependencies, self-referencing skills, diamond patterns, and deep chains. While robust, this complexity may be YAGNI (You Aren't Gonna Need It) for most use cases. Simple linear dependencies would cover 95% of scenarios.
💡 SuggestionConsider starting with a simpler dependency model that only supports linear dependencies (no cycles). If circular dependencies are detected, fail fast with a clear error message. This would simplify the code significantly. Add cycle detection only if real-world use cases demonstrate the need.
🟢 Info defaults/assistant.yaml:274-545
The build-config script has special handling for merging allowedMethods and blockedMethods when multiple skills configure the same tool. This merge logic is implicit and could be surprising to users.
💡 SuggestionMake tool merging behavior explicit through configuration. Add a 'mergeStrategy' field: 'merge', 'override', 'error'. Default to 'error' to catch conflicts early. This would make the behavior predictable and allow users to choose how conflicts are resolved.
🟢 Info defaults/intent-router.yaml:1-478
The intent-router.yaml workflow is well-designed with a single responsibility (intent classification) and a focused scope. At 478 lines, it's appropriately sized and follows good architectural principles.
💡 SuggestionNo issues. This is a good example of a focused, single-responsibility workflow that other workflows should emulate.
🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol handler introduces a custom URL scheme that creates unnecessary complexity. A simpler approach would be to use relative file paths from a known location or environment variables to specify the defaults directory path.
💡 SuggestionConsider using a configuration-based approach instead of a custom URL protocol. For example: 1) Use an environment variable VISOR_DEFAULTS_PATH, 2) Support relative paths like './defaults/assistant.yaml', or 3) Use a standard file:// URL scheme. This would eliminate the need for custom protocol parsing and reduce cognitive load for users.
🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol is a special case that doesn't follow standard URL patterns. This creates a non-standard interface that users must learn, deviating from familiar file:// and https:// patterns.
💡 SuggestionUse standard file:// URLs or relative paths instead. For example: 'file://./defaults/assistant.yaml' or simply './defaults/assistant.yaml' with a configurable base path.
🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol handler is inconsistent with the existing URL/file import patterns in the codebase. Other imports use http://, https://, or file paths, creating an inconsistent interface.
💡 SuggestionAlign with existing patterns by using standard file paths or file:// URLs. The current implementation adds a third pattern (visor://) alongside http:// and file paths, increasing complexity without clear benefits.
🟡 Warning defaults/assistant.yaml:274-545
The build-config script has special case handling for 'bash' and 'execute_plan' tool names, treating them differently from other tools. This creates implicit knowledge that certain tool names have special behavior.
💡 SuggestionMake special tool handling explicit through configuration. Add a 'type' field to tool definitions: 'builtin', 'mcp', 'workflow'. For example: tools: { bash: { type: 'builtin' }, jira: { type: 'mcp', command: 'uvx', args: ['mcp-atlassian'] } }. This makes the special cases explicit and configurable.
🟡 Warning defaults/assistant.yaml:274-545
The build-config script duplicates logic for processing skills, standalone mcp_servers, and standalone knowledge. Each section has similar patterns for checking tags/intent and activating configuration, creating code duplication.
💡 SuggestionExtract the common activation logic into a helper function. Create a generic activateByTagsOrIntent() function that takes an item, its tags/intent, and the current tags/intent context, returning whether it should be active. This would reduce duplication and make the code more maintainable.
🟡 Warning defaults/assistant.yaml:274-545
The workflow supports two activation modes: 'skills mode' (unified) and 'legacy mode' (tags/intent). This dual-mode approach creates inconsistency and confusion. The code checks 'usingSkillsMode' and branches differently, making the logic harder to follow.
💡 SuggestionChoose one activation pattern and deprecate the other. Since skills mode is described as 'recommended', remove legacy tag/intent activation support. This would simplify the code significantly by eliminating the conditional logic and the shouldActivateByTagsOrIntent() helper function.
🟡 Warning defaults/code-talk.yaml:1-1250
The code-talk.yaml workflow is 1,250 lines and handles multiple concerns: project routing, git checkout, code exploration, and confidence scoring. This is a large, monolithic workflow that could be broken down into smaller, reusable components.
💡 SuggestionConsider splitting into: 1) project-router.yaml (AI-based project selection), 2) repo-checkout.yaml (git checkout logic), 3) code-explorer.yaml (exploration logic). The main code-talk workflow would orchestrate these smaller workflows. This would improve reusability and testability.
🟡 Warning defaults/assistant.yaml:547-670
The generate-response step uses a wildcard-with-exclusions pattern for tool permissions (['*', ...excluded.map(t => '!' + t)]). This is inconsistent with the more explicit allowlist approach used elsewhere in the codebase for security.
💡 SuggestionUse an explicit allowlist approach instead of wildcard-with-exclusions. Define a base set of allowed tools and add skill-specific tools to it. This is more secure and makes the security model clearer. For example: const allowed = ['attempt_completion', 'search', 'query']; return [...allowed, ...skillTools];
🟡 Warning defaults/assistant.yaml:274-545
The build-config script manually constructs XML blocks for skill knowledge injection using string concatenation. This is a missing abstraction - there should be a template-based approach for formatting knowledge content.
💡 SuggestionCreate a knowledge template system that supports structured formats. For example, use Liquid templates or a dedicated formatter function: formatSkillKnowledge(skill) that returns formatted content. This would separate formatting logic from activation logic and make it easier to change the output format.
🟡 Warning src/workflow-registry.ts:421-434
The path traversal protection in the visor:// protocol handler is a good security measure, but it only checks if the resolved path starts with defaultsDir. This could be bypassed on Windows with drive letters or if defaultsDir contains symlinks.
💡 SuggestionUse path.normalize() on both paths before comparison, and use path.relative() to check if the resolved path escapes the defaults directory. For example: const relative = path.relative(defaultsDir, filePath); if (relative.startsWith('..')) throw new Error(...). This is more robust across platforms and handles edge cases.

Security Issues (5)

Severity Location Issue
🔴 Critical src/workflow-registry.ts:421-434
Path traversal validation in visor:// protocol handler can be bypassed using path normalization tricks. The check `filePath.startsWith(defaultsDir + path.sep)` fails when defaultsDir itself contains symbolic links or when the resolved path uses different path separators (e.g., mixed / and \ on Windows). An attacker could use `visor://defaults/../package.json` to read files outside the defaults directory.
💡 SuggestionUse robust path validation with path.resolve() and path.normalize() on both the resolved path and defaultsDir before comparison. Consider using path.relative() to detect traversal attempts, similar to the pattern used in file-exclusion.ts. Add validation to reject paths containing '..' before resolution.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');

// Security: Reject obvious traversal patterns before resolution
if (relativePath.includes('..')) {
throw new Error(Invalid visor:// path: path traversal detected);
}

const defaultsDir = path.resolve(__dirname, '..', 'defaults');
const filePath = path.resolve(defaultsDir, relativePath);

// Normalize both paths for comparison
const normalizedFilePath = path.normalize(filePath);
const normalizedDefaultsDir = path.normalize(defaultsDir);

// Use path.relative for robust traversal detection
const relative = path.relative(normalizedDefaultsDir, normalizedFilePath);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

🔴 Critical src/workflow-registry.ts:421-434
Path traversal check `filePath.startsWith(defaultsDir + path.sep)` is vulnerable on Windows systems where path.sep is '\\'. If defaultsDir is 'C:\\path\\to\\defaults' and filePath is 'C:\\path\\to\\defaults-traversal\\file.yaml', the check would incorrectly pass because 'C:\\path\\to\\defaults-traversal' starts with 'C:\\path\\to\\defaults\\'.
💡 SuggestionUse path.relative() for cross-platform path validation, or ensure the comparison includes a trailing separator and normalizes both paths. The isPathWithinDirectory helper function in mcp-server.ts demonstrates the correct pattern.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');
  const defaultsDir = path.resolve(__dirname, '..', 'defaults');
  const filePath = path.resolve(defaultsDir, relativePath);

// Use helper function for robust path validation
if (!isPathWithinDirectory(filePath, defaultsDir)) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

🟠 Error src/workflow-registry.ts:421-434
The visor:// protocol handler does not protect against symlink-based path traversal. An attacker could create a symlink inside the defaults directory that points to sensitive files (e.g., /etc/passwd, ~/.ssh/id_rsa), then access it via visor://symlink-to-sensitive-file. The path validation would pass because the symlink path is within defaults directory.
💡 SuggestionAdd realpath resolution to follow symlinks and validate the final target path. Use fs.realpathSync.native() or fs.promises.realpath() to resolve symlinks before validation. This is already done correctly in config-loader.ts's validateLocalPath method.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');
  const defaultsDir = path.resolve(__dirname, '..', 'defaults');
  const filePath = path.resolve(defaultsDir, relativePath);

// Resolve symlinks to prevent symlink-based traversal
let realPath: string;
try {
realPath = await fs.promises.realpath(filePath);
} catch {
realPath = path.normalize(filePath);
}

let realDefaultsDir: string;
try {
realDefaultsDir = await fs.promises.realpath(defaultsDir);
} catch {
realDefaultsDir = path.normalize(defaultsDir);
}

// Validate the resolved path stays within defaults directory
if (!realPath.startsWith(realDefaultsDir + path.sep) && realPath !== realDefaultsDir) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol handler does not validate for null byte injection attempts. An attacker could use 'visor://../../etc/passwd\x00.yaml' which would pass the startsWith check but the null byte would be stripped by the underlying fs.readFile, potentially accessing unintended files.
💡 SuggestionAdd validation to reject paths containing null bytes or other control characters before processing. This pattern is already used in file-exclusion.ts for gitignore content sanitization.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  const relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');

// Security: Reject null bytes and control characters
if (/\x00/.test(relativePath)) {
throw new Error(Invalid visor:// path: null byte detected);
}

const defaultsDir = path.resolve(__dirname, '..', 'defaults');
const filePath = path.resolve(defaultsDir, relativePath);

if (!filePath.startsWith(defaultsDir + path.sep)) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol handler does not decode or validate URI-encoded components in the path. An attacker could use 'visor://.%2e%2e/etc/passwd' or 'visor://..%252fetc/passwd' which would bypass simple '..' checks but still resolve to parent directories when the URL is decoded.
💡 SuggestionUse decodeURIComponent() on the relative path before validation, or explicitly reject URI-encoded characters (%). Since visor:// is a custom protocol and not a standard URL, URI encoding should not be expected or allowed.
🔧 Suggested Fix
if (source.startsWith('visor://') || source.startsWith('visor-ee://')) {
  let relativePath = source.replace(/^visor(?:-ee)?:\/\//, '');

// Security: Reject URI-encoded paths (not expected in visor:// protocol)
if (/%[0-9a-f]{2}/i.test(relativePath)) {
throw new Error(Invalid visor:// path: URI encoding not allowed);
}

const defaultsDir = path.resolve(__dirname, '..', 'defaults');
const filePath = path.resolve(defaultsDir, relativePath);

if (!filePath.startsWith(defaultsDir + path.sep)) {
throw new Error(Invalid visor:// path: resolved path escapes defaults directory);
}

const content = await fs.readFile(filePath, 'utf-8');
return { content, resolvedSource: filePath, importBasePath: path.dirname(filePath) };
}

\n\n ### Architecture Issues (15)
Severity Location Issue
🟠 Error defaults/assistant.yaml:1-2141
The assistant.yaml workflow is 2,141 lines with excessive complexity. It combines intent routing, skill management, knowledge injection, MCP server configuration, and bash command handling in a single monolithic workflow. This violates the Single Responsibility Principle and makes the workflow difficult to understand, test, and maintain.
💡 SuggestionBreak down the workflow into smaller, focused workflows: 1) intent-router.yaml (already exists), 2) skill-activator.yaml (handles skill dependencies and activation), 3) knowledge-injector.yaml (handles knowledge injection), 4) tool-configurator.yaml (handles MCP server configuration). The main assistant.yaml should orchestrate these smaller workflows rather than containing all logic inline.
🟠 Error defaults/assistant.yaml:274-545
The build-config step contains 271 lines of JavaScript code embedded in YAML. This creates a maintenance nightmare as the logic is hard to test, debug, and version control. The code handles skill dependency expansion, knowledge injection, MCP server configuration, and bash command pattern collection all in one monolithic script.
💡 SuggestionExtract this logic into a separate TypeScript module that can be unit tested. Create a SkillActivator class with methods: expandDependencies(), activateSkills(), buildMcpConfig(), buildKnowledgeContent(). The workflow should call this module via a custom step type or a script step that imports the module.
🟢 Info defaults/assistant.yaml:274-545
The skill dependency expansion logic handles circular dependencies, self-referencing skills, diamond patterns, and deep chains. While robust, this complexity may be YAGNI (You Aren't Gonna Need It) for most use cases. Simple linear dependencies would cover 95% of scenarios.
💡 SuggestionConsider starting with a simpler dependency model that only supports linear dependencies (no cycles). If circular dependencies are detected, fail fast with a clear error message. This would simplify the code significantly. Add cycle detection only if real-world use cases demonstrate the need.
🟢 Info defaults/assistant.yaml:274-545
The build-config script has special handling for merging allowedMethods and blockedMethods when multiple skills configure the same tool. This merge logic is implicit and could be surprising to users.
💡 SuggestionMake tool merging behavior explicit through configuration. Add a 'mergeStrategy' field: 'merge', 'override', 'error'. Default to 'error' to catch conflicts early. This would make the behavior predictable and allow users to choose how conflicts are resolved.
🟢 Info defaults/intent-router.yaml:1-478
The intent-router.yaml workflow is well-designed with a single responsibility (intent classification) and a focused scope. At 478 lines, it's appropriately sized and follows good architectural principles.
💡 SuggestionNo issues. This is a good example of a focused, single-responsibility workflow that other workflows should emulate.
🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol handler introduces a custom URL scheme that creates unnecessary complexity. A simpler approach would be to use relative file paths from a known location or environment variables to specify the defaults directory path.
💡 SuggestionConsider using a configuration-based approach instead of a custom URL protocol. For example: 1) Use an environment variable VISOR_DEFAULTS_PATH, 2) Support relative paths like './defaults/assistant.yaml', or 3) Use a standard file:// URL scheme. This would eliminate the need for custom protocol parsing and reduce cognitive load for users.
🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol is a special case that doesn't follow standard URL patterns. This creates a non-standard interface that users must learn, deviating from familiar file:// and https:// patterns.
💡 SuggestionUse standard file:// URLs or relative paths instead. For example: 'file://./defaults/assistant.yaml' or simply './defaults/assistant.yaml' with a configurable base path.
🟡 Warning src/workflow-registry.ts:421-434
The visor:// protocol handler is inconsistent with the existing URL/file import patterns in the codebase. Other imports use http://, https://, or file paths, creating an inconsistent interface.
💡 SuggestionAlign with existing patterns by using standard file paths or file:// URLs. The current implementation adds a third pattern (visor://) alongside http:// and file paths, increasing complexity without clear benefits.
🟡 Warning defaults/assistant.yaml:274-545
The build-config script has special case handling for 'bash' and 'execute_plan' tool names, treating them differently from other tools. This creates implicit knowledge that certain tool names have special behavior.
💡 SuggestionMake special tool handling explicit through configuration. Add a 'type' field to tool definitions: 'builtin', 'mcp', 'workflow'. For example: tools: { bash: { type: 'builtin' }, jira: { type: 'mcp', command: 'uvx', args: ['mcp-atlassian'] } }. This makes the special cases explicit and configurable.
🟡 Warning defaults/assistant.yaml:274-545
The build-config script duplicates logic for processing skills, standalone mcp_servers, and standalone knowledge. Each section has similar patterns for checking tags/intent and activating configuration, creating code duplication.
💡 SuggestionExtract the common activation logic into a helper function. Create a generic activateByTagsOrIntent() function that takes an item, its tags/intent, and the current tags/intent context, returning whether it should be active. This would reduce duplication and make the code more maintainable.
🟡 Warning defaults/assistant.yaml:274-545
The workflow supports two activation modes: 'skills mode' (unified) and 'legacy mode' (tags/intent). This dual-mode approach creates inconsistency and confusion. The code checks 'usingSkillsMode' and branches differently, making the logic harder to follow.
💡 SuggestionChoose one activation pattern and deprecate the other. Since skills mode is described as 'recommended', remove legacy tag/intent activation support. This would simplify the code significantly by eliminating the conditional logic and the shouldActivateByTagsOrIntent() helper function.
🟡 Warning defaults/code-talk.yaml:1-1250
The code-talk.yaml workflow is 1,250 lines and handles multiple concerns: project routing, git checkout, code exploration, and confidence scoring. This is a large, monolithic workflow that could be broken down into smaller, reusable components.
💡 SuggestionConsider splitting into: 1) project-router.yaml (AI-based project selection), 2) repo-checkout.yaml (git checkout logic), 3) code-explorer.yaml (exploration logic). The main code-talk workflow would orchestrate these smaller workflows. This would improve reusability and testability.
🟡 Warning defaults/assistant.yaml:547-670
The generate-response step uses a wildcard-with-exclusions pattern for tool permissions (['*', ...excluded.map(t => '!' + t)]). This is inconsistent with the more explicit allowlist approach used elsewhere in the codebase for security.
💡 SuggestionUse an explicit allowlist approach instead of wildcard-with-exclusions. Define a base set of allowed tools and add skill-specific tools to it. This is more secure and makes the security model clearer. For example: const allowed = ['attempt_completion', 'search', 'query']; return [...allowed, ...skillTools];
🟡 Warning defaults/assistant.yaml:274-545
The build-config script manually constructs XML blocks for skill knowledge injection using string concatenation. This is a missing abstraction - there should be a template-based approach for formatting knowledge content.
💡 SuggestionCreate a knowledge template system that supports structured formats. For example, use Liquid templates or a dedicated formatter function: formatSkillKnowledge(skill) that returns formatted content. This would separate formatting logic from activation logic and make it easier to change the output format.
🟡 Warning src/workflow-registry.ts:421-434
The path traversal protection in the visor:// protocol handler is a good security measure, but it only checks if the resolved path starts with defaultsDir. This could be bypassed on Windows with drive letters or if defaultsDir contains symlinks.
💡 SuggestionUse path.normalize() on both paths before comparison, and use path.relative() to check if the resolved path escapes the defaults directory. For example: const relative = path.relative(defaultsDir, filePath); if (relative.startsWith('..')) throw new Error(...). This is more robust across platforms and handles edge cases.
\n\n ### Performance Issues (9)
Severity Location Issue
🟡 Warning defaults/assistant.yaml:452-477
Dependency expansion uses O(n²) algorithm with nested while loop and array.indexOf() operations. The while loop iterates through all skills, and for each skill, it searches through activatedSkillsArr using indexOf() which is O(n). With many skills and deep dependency chains, this becomes O(n²) or worse.
💡 SuggestionReplace array with Set for O(1) lookups. Use a Set for activatedSkills and iterate until no new dependencies are added, or use a proper topological sort algorithm.
🔧 Suggested Fix
const activatedSkillsSet = new Set(Array.isArray(rawSelectedSkills) ? rawSelectedSkills : []);
for (let i = 0; i < defaultSkillsForIntent.length; i++) {
  activatedSkillsSet.add(defaultSkillsForIntent[i]);
}
for (let i = 0; i < skillConfigs.length; i++) {
  const skill = skillConfigs[i];
  if (skill.always === true && skill.id) {
    activatedSkillsSet.add(skill.id);
  }
}
let changed = true;
while (changed) {
  changed = false;
  const currentSkills = Array.from(activatedSkillsSet);
  for (let i = 0; i < currentSkills.length; i++) {
    const skillId = currentSkills[i];
    const deps = requiresMap[skillId] || [];
    for (let j = 0; j < deps.length; j++) {
      const dep = deps[j];
      if (!activatedSkillsSet.has(dep)) {
        activatedSkillsSet.add(dep);
        changed = true;
      }
    }
  }
}
const activatedSkills = Array.from(activatedSkillsSet);
🟡 Warning defaults/assistant.yaml:452-457
Using array.indexOf() in a loop creates O(n²) complexity. Each indexOf() call is O(n), and this is called for each skill in defaultSkillsForIntent.
💡 SuggestionUse a Set data structure for O(1) membership testing instead of array.indexOf().
🟡 Warning defaults/assistant.yaml:459-464
Using array.indexOf() in a loop for always-on skills creates O(n²) complexity. Each check is O(n) and this runs for all skillConfigs.
💡 SuggestionUse a Set data structure for O(1) membership testing.
🟡 Warning defaults/assistant.yaml:467-477
Dependency expansion loop uses array.indexOf() which is O(n) for each dependency check. With nested loops (while + for + for), this creates O(n³) worst-case complexity for deep dependency chains.
💡 SuggestionReplace activatedSkillsArr with a Set for O(1) has() checks instead of O(n) indexOf().
🟡 Warning defaults/assistant.yaml:581-586
Merging allowedMethods uses array.indexOf() in nested loops, creating O(n*m) complexity where n is toolConfig.allowedMethods.length and m is existing.allowedMethods.length.
💡 SuggestionUse a Set for existing.allowedMethods to convert O(n*m) to O(n+m).
🔧 Suggested Fix
if (Array.isArray(toolConfig.allowedMethods)) {
  if (!Array.isArray(existing.allowedMethods)) {
    existing.allowedMethods = [];
  }
  const existingSet = new Set(existing.allowedMethods);
  for (let j = 0; j < toolConfig.allowedMethods.length; j++) {
    const method = toolConfig.allowedMethods[j];
    if (!existingSet.has(method)) {
      existing.allowedMethods.push(method);
      existingSet.add(method);
    }
  }
}
🟡 Warning defaults/assistant.yaml:593-598
Merging blockedMethods uses array.indexOf() in nested loops, creating O(n*m) complexity.
💡 SuggestionUse a Set for existing.blockedMethods to convert O(n*m) to O(n+m).
🔧 Suggested Fix
if (Array.isArray(toolConfig.blockedMethods)) {
  if (!Array.isArray(existing.blockedMethods)) {
    existing.blockedMethods = [];
  }
  const existingSet = new Set(existing.blockedMethods);
  for (let j = 0; j < toolConfig.blockedMethods.length; j++) {
    const method = toolConfig.blockedMethods[j];
    if (!existingSet.has(method)) {
      existing.blockedMethods.push(method);
      existingSet.add(method);
    }
  }
}
🟡 Warning defaults/assistant.yaml:609-613
Collecting bash allowed_commands uses array.indexOf() in nested loops, creating O(n*m) complexity where n is skill.allowed_commands.length and m is bashAllow.length.
💡 SuggestionUse a Set for bashAllow to achieve O(1) lookups.
🔧 Suggested Fix
if (Array.isArray(skill.allowed_commands)) {
  const bashAllowSet = new Set(bashAllow);
  for (let j = 0; j < skill.allowed_commands.length; j++) {
    const cmd = skill.allowed_commands[j];
    if (!bashAllowSet.has(cmd)) {
      bashAllow.push(cmd);
      bashAllowSet.add(cmd);
    }
  }
}
🟡 Warning defaults/assistant.yaml:615-619
Collecting bash disallowed_commands uses array.indexOf() in nested loops, creating O(n*m) complexity.
💡 SuggestionUse a Set for bashDeny to achieve O(1) lookups.
🔧 Suggested Fix
if (Array.isArray(skill.disallowed_commands)) {
  const bashDenySet = new Set(bashDeny);
  for (let j = 0; j < skill.disallowed_commands.length; j++) {
    const cmd = skill.disallowed_commands[j];
    if (!bashDenySet.has(cmd)) {
      bashDeny.push(cmd);
      bashDenySet.add(cmd);
    }
  }
}
🟡 Warning src/workflow-registry.ts:428
String concatenation for path comparison (defaultsDir + path.sep) may fail on Windows or with edge cases. path.resolve() should be used for consistent path normalization.
💡 SuggestionUse path.resolve() for both paths before comparison to ensure consistent normalization across platforms.
🔧 Suggested Fix
const normalizedDefaultsDir = path.resolve(defaultsDir);
const normalizedFilePath = path.resolve(filePath);
if (!normalizedFilePath.startsWith(normalizedDefaultsDir + path.sep) && normalizedFilePath !== normalizedDefaultsDir) {
  throw new Error(`Invalid visor:// path: resolved path escapes defaults directory`);
}

Quality Issues (10)

Severity Location Issue
🔴 Critical src/workflow-registry.ts:421
The visor:// protocol handler doesn't validate that the resolved path stays within the package root. A malicious visor:// URL like 'visor://../../etc/passwd' could escape the package directory.
💡 SuggestionAdd path traversal validation: const normalizedPath = path.normalize(filePath); if (!normalizedPath.startsWith(packageRoot + path.sep)) { throw new Error('Invalid visor:// path: escapes package root'); }
🟠 Error src/workflow-registry.ts:421
No validation that the visor:// URL path actually exists before reading file. The code resolves the path and reads the file without checking if it exists, which will throw an unhandled error if the file is missing.
💡 SuggestionAdd file existence check before reading: if (!fs.existsSync(filePath)) { throw new Error(`Built-in workflow not found: ${relativePath}`); }
🟠 Error src/workflow-registry.ts:421
The fs.readFile() call is not wrapped in try-catch. If the file doesn't exist or can't be read, the error will propagate up and fail the entire import operation without a clear error message.
💡 SuggestionWrap fs.readFile in try-catch to provide better error messages: try { const content = await fs.readFile(filePath, 'utf-8'); } catch (error) { throw new Error(`Failed to read built-in workflow '${relativePath}': ${error.message}`); }
🟠 Error src/workflow-registry.ts:421
No tests for the new visor:// protocol handler. The code adds a new URL scheme feature but there are no tests verifying it works correctly, handles errors, or rejects invalid paths.
💡 SuggestionAdd tests in tests/unit/workflow-registry.test.ts covering: valid visor:// URLs, visor-ee:// backward compat, missing files, path traversal attempts, and relative path resolution
🟡 Warning workflows/assistant.yaml:1
The test 'unified-skills-classification' uses mock data with arbitrary skill IDs ('jira', 'zendesk') and tool names without clear semantic meaning. The test mocks return hardcoded values that may not reflect actual classifier behavior.
💡 SuggestionUse more realistic test data with clear business domain meaning, or add comments explaining why these specific values are used. Consider using constants for frequently used test values.
🟡 Warning workflows/assistant.yaml:1
Tests only cover happy path scenarios. No tests for error conditions like: invalid skill configurations, missing required fields, circular dependency detection failures, or malformed MCP server configs.
💡 SuggestionAdd negative test cases covering: invalid skill IDs in requires array, missing required inputs, malformed tool configurations, and dependency resolution errors
🟡 Warning workflows/code-talk.yaml:1
Test 'basic-code-question' uses arbitrary mock data ('org/gateway', 'org/backend') without clear domain context. The mock returns hardcoded values that may not match actual routing behavior.
💡 SuggestionUse realistic test data with clear business meaning or add comments explaining the test scenario. Consider using fixture data files for complex test scenarios.
🟡 Warning workflows/code-talk.yaml:1
No tests for error scenarios like: checkout failures, invalid project IDs, malformed AI responses, or network errors during git operations.
💡 SuggestionAdd tests covering: git checkout failures, invalid repository URLs, malformed routing responses, and timeout scenarios
🟡 Warning workflows/intent-router.yaml:1
Tests use arbitrary intent IDs ('chat', 'code_help') and tag names without clear semantic meaning. Mock responses contain hardcoded values that may not reflect actual classifier output.
💡 SuggestionUse more realistic test data or add comments explaining the business context. Consider using constants for frequently used test values like 'code_help'.
🟡 Warning workflows/intent-router.yaml:1
Tests only cover successful classification. No tests for: malformed user input, invalid %intent markers, classifier errors, or edge cases like empty messages.
💡 SuggestionAdd negative test cases covering: invalid explicit markers, malformed input, classifier failures, and edge cases like very long messages or special characters

Powered by Visor from Probelabs

Last updated: 2026-03-03T11:23:37.868Z | Triggered by: pr_updated | Commit: 305b27f

💡 TIP: You can chat with Visor using /visor ask <your question>

buger and others added 2 commits March 3, 2026 06:47
Reject visor:// URLs that resolve outside the package root (e.g.
visor://../../etc/passwd). Add unit tests for visor:// resolution,
visor-ee:// backward compat, and path traversal rejection.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Place assistant.yaml, code-talk.yaml, and intent-router.yaml in
defaults/ alongside existing bundled configs so they ship in dist/
without build changes. Update visor:// protocol to resolve from
defaults/ dir. visor://assistant.yaml now maps to defaults/assistant.yaml.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@buger buger merged commit 3e2a2c4 into main Mar 3, 2026
14 of 18 checks passed
@buger buger changed the title feat: bundle assistant workflows and add visor:// protocol feat: bundle assistant workflows with visor:// protocol, docs, and examples Mar 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant