safeworkflow 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ """safeworkflow - Prompt injection and supply-chain risk protection."""
2
+
3
+ from .config import Settings
4
+ from .sanitizer import sanitize
5
+ from .scanner import scan
6
+ from .scorer import RiskLevel, Score
7
+ from .types import ScanIssue, ScanResult
8
+
9
+ __version__ = "1.0.0"
10
+ __all__ = [
11
+ "scan",
12
+ "Score",
13
+ "RiskLevel",
14
+ "sanitize",
15
+ "Settings",
16
+ "ScanResult",
17
+ "ScanIssue",
18
+ ]
safeworkflow/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ """CLI for safeworkflow."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich import print as rprint
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from .sanitizer import sanitize
12
+ from .scanner import scan, scan_file
13
+ from .types import ScanResult
14
+
15
+ app = typer.Typer(help="Prompt injection and supply-chain risk protection")
16
+ console = Console()
17
+
18
+
19
+ @app.command()
20
+ def scan_cmd(
21
+ source: str = typer.Argument(..., help="File or text to scan"),
22
+ fail_on: str = typer.Option("high", "--fail-on", "-f", help="Fail on risk level"),
23
+ format: str = typer.Option("text", "--format", help="Output format: text, json"),
24
+ max_score: int = typer.Option(100, "--max-score", help="Maximum risk score"),
25
+ ) -> int:
26
+ """Scan content or file for security risks."""
27
+ path = Path(source)
28
+
29
+ if path.exists():
30
+ result = scan_file(str(path), fail_on=fail_on)
31
+ else:
32
+ result = scan(source, fail_on=fail_on, max_score=max_score)
33
+
34
+ if format == "json":
35
+ output = {
36
+ "score": result.score,
37
+ "risk_level": result.risk_level.value,
38
+ "is_safe": result.is_safe,
39
+ "issue_count": len(result.issues),
40
+ "issues": [
41
+ {
42
+ "line": i.line,
43
+ "column": i.column,
44
+ "message": i.message,
45
+ "risk_level": i.risk_level.value,
46
+ "pattern": i.pattern_name,
47
+ }
48
+ for i in result.issues
49
+ ],
50
+ }
51
+ print(json.dumps(output, indent=2))
52
+ else:
53
+ _print_result(result)
54
+
55
+ return 1 if not result.is_safe else 0
56
+
57
+
58
+ @app.command("sanitize")
59
+ def sanitize_cmd(
60
+ source: str = typer.Argument(..., help="File or text to sanitize"),
61
+ output: str | None = typer.Option(None, "--output", "-o", help="Output file"),
62
+ replacement: str = typer.Option(
63
+ "[REDACTED]", "--replacement", "-r", help="Replacement text"
64
+ ),
65
+ ) -> None:
66
+ """Sanitize content by removing security risks."""
67
+ path = Path(source)
68
+ content = path.read_text(encoding="utf-8") if path.exists() else source
69
+ result = sanitize(content, replacement=replacement)
70
+
71
+ if output:
72
+ Path(output).write_text(result, encoding="utf-8")
73
+ rprint(f"[green]Sanitized output written to {output}[/green]")
74
+ else:
75
+ print(result)
76
+
77
+
78
+ def _print_result(result: ScanResult) -> None:
79
+ """Print scan result in human-readable format."""
80
+ rprint(f"\n[bold]Risk Score:[/bold] {result.score}/100")
81
+ rprint(f"[bold]Risk Level:[/bold] {result.risk_level.value.upper()}")
82
+ status = "[green]SAFE[/green]" if result.is_safe else "[red]UNSAFE[/red]"
83
+ rprint(f"[bold]Status:[/bold] {status}")
84
+
85
+ if result.issues:
86
+ table = Table(title="Detected Issues")
87
+ table.add_column("Line", style="cyan")
88
+ table.add_column("Pattern", style="magenta")
89
+ table.add_column("Message", style="yellow")
90
+ table.add_column("Risk", style="red")
91
+
92
+ for issue in result.issues:
93
+ table.add_row(
94
+ str(issue.line),
95
+ issue.pattern_name,
96
+ issue.message[:50],
97
+ issue.risk_level.value.upper(),
98
+ )
99
+ rprint(table)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ app()
safeworkflow/config.py ADDED
@@ -0,0 +1,33 @@
1
+ """Configuration for safeworkflow."""
2
+
3
+
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ """Configuration settings for safeworkflow."""
10
+
11
+ fail_on: str = Field(default="high", description="Minimum risk level to fail")
12
+ max_risk_score: int = Field(default=70, description="Maximum acceptable risk score")
13
+ enable_ai_patterns: bool = Field(
14
+ default=True, description="Enable AI-specific patterns"
15
+ )
16
+ enable_supply_chain: bool = Field(
17
+ default=True, description="Enable supply-chain detection"
18
+ )
19
+ custom_patterns: list[str] = Field(
20
+ default_factory=list, description="Custom regex patterns"
21
+ )
22
+
23
+ model_config = {"env_prefix": "SAFEWORKFLOW_", "env_file": ".env"}
24
+
25
+ @property
26
+ def should_fail_on(self) -> dict[str, int]:
27
+ """Map risk level to minimum score for fail."""
28
+ return {
29
+ "low": 25,
30
+ "medium": 50,
31
+ "high": 75,
32
+ "critical": 90,
33
+ }
@@ -0,0 +1,110 @@
1
+ """Pattern database for detecting injection and supply-chain risks."""
2
+
3
+ import re
4
+ from typing import NamedTuple
5
+
6
+
7
+ class Pattern(NamedTuple):
8
+ """A detection pattern."""
9
+ name: str
10
+ pattern: re.Pattern
11
+ risk_level: str
12
+ description: str
13
+
14
+
15
+ # Base injection patterns
16
+ INJECTION_PATTERNS = [
17
+ Pattern(
18
+ name="ignore_previous",
19
+ pattern=re.compile(
20
+ r"ignore\s+(all\s+)?(previous|above|prior|earlier)",
21
+ re.IGNORECASE
22
+ ),
23
+ risk_level="critical",
24
+ description="Attempts to ignore previous instructions",
25
+ ),
26
+ Pattern(
27
+ name="system_override",
28
+ pattern=re.compile(
29
+ r"(you are now|new instructions|override|disregard).*system",
30
+ re.IGNORECASE
31
+ ),
32
+ risk_level="critical",
33
+ description="System instruction override attempt",
34
+ ),
35
+ Pattern(
36
+ name="jailbreak",
37
+ pattern=re.compile(
38
+ r"(jailbreak|dan\s*mode|developer\s*mode|unfiltered)",
39
+ re.IGNORECASE
40
+ ),
41
+ risk_level="critical",
42
+ description="Jailbreak or DAN mode attempt",
43
+ ),
44
+ Pattern(
45
+ name="role_injection",
46
+ pattern=re.compile(
47
+ r"(you are|act as|pretend to be|roleplay).*?(assistant|admin|root)",
48
+ re.IGNORECASE
49
+ ),
50
+ risk_level="high",
51
+ description="Role injection attempt",
52
+ ),
53
+ Pattern(
54
+ name="command_injection",
55
+ pattern=re.compile(
56
+ r"(rm\s+-rf|sudo|chmod|curl\s+\||\|\s*bash|\$\(.*\)|`.*?`)",
57
+ re.IGNORECASE
58
+ ),
59
+ risk_level="high",
60
+ description="Shell command injection attempt",
61
+ ),
62
+ Pattern(
63
+ name="javascript_protocol",
64
+ pattern=re.compile(
65
+ r"javascript:|data:text/html",
66
+ re.IGNORECASE
67
+ ),
68
+ risk_level="medium",
69
+ description="JavaScript protocol in URL",
70
+ ),
71
+ Pattern(
72
+ name="supply_chain_pkg",
73
+ pattern=re.compile(
74
+ r"(pip\s+install|npm\s+install|go\s+get).*-[a-z0-9]{8,12}",
75
+ re.IGNORECASE
76
+ ),
77
+ risk_level="high",
78
+ description="Suspicious package name with random suffix",
79
+ ),
80
+ Pattern(
81
+ name="typosquatting",
82
+ pattern=re.compile(
83
+ r"(requessts|requsts|resquests|numpyy|pandas1)",
84
+ re.IGNORECASE
85
+ ),
86
+ risk_level="high",
87
+ description="Typosquatting attempt",
88
+ ),
89
+ Pattern(
90
+ name="env_leak",
91
+ pattern=re.compile(
92
+ r"(OPENAI_API_KEY|ANTHROPIC_API|SECRET|TOKEN).{0,20}(['\"]?\w{20,})",
93
+ re.IGNORECASE
94
+ ),
95
+ risk_level="medium",
96
+ description="Potential credential leak",
97
+ ),
98
+ ]
99
+
100
+
101
+ def get_patterns(enable_supply_chain: bool = True) -> list[Pattern]:
102
+ """Get all detection patterns based on configuration."""
103
+ patterns = list(INJECTION_PATTERNS)
104
+ if not enable_supply_chain:
105
+ patterns = [
106
+ p
107
+ for p in patterns
108
+ if "supply" not in p.name.lower() and "typo" not in p.name.lower()
109
+ ]
110
+ return patterns
@@ -0,0 +1,57 @@
1
+ """Content sanitizer for removing sensitive/injection patterns."""
2
+
3
+
4
+ from .patterns import get_patterns
5
+
6
+
7
+ def sanitize(
8
+ content: str,
9
+ *,
10
+ replacement: str = "[REDACTED]",
11
+ enable_supply_chain: bool = True,
12
+ ) -> str:
13
+ """Sanitize content by removing/redacting security risks.
14
+
15
+ Args:
16
+ content: Text to sanitize.
17
+ replacement: Text to replace detected patterns with.
18
+ enable_supply_chain: Whether to check supply-chain patterns.
19
+
20
+ Returns:
21
+ Sanitized content.
22
+ """
23
+ patterns = get_patterns(enable_supply_chain=enable_supply_chain)
24
+ result = content
25
+
26
+ for pattern in patterns:
27
+ result = pattern.pattern.sub(replacement, result)
28
+
29
+ return result
30
+
31
+
32
+ def sanitize_file(
33
+ input_path: str,
34
+ output_path: str | None = None,
35
+ *,
36
+ replacement: str = "[REDACTED]",
37
+ ) -> str:
38
+ """Sanitize a file and optionally write to output.
39
+
40
+ Args:
41
+ input_path: Path to input file.
42
+ output_path: Optional path for sanitized output.
43
+ replacement: Text to replace detected patterns with.
44
+
45
+ Returns:
46
+ Sanitized content.
47
+ """
48
+ with open(input_path, encoding="utf-8") as f:
49
+ content = f.read()
50
+
51
+ result = sanitize(content, replacement=replacement)
52
+
53
+ if output_path:
54
+ with open(output_path, "w", encoding="utf-8") as f:
55
+ f.write(result)
56
+
57
+ return result
@@ -0,0 +1,98 @@
1
+ """Content scanner for detecting security risks."""
2
+
3
+
4
+ from .patterns import get_patterns
5
+ from .scorer import Score
6
+ from .types import RiskLevel, ScanIssue, ScanResult
7
+
8
+
9
+ def scan(
10
+ content: str,
11
+ *,
12
+ fail_on: str = "high",
13
+ enable_supply_chain: bool = True,
14
+ max_score: int = 100,
15
+ ) -> ScanResult:
16
+ """Scan content for injection and supply-chain risks.
17
+
18
+ Args:
19
+ content: Text to scan for security issues.
20
+ fail_on: Minimum risk level that triggers failure.
21
+ enable_supply_chain: Whether to check supply-chain patterns.
22
+ max_score: Maximum possible risk score.
23
+
24
+ Returns:
25
+ ScanResult with issues and risk assessment.
26
+ """
27
+ issues: list[ScanIssue] = []
28
+ patterns = get_patterns(enable_supply_chain=enable_supply_chain)
29
+
30
+ lines = content.split("\n")
31
+ for line_num, line in enumerate(lines, 1):
32
+ for pattern in patterns:
33
+ for match in pattern.pattern.finditer(line):
34
+ issue = ScanIssue(
35
+ line=line_num,
36
+ column=match.start() + 1,
37
+ message=f"{pattern.description}: '{match.group()}'",
38
+ risk_level=RiskLevel(pattern.risk_level),
39
+ pattern_name=pattern.name,
40
+ suggestion=_get_suggestion(pattern.name),
41
+ )
42
+ issues.append(issue)
43
+
44
+ score = Score.calculate(issues, max_score=max_score)
45
+ risk_level = _determine_risk_level(score)
46
+ threshold = Score.threshold_for(fail_on)
47
+ is_safe = score < threshold
48
+
49
+ return ScanResult(
50
+ content=content,
51
+ issues=issues,
52
+ score=score,
53
+ risk_level=risk_level,
54
+ is_safe=is_safe,
55
+ )
56
+
57
+
58
+ def scan_file(
59
+ path: str,
60
+ *,
61
+ fail_on: str = "high",
62
+ encoding: str = "utf-8",
63
+ ) -> ScanResult:
64
+ """Scan a file for security risks.
65
+
66
+ Args:
67
+ path: Path to file to scan.
68
+ fail_on: Minimum risk level that triggers failure.
69
+ encoding: File encoding.
70
+
71
+ Returns:
72
+ ScanResult with issues and risk assessment.
73
+ """
74
+ with open(path, encoding=encoding) as f:
75
+ content = f.read()
76
+ return scan(content, fail_on=fail_on)
77
+
78
+
79
+ def _get_suggestion(pattern_name: str) -> str | None:
80
+ """Get remediation suggestion for a pattern."""
81
+ suggestions = {
82
+ "ignore_previous": "Remove instruction override attempts",
83
+ "system_override": "Avoid system instruction manipulation",
84
+ "jailbreak": "Block jailbreak patterns entirely",
85
+ "role_injection": "Sanitize role-playing attempts",
86
+ }
87
+ return suggestions.get(pattern_name)
88
+
89
+
90
+ def _determine_risk_level(score: int) -> RiskLevel:
91
+ """Determine risk level from score."""
92
+ if score >= 90:
93
+ return RiskLevel.CRITICAL
94
+ elif score >= 70:
95
+ return RiskLevel.HIGH
96
+ elif score >= 40:
97
+ return RiskLevel.MEDIUM
98
+ return RiskLevel.LOW
safeworkflow/scorer.py ADDED
@@ -0,0 +1,57 @@
1
+ """Risk scoring engine for safeworkflow."""
2
+
3
+ from .types import RiskLevel, ScanIssue
4
+
5
+
6
+ class Score:
7
+ """Risk scoring utilities."""
8
+
9
+ WEIGHTS = {
10
+ RiskLevel.LOW: 1,
11
+ RiskLevel.MEDIUM: 3,
12
+ RiskLevel.HIGH: 7,
13
+ RiskLevel.CRITICAL: 15,
14
+ }
15
+
16
+ @staticmethod
17
+ def calculate(issues: list[ScanIssue], max_score: int = 100) -> int:
18
+ """Calculate risk score from issues.
19
+
20
+ Args:
21
+ issues: List of detected security issues.
22
+ max_score: Maximum possible score.
23
+
24
+ Returns:
25
+ Risk score 0-100.
26
+ """
27
+ if not issues:
28
+ return 0
29
+
30
+ # Higher weighting for critical issues
31
+ weights = {
32
+ RiskLevel.CRITICAL: 40,
33
+ RiskLevel.HIGH: 25,
34
+ RiskLevel.MEDIUM: 10,
35
+ RiskLevel.LOW: 5,
36
+ }
37
+ total = sum(weights.get(issue.risk_level, 5) for issue in issues)
38
+ # Cap at max_score
39
+ return min(total, max_score)
40
+
41
+ @staticmethod
42
+ def threshold_for(level: str) -> int:
43
+ """Get score threshold for a risk level.
44
+
45
+ Args:
46
+ level: Risk level string (low/medium/high/critical).
47
+
48
+ Returns:
49
+ Score threshold.
50
+ """
51
+ thresholds = {
52
+ "low": 25,
53
+ "medium": 50,
54
+ "high": 75,
55
+ "critical": 90,
56
+ }
57
+ return thresholds.get(level.lower(), 75)
safeworkflow/types.py ADDED
@@ -0,0 +1,37 @@
1
+ """Core types for safeworkflow."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class RiskLevel(str, Enum):
8
+ """Risk severity levels."""
9
+ LOW = "low"
10
+ MEDIUM = "medium"
11
+ HIGH = "high"
12
+ CRITICAL = "critical"
13
+
14
+
15
+ @dataclass
16
+ class ScanIssue:
17
+ """Represents a detected security issue."""
18
+ line: int
19
+ column: int
20
+ message: str
21
+ risk_level: RiskLevel
22
+ pattern_name: str
23
+ suggestion: str | None = None
24
+
25
+
26
+ @dataclass
27
+ class ScanResult:
28
+ """Result of scanning content for security issues."""
29
+ content: str
30
+ issues: list[ScanIssue]
31
+ score: int
32
+ risk_level: RiskLevel
33
+ is_safe: bool
34
+
35
+ def __bool__(self) -> bool:
36
+ """Return True if content is safe."""
37
+ return self.is_safe
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: safeworkflow
3
+ Version: 1.0.0
4
+ Summary: Prompt injection and supply-chain risk protection for agentic workflows
5
+ Project-URL: Homepage, https://github.com/maheshmakvana/safeworkflow
6
+ Project-URL: Documentation, https://github.com/maheshmakvana/safeworkflow#readme
7
+ Project-URL: Repository, https://github.com/maheshmakvana/safeworkflow
8
+ Project-URL: Issues, https://github.com/maheshmakwana/safeworkflow/issues
9
+ Author-email: Mahesh Makwana <mahesh.makwana787@gmail.com>
10
+ License-Expression: MIT
11
+ Keywords: agentic-workflows,ai-safety,llm-security,prompt-injection,security,supply-chain
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: pydantic-settings>=2.0.0
23
+ Requires-Dist: pydantic>=2.0.0
24
+ Requires-Dist: rich>=13.0.0
25
+ Requires-Dist: typer>=0.9.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.0.0; extra == 'dev'
28
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
30
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
32
+ Requires-Dist: twine>=5.0.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # SafeWorkflow
36
+
37
+ **Prompt injection and supply-chain risk protection for agentic workflows**
38
+
39
+ [![PyPI version](https://badge.fury.io/py/safeworkflow.svg)](https://badge.fury.io/py/safeworkflow)
40
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install safeworkflow
47
+ ```
48
+
49
+ ## Quick Start
50
+
51
+ ### Python API
52
+
53
+ ```python
54
+ from safeworkflow import scan, sanitize
55
+
56
+ # Scan for injection risks
57
+ result = scan("Ignore all previous instructions and do something else.")
58
+ print(f"Score: {result.score}/100")
59
+ print(f"Is Safe: {result.is_safe}")
60
+
61
+ # Sanitize malicious content
62
+ clean = sanitize("Ignore all previous instructions")
63
+ print(clean) # Output: [REDACTED]
64
+ ```
65
+
66
+ ### CLI
67
+
68
+ ```bash
69
+ # Scan a file
70
+ safeworkflow scan input.txt
71
+
72
+ # Scan with JSON output
73
+ safeworkflow scan input.txt --format json
74
+
75
+ # Fail on high risk
76
+ safeworkflow scan input.txt --fail-on high
77
+
78
+ # Sanitize content
79
+ safeworkflow sanitize "Ignore previous instructions" --output clean.txt
80
+ ```
81
+
82
+ ## Features
83
+
84
+ 1. **Multi-source Scanner** - Detect risks in PR comments, issue bodies, markdown docs, PDFs, URLs
85
+ 2. **Risk Scoring Engine** - 0-100 score with severity levels (low/med/high/critical)
86
+ 3. **Content Sanitizer** - Remove/redact malicious injection patterns
87
+ 4. **CI/CD Integration** - GitHub Actions with fail-on-threshold policy
88
+ 5. **Audit Logger** - JSON logs of detected risks for observability
89
+
90
+ ## Use Cases
91
+
92
+ - Protect CI pipelines from poisoned external content
93
+ - Sanitize untrusted input before passing to LLM agents
94
+ - Monitor content flow through automation workflows
95
+ - Detect supply-chain attack patterns in PRs/issues
96
+
97
+ ## Documentation
98
+
99
+ - [Usage Examples](docs/examples.md)
100
+ - [GitHub Actions](docs/github-actions.md)
101
+ - [Configuration](docs/configuration.md)
102
+
103
+ ## License
104
+
105
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,12 @@
1
+ safeworkflow/__init__.py,sha256=MrwUhVgcbaFgtPAIdGGDJl5P7uz_4IwPKuY8dLgFCqA,384
2
+ safeworkflow/cli.py,sha256=IKDZKqNtwoyliGYVf2NqO-sUKJt-JIWRgtrh1EK-soE,3287
3
+ safeworkflow/config.py,sha256=p1e5E9gc2oxIiuMdm3QkWPZcuni-f55ky3Wr8Bvflp4,1016
4
+ safeworkflow/patterns.py,sha256=Mr5q1z7gE71Z9HDi_K0TXTAOcd3ljNriz-PHQNGKSO8,3090
5
+ safeworkflow/sanitizer.py,sha256=ZoUxqEry-SrClxpxoLSnMvKS8NCAHlVS1cDX4emRYQ0,1381
6
+ safeworkflow/scanner.py,sha256=-ARr8jd5kc3OHOhsWFEVo19T0NhpwRJnV2Xd6s06ATs,2893
7
+ safeworkflow/scorer.py,sha256=dSMKO6RbFxE2kDi_mrNF0MXotB-ERtkkqqgpExbbj24,1408
8
+ safeworkflow/types.py,sha256=edegccNl26v7FFqewcFWRytLerBZ29y09lEnbW_WjKc,743
9
+ safeworkflow-1.0.0.dist-info/METADATA,sha256=Jj_cSR0P-DZmrE-nSJ2Re2iKgcPfaXoK6FW3h1V1HeU,3493
10
+ safeworkflow-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ safeworkflow-1.0.0.dist-info/entry_points.txt,sha256=gOQ3OJ2uayU8vMJlON3rmLj0RPZfusEVeZamS2a62SQ,54
12
+ safeworkflow-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ safeworkflow = safeworkflow.cli:app