Skip to content

Commit 6927464

Browse files
authored
feat: add comprehensive secrets management system (#17)
BREAKING Implement server-side secrets management with automatic redaction, audit logging, and fail-safe security. Secrets are resolved during execution and never reach LLM context. Core features: - Five-namespace variable system (inputs, metadata, blocks, __internal__, secrets) - EnvVarSecretProvider with WORKFLOW_SECRET_* prefix - Automatic output redaction for credential protection - Comprehensive audit trail for compliance - Fail-fast behavior for missing secrets Changes: - Add secrets/ module with provider, redactor, audit, exceptions - Integrate secrets into VariableResolver with async resolution - Update all executors to support secret substitution - Add 8 comprehensive test workflows for secrets validation - Update 30+ snapshot tests for five-namespace variable system Security: - Server-side resolution prevents credential leakage to LLM - Pattern-based redactor sanitizes all outputs - Structured audit logging for security compliance - Fail-fast on missing secrets prevents silent failures
2 parents 23fb031 + 91f4059 commit 6927464

61 files changed

Lines changed: 6123 additions & 2679 deletions

File tree

Some content is hidden

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

.github/workflows/release.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,26 @@ jobs:
1313
issues: write # to be able to comment on released issues
1414
pull-requests: write # to be able to comment on released pull requests
1515
steps:
16+
- name: Checkout code
17+
uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0
20+
21+
- name: Setup Python
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: '3.12'
25+
26+
- name: Install packaging library
27+
run: pip install packaging
28+
1629
- name: Semantic Release
1730
uses: qtsone/actions/release@main
1831
with:
1932
github-token: ${{ secrets.GITHUB_TOKEN }}
2033
ssh-key: ${{ secrets.DEPLOY_KEY }}
34+
skip-checkout: 'true'
35+
extra-plugins: |
36+
@semantic-release/changelog
37+
@semantic-release/git
38+
@semantic-release/exec

.releaserc

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,18 @@
1717
"changelogTitle": "# Changelog\n\nAll notable changes to this project will be documented in this file."
1818
}
1919
],
20+
[
21+
"@semantic-release/exec",
22+
{
23+
"prepareCmd": "python scripts/update-version.py ${nextRelease.version}"
24+
}
25+
],
2026
[
2127
"@semantic-release/git",
2228
{
2329
"assets": [
24-
"CHANGELOG.md"
30+
"CHANGELOG.md",
31+
"src/workflows_mcp/__init__.py"
2532
],
2633
"message": "chore(release): version ${nextRelease.version}\n\n${nextRelease.notes}"
2734
}

README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,54 @@ All `env` variables are optional. The `--refresh` flag is recommended to ensure
6666

6767
Restart your LLM client, and you're ready to go!
6868

69+
## ✨ Features
70+
71+
- **DAG-Based Workflows**: Automatic dependency resolution and parallel execution
72+
- **🔐 Secrets Management**: Server-side credential handling with automatic redaction (v5.0.0+)
73+
- **Workflow Composition**: Reusable workflows via Workflow blocks
74+
- **Conditional Execution**: Boolean expressions for dynamic control flow
75+
- **Variable Resolution**: Five-namespace system (inputs, metadata, blocks, secrets, __internal__)
76+
- **File Operations**: CreateFile, ReadFile, RenderTemplate with Jinja2
77+
- **HTTP Integration**: HttpCall for REST API interactions
78+
- **Interactive Workflows**: Prompt blocks for user input
79+
- **MCP Integration**: Exposed as tools for LLM agents via Model Context Protocol
80+
81+
### 🔐 Secrets Management
82+
83+
Securely manage API keys, passwords, and tokens in workflows:
84+
85+
```yaml
86+
blocks:
87+
- id: call_github_api
88+
type: HttpCall
89+
inputs:
90+
url: "https://api.github.com/user"
91+
headers:
92+
Authorization: "Bearer {{secrets.GITHUB_TOKEN}}"
93+
```
94+
95+
**Key Features:**
96+
97+
- ✅ **Server-side resolution** - Secrets never reach LLM context
98+
- ✅ **Automatic redaction** - Secret values sanitized from all outputs
99+
- ✅ **Fail-fast behavior** - Missing secrets cause immediate workflow failure
100+
- ✅ **Audit logging** - Comprehensive tracking for compliance
101+
102+
**Configuration:**
103+
104+
```json
105+
{
106+
"mcpServers": {
107+
"workflows": {
108+
"env": {
109+
"WORKFLOW_SECRET_GITHUB_TOKEN": "ghp_xxx",
110+
"WORKFLOW_SECRET_OPENAI_API_KEY": "sk-xxx"
111+
}
112+
}
113+
}
114+
}
115+
```
116+
69117
## What Can You Do With It?
70118

71119
### Built-in Workflows
@@ -212,6 +260,7 @@ Your custom workflows override built-in ones if they have the same name. Later d
212260
- **WORKFLOWS_TEMPLATE_PATHS** - Comma-separated list of additional template directories
213261
- **WORKFLOWS_MAX_RECURSION_DEPTH** - Maximum workflow recursion depth (default: 50, range: 1-10000)
214262
- **WORKFLOWS_LOG_LEVEL** - Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
263+
- **WORKFLOW_SECRET_<NAME>** - Define secrets for workflow execution (e.g., `WORKFLOW_SECRET_GITHUB_TOKEN`)
215264

216265
## Example Usage with Claude
217266

@@ -287,7 +336,7 @@ The server uses a **fractal execution model** where workflows and blocks share t
287336
- **WorkflowRunner** - Orchestrates workflow execution
288337
- **BlockOrchestrator** - Executes individual blocks with error handling
289338
- **DAGResolver** - Resolves dependencies and computes parallel execution waves
290-
- **Variable Resolution** - Four-namespace variable system (inputs, blocks, metadata, internal)
339+
- **Variable Resolution** - Five-namespace variable system (inputs, blocks, metadata, secrets, __internal__)
291340
- **Checkpoint System** - Pause/resume support for interactive workflows
292341

293342
Workflows execute in **waves**—blocks with no dependencies or whose dependencies are satisfied run in parallel within each wave, maximizing efficiency.

scripts/update-version.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env python3
2+
"""Update version in src/workflows_mcp/__init__.py
3+
4+
Usage: python scripts/update-version.py <version>
5+
6+
This script uses the official Python packaging library for robust version validation,
7+
supporting all PEP 440 version formats including:
8+
- Standard releases: 1.2.3
9+
- Pre-releases: 1.2.3a1, 1.2.3b2, 1.2.3rc1
10+
- Post-releases: 1.2.3.post1
11+
- Dev releases: 1.2.3.dev1
12+
- Epochs: 1!1.0
13+
- Local versions: 1.2.3+local.version
14+
"""
15+
16+
import sys
17+
from pathlib import Path
18+
19+
try:
20+
from packaging.version import InvalidVersion, Version
21+
except ImportError:
22+
print("Error: 'packaging' library not found", file=sys.stderr)
23+
print("Install it with: pip install packaging", file=sys.stderr)
24+
sys.exit(1)
25+
26+
27+
def validate_version(version_string: str) -> Version:
28+
"""Validate version string using PEP 440 specification.
29+
30+
Args:
31+
version_string: Version string to validate
32+
33+
Returns:
34+
Validated Version object
35+
36+
Raises:
37+
InvalidVersion: If version string is invalid
38+
"""
39+
try:
40+
return Version(version_string)
41+
except InvalidVersion as e:
42+
print(f"Error: Invalid version format: {version_string}", file=sys.stderr)
43+
print(f"Reason: {e}", file=sys.stderr)
44+
print(
45+
"\nValid formats include:",
46+
" - Standard: 1.2.3",
47+
" - Pre-release: 1.2.3a1, 1.2.3b2, 1.2.3rc1",
48+
" - Post-release: 1.2.3.post1",
49+
" - Dev release: 1.2.3.dev1",
50+
" - With epoch: 1!1.0",
51+
" - With local: 1.2.3+local.version",
52+
sep="\n",
53+
file=sys.stderr,
54+
)
55+
sys.exit(1)
56+
57+
58+
def update_version_in_file(file_path: Path, new_version: str) -> tuple[str, bool]:
59+
"""Update __version__ variable in a Python file.
60+
61+
Args:
62+
file_path: Path to the Python file
63+
new_version: New version string
64+
65+
Returns:
66+
Tuple of (old_version, was_updated)
67+
"""
68+
if not file_path.exists():
69+
print(f"Error: {file_path} not found", file=sys.stderr)
70+
sys.exit(1)
71+
72+
content = file_path.read_text(encoding="utf-8")
73+
74+
old_version = None
75+
updated = False
76+
77+
# Find and update __version__ line while preserving all formatting
78+
for line in content.splitlines():
79+
# Match __version__ = "..." with any amount of whitespace
80+
if line.strip().startswith("__version__") and "=" in line:
81+
# Extract old version (everything between quotes)
82+
parts = line.split("=", 1)
83+
if len(parts) == 2:
84+
value_part = parts[1].strip()
85+
if value_part.startswith('"') or value_part.startswith("'"):
86+
quote_char = value_part[0]
87+
# Find the version string between quotes
88+
start = value_part.index(quote_char)
89+
end = value_part.index(quote_char, start + 1)
90+
old_version = value_part[start + 1 : end]
91+
92+
if old_version != new_version:
93+
# Replace only the version string in the entire content
94+
# This preserves all whitespace and line endings
95+
old_full_line = line
96+
new_value_part = (
97+
value_part[: start + 1] + new_version + value_part[end:]
98+
)
99+
new_full_line = parts[0] + "= " + new_value_part
100+
content = content.replace(old_full_line, new_full_line, 1)
101+
updated = True
102+
break
103+
104+
if old_version is None:
105+
print(f"Error: Could not find __version__ in {file_path}", file=sys.stderr)
106+
sys.exit(1)
107+
108+
if updated:
109+
file_path.write_text(content, encoding="utf-8")
110+
111+
return old_version, updated
112+
113+
114+
def main() -> None:
115+
"""Update version in __init__.py file."""
116+
if len(sys.argv) != 2:
117+
print("Error: Version argument required", file=sys.stderr)
118+
print("Usage: python scripts/update-version.py <version>", file=sys.stderr)
119+
sys.exit(1)
120+
121+
new_version_str = sys.argv[1]
122+
123+
# Validate using official Python packaging library (PEP 440)
124+
new_version = validate_version(new_version_str)
125+
126+
print(f"\n📦 Updating version to {new_version}\n")
127+
128+
# Update __init__.py
129+
init_file = Path("src/workflows_mcp/__init__.py")
130+
old_version, was_updated = update_version_in_file(init_file, str(new_version))
131+
132+
if was_updated:
133+
print(f"✓ {init_file}: {old_version}{new_version}")
134+
else:
135+
print(f" {init_file}: already {new_version}")
136+
137+
print("\n✅ Version update complete\n")
138+
139+
140+
if __name__ == "__main__":
141+
main()

src/workflows_mcp/engine/orchestrator.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
2. Catch ExecutionPaused → signal pause to caller
66
3. Determine operation outcome (for Shell: exit_code)
77
4. Return structured BlockExecution result
8+
5. Resolve secrets in inputs and redact secrets from outputs
89
910
This is the bridge between executors (which return BaseModel or raise exceptions)
1011
and the workflow execution layer (which needs Metadata).
@@ -13,7 +14,7 @@
1314
from __future__ import annotations
1415

1516
from datetime import UTC, datetime
16-
from typing import Any
17+
from typing import TYPE_CHECKING, Any
1718

1819
from pydantic import BaseModel
1920

@@ -23,6 +24,9 @@
2324
from .executor_base import BlockExecutor
2425
from .metadata import Metadata
2526

27+
if TYPE_CHECKING:
28+
from .secrets import SecretProvider, SecretRedactor
29+
2630

2731
class BlockExecution(BaseModel):
2832
"""
@@ -56,9 +60,26 @@ class BlockOrchestrator:
5660
- Catch exceptions → create Metadata
5761
- Catch ExecutionPaused → signal pause
5862
- Determine operation outcome (e.g., Shell exit_code)
63+
- Resolve secrets in inputs before execution
64+
- Redact secrets from outputs after execution
5965
- Return BlockExecution with output + metadata
6066
"""
6167

68+
def __init__(
69+
self,
70+
secret_provider: SecretProvider | None = None,
71+
secret_redactor: SecretRedactor | None = None,
72+
):
73+
"""
74+
Initialize block orchestrator with optional secret management.
75+
76+
Args:
77+
secret_provider: Optional secret provider for resolving {{secrets.*}}
78+
secret_redactor: Optional secret redactor for output sanitization
79+
"""
80+
self.secret_provider = secret_provider
81+
self.secret_redactor = secret_redactor
82+
6283
async def execute_block(
6384
self,
6485
executor: BlockExecutor,
@@ -95,6 +116,14 @@ async def execute_block(
95116
# Execute block
96117
output = await executor.execute(inputs, context)
97118

119+
# Redact secrets from output (if redactor is configured)
120+
if self.secret_redactor:
121+
# Convert output to dict, redact, then reconstruct
122+
output_dict = output.model_dump()
123+
redacted_dict = self.secret_redactor.redact(output_dict)
124+
# Reconstruct output with redacted values
125+
output = type(output)(**redacted_dict)
126+
98127
# Success! Determine operation outcome
99128
completed_at = datetime.now(UTC).isoformat()
100129
execution_time_ms = datetime.now(UTC).timestamp() * 1000 - start_time_ms

0 commit comments

Comments
 (0)