flowly-code 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.
- flowly_code/__init__.py +30 -0
- flowly_code/__main__.py +8 -0
- flowly_code/activity/__init__.py +1 -0
- flowly_code/activity/bus.py +91 -0
- flowly_code/activity/events.py +40 -0
- flowly_code/agent/__init__.py +8 -0
- flowly_code/agent/context.py +485 -0
- flowly_code/agent/loop.py +1349 -0
- flowly_code/agent/memory.py +109 -0
- flowly_code/agent/skills.py +259 -0
- flowly_code/agent/subagent.py +249 -0
- flowly_code/agent/tools/__init__.py +6 -0
- flowly_code/agent/tools/base.py +55 -0
- flowly_code/agent/tools/delegate.py +194 -0
- flowly_code/agent/tools/dispatch.py +840 -0
- flowly_code/agent/tools/docker.py +609 -0
- flowly_code/agent/tools/filesystem.py +280 -0
- flowly_code/agent/tools/mcp.py +85 -0
- flowly_code/agent/tools/message.py +235 -0
- flowly_code/agent/tools/registry.py +257 -0
- flowly_code/agent/tools/screenshot.py +444 -0
- flowly_code/agent/tools/shell.py +166 -0
- flowly_code/agent/tools/spawn.py +65 -0
- flowly_code/agent/tools/system.py +917 -0
- flowly_code/agent/tools/trello.py +420 -0
- flowly_code/agent/tools/web.py +139 -0
- flowly_code/agent/tools/x.py +399 -0
- flowly_code/bus/__init__.py +6 -0
- flowly_code/bus/events.py +37 -0
- flowly_code/bus/queue.py +81 -0
- flowly_code/channels/__init__.py +6 -0
- flowly_code/channels/base.py +121 -0
- flowly_code/channels/manager.py +135 -0
- flowly_code/channels/telegram.py +1132 -0
- flowly_code/cli/__init__.py +1 -0
- flowly_code/cli/commands.py +1831 -0
- flowly_code/cli/setup.py +1356 -0
- flowly_code/compaction/__init__.py +39 -0
- flowly_code/compaction/estimator.py +88 -0
- flowly_code/compaction/pruning.py +223 -0
- flowly_code/compaction/service.py +297 -0
- flowly_code/compaction/summarizer.py +384 -0
- flowly_code/compaction/types.py +71 -0
- flowly_code/config/__init__.py +6 -0
- flowly_code/config/loader.py +102 -0
- flowly_code/config/schema.py +324 -0
- flowly_code/exec/__init__.py +39 -0
- flowly_code/exec/approvals.py +288 -0
- flowly_code/exec/executor.py +184 -0
- flowly_code/exec/safety.py +247 -0
- flowly_code/exec/types.py +88 -0
- flowly_code/gateway/__init__.py +5 -0
- flowly_code/gateway/server.py +103 -0
- flowly_code/heartbeat/__init__.py +5 -0
- flowly_code/heartbeat/service.py +130 -0
- flowly_code/multiagent/README.md +248 -0
- flowly_code/multiagent/__init__.py +1 -0
- flowly_code/multiagent/invoke.py +210 -0
- flowly_code/multiagent/orchestrator.py +156 -0
- flowly_code/multiagent/router.py +156 -0
- flowly_code/multiagent/setup.py +171 -0
- flowly_code/pairing/__init__.py +21 -0
- flowly_code/pairing/store.py +343 -0
- flowly_code/providers/__init__.py +6 -0
- flowly_code/providers/base.py +69 -0
- flowly_code/providers/litellm_provider.py +178 -0
- flowly_code/providers/transcription.py +64 -0
- flowly_code/session/__init__.py +5 -0
- flowly_code/session/manager.py +249 -0
- flowly_code/skills/README.md +24 -0
- flowly_code/skills/compact/SKILL.md +27 -0
- flowly_code/skills/github/SKILL.md +48 -0
- flowly_code/skills/skill-creator/SKILL.md +371 -0
- flowly_code/skills/summarize/SKILL.md +67 -0
- flowly_code/skills/tmux/SKILL.md +121 -0
- flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
- flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
- flowly_code/skills/weather/SKILL.md +49 -0
- flowly_code/utils/__init__.py +5 -0
- flowly_code/utils/helpers.py +91 -0
- flowly_code-1.0.0.dist-info/METADATA +724 -0
- flowly_code-1.0.0.dist-info/RECORD +86 -0
- flowly_code-1.0.0.dist-info/WHEEL +4 -0
- flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
- flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
- flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Command executor with security checks."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from flowly_code.exec.types import (
|
|
11
|
+
ExecRequest,
|
|
12
|
+
ExecResult,
|
|
13
|
+
ExecConfig,
|
|
14
|
+
)
|
|
15
|
+
from flowly_code.exec.safety import analyze_command
|
|
16
|
+
from flowly_code.exec.approvals import (
|
|
17
|
+
ExecApprovalStore,
|
|
18
|
+
check_allowlist,
|
|
19
|
+
requires_approval,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def execute_command(
|
|
24
|
+
request: ExecRequest,
|
|
25
|
+
config: ExecConfig,
|
|
26
|
+
store: ExecApprovalStore,
|
|
27
|
+
) -> ExecResult:
|
|
28
|
+
"""
|
|
29
|
+
Execute a command with full security checks.
|
|
30
|
+
|
|
31
|
+
Security flow:
|
|
32
|
+
1. Check if exec is enabled
|
|
33
|
+
2. Analyze command for safety
|
|
34
|
+
3. Check security mode (deny/allowlist/full)
|
|
35
|
+
4. Check allowlist if in allowlist mode
|
|
36
|
+
5. Request approval if needed
|
|
37
|
+
6. Execute command with timeout
|
|
38
|
+
"""
|
|
39
|
+
# Check if enabled
|
|
40
|
+
if not config.enabled:
|
|
41
|
+
return ExecResult(
|
|
42
|
+
success=False,
|
|
43
|
+
denied=True,
|
|
44
|
+
error="Command execution is disabled"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Analyze command
|
|
48
|
+
analysis = analyze_command(request.command)
|
|
49
|
+
|
|
50
|
+
# Check for dangerous patterns
|
|
51
|
+
if analysis.has_dangerous_chars:
|
|
52
|
+
return ExecResult(
|
|
53
|
+
success=False,
|
|
54
|
+
denied=True,
|
|
55
|
+
error=f"Command rejected: {analysis.reason}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Get store config
|
|
59
|
+
store_config = store.config
|
|
60
|
+
|
|
61
|
+
# Security mode check
|
|
62
|
+
if store_config.security == "deny":
|
|
63
|
+
return ExecResult(
|
|
64
|
+
success=False,
|
|
65
|
+
denied=True,
|
|
66
|
+
error="Command execution denied by security policy"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Allowlist mode
|
|
70
|
+
if store_config.security == "allowlist":
|
|
71
|
+
allowlist_ok = check_allowlist(store, analysis.resolved_path, analysis.executable)
|
|
72
|
+
|
|
73
|
+
# Check if approval is required
|
|
74
|
+
if requires_approval(store_config, analysis.ok, allowlist_ok):
|
|
75
|
+
# Create pending approval
|
|
76
|
+
pending = store.create_pending(request, config.approval_timeout_seconds)
|
|
77
|
+
|
|
78
|
+
# Request approval via callback (Telegram)
|
|
79
|
+
decision = await store.request_approval(pending)
|
|
80
|
+
|
|
81
|
+
if decision is None:
|
|
82
|
+
# Timeout or no callback
|
|
83
|
+
return ExecResult(
|
|
84
|
+
success=False,
|
|
85
|
+
denied=True,
|
|
86
|
+
error="Approval timed out or not available"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if decision == "deny":
|
|
90
|
+
store.resolve_pending(pending.id, decision)
|
|
91
|
+
return ExecResult(
|
|
92
|
+
success=False,
|
|
93
|
+
denied=True,
|
|
94
|
+
error="Command denied by user"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Allow-once or allow-always
|
|
98
|
+
store.resolve_pending(pending.id, decision)
|
|
99
|
+
|
|
100
|
+
elif not allowlist_ok and not analysis.is_safe_bin:
|
|
101
|
+
# Not in allowlist and not a safe bin
|
|
102
|
+
return ExecResult(
|
|
103
|
+
success=False,
|
|
104
|
+
denied=True,
|
|
105
|
+
error=f"Command not in allowlist: {analysis.executable}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Execute the command
|
|
109
|
+
timeout = request.timeout or config.timeout_seconds
|
|
110
|
+
cwd = request.cwd or str(Path.home())
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
# Build environment
|
|
114
|
+
env = None
|
|
115
|
+
if request.env:
|
|
116
|
+
import os
|
|
117
|
+
env = os.environ.copy()
|
|
118
|
+
env.update(request.env)
|
|
119
|
+
|
|
120
|
+
# Background mode: start process and return immediately with PID
|
|
121
|
+
if request.background:
|
|
122
|
+
process = await asyncio.create_subprocess_shell(
|
|
123
|
+
request.command,
|
|
124
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
125
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
126
|
+
cwd=cwd,
|
|
127
|
+
env=env,
|
|
128
|
+
start_new_session=True,
|
|
129
|
+
)
|
|
130
|
+
logger.info(f"Background process started: PID={process.pid}, cmd={request.command[:80]}")
|
|
131
|
+
return ExecResult(
|
|
132
|
+
success=True,
|
|
133
|
+
pid=process.pid,
|
|
134
|
+
stdout=f"Process started in background (PID: {process.pid})",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Foreground mode: run and wait for completion
|
|
138
|
+
process = await asyncio.create_subprocess_shell(
|
|
139
|
+
request.command,
|
|
140
|
+
stdout=asyncio.subprocess.PIPE,
|
|
141
|
+
stderr=asyncio.subprocess.PIPE,
|
|
142
|
+
cwd=cwd,
|
|
143
|
+
env=env,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
stdout, stderr = await asyncio.wait_for(
|
|
148
|
+
process.communicate(),
|
|
149
|
+
timeout=timeout
|
|
150
|
+
)
|
|
151
|
+
except asyncio.TimeoutError:
|
|
152
|
+
process.kill()
|
|
153
|
+
await process.wait()
|
|
154
|
+
return ExecResult(
|
|
155
|
+
success=False,
|
|
156
|
+
exit_code=-1,
|
|
157
|
+
error=f"Command timed out after {timeout} seconds",
|
|
158
|
+
timed_out=True
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Decode output
|
|
162
|
+
stdout_str = stdout.decode('utf-8', errors='replace')
|
|
163
|
+
stderr_str = stderr.decode('utf-8', errors='replace')
|
|
164
|
+
|
|
165
|
+
# Truncate if too long
|
|
166
|
+
max_output = config.max_output_chars
|
|
167
|
+
if len(stdout_str) > max_output:
|
|
168
|
+
stdout_str = stdout_str[:max_output] + f"\n... (truncated, {len(stdout_str)} total chars)"
|
|
169
|
+
if len(stderr_str) > max_output:
|
|
170
|
+
stderr_str = stderr_str[:max_output] + f"\n... (truncated, {len(stderr_str)} total chars)"
|
|
171
|
+
|
|
172
|
+
return ExecResult(
|
|
173
|
+
success=process.returncode == 0,
|
|
174
|
+
exit_code=process.returncode,
|
|
175
|
+
stdout=stdout_str,
|
|
176
|
+
stderr=stderr_str,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"Command execution error: {e}")
|
|
181
|
+
return ExecResult(
|
|
182
|
+
success=False,
|
|
183
|
+
error=str(e)
|
|
184
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Command safety analysis and validation."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from flowly_code.exec.types import CommandAnalysis
|
|
10
|
+
|
|
11
|
+
# Safe binaries that only operate on stdin (no file args)
|
|
12
|
+
DEFAULT_SAFE_BINS = frozenset([
|
|
13
|
+
"jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc",
|
|
14
|
+
"cat", "echo", "date", "whoami", "pwd", "hostname", "uname",
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
# Dangerous shell metacharacters
|
|
18
|
+
SHELL_METACHARS = re.compile(r'[;&|`$<>]')
|
|
19
|
+
CONTROL_CHARS = re.compile(r'[\r\n\x00]')
|
|
20
|
+
QUOTE_CHARS = re.compile(r'["\']')
|
|
21
|
+
|
|
22
|
+
# Patterns for dangerous commands (Unix)
|
|
23
|
+
_UNIX_DANGEROUS_PATTERNS = [
|
|
24
|
+
re.compile(r'\brm\s+(-[rf]+\s+)*/', re.IGNORECASE), # rm -rf /
|
|
25
|
+
re.compile(r'\bsudo\b', re.IGNORECASE),
|
|
26
|
+
re.compile(r'\bchmod\s+777', re.IGNORECASE),
|
|
27
|
+
re.compile(r'\bchown\b.*root', re.IGNORECASE),
|
|
28
|
+
re.compile(r'\bmkfs\b', re.IGNORECASE),
|
|
29
|
+
re.compile(r'\bdd\b.*of=/', re.IGNORECASE),
|
|
30
|
+
re.compile(r'>\s*/dev/', re.IGNORECASE),
|
|
31
|
+
re.compile(r'\bcurl\b.*\|\s*(ba)?sh', re.IGNORECASE), # curl | sh
|
|
32
|
+
re.compile(r'\bwget\b.*\|\s*(ba)?sh', re.IGNORECASE), # wget | sh
|
|
33
|
+
re.compile(r':(){.*};:', re.IGNORECASE), # Fork bomb
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Patterns for dangerous commands (Windows)
|
|
37
|
+
_WINDOWS_DANGEROUS_PATTERNS = [
|
|
38
|
+
re.compile(r'\bformat\b\s+[a-z]:', re.IGNORECASE), # format C:
|
|
39
|
+
re.compile(r'\bdiskpart\b', re.IGNORECASE),
|
|
40
|
+
re.compile(r'\breg\b\s+delete', re.IGNORECASE), # reg delete
|
|
41
|
+
re.compile(r'\bcipher\b\s+/w', re.IGNORECASE), # cipher /w (wipe)
|
|
42
|
+
re.compile(r'\bdel\b\s+/[sfq]', re.IGNORECASE), # del /s /f /q
|
|
43
|
+
re.compile(r'\brd\b\s+/s', re.IGNORECASE), # rd /s (recursive delete)
|
|
44
|
+
re.compile(r'\brmdir\b\s+/s', re.IGNORECASE), # rmdir /s
|
|
45
|
+
re.compile(r'\bnet\b\s+user\b.*\b/delete\b', re.IGNORECASE), # net user /delete
|
|
46
|
+
re.compile(r'\bbcdedit\b', re.IGNORECASE), # boot config
|
|
47
|
+
re.compile(r'\brunas\b\s+/user:administrator', re.IGNORECASE),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
DANGEROUS_PATTERNS = (
|
|
51
|
+
_UNIX_DANGEROUS_PATTERNS + _WINDOWS_DANGEROUS_PATTERNS
|
|
52
|
+
if platform.system() == "Windows"
|
|
53
|
+
else _UNIX_DANGEROUS_PATTERNS
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Pipeline operators that are not allowed in allowlist mode
|
|
57
|
+
DISALLOWED_PIPELINE_OPS = {'||', '|&', '`', '$(', '\n', '\r', '(', ')'}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_safe_executable(value: str | None) -> bool:
|
|
61
|
+
"""Check if a string is safe to use as an executable name."""
|
|
62
|
+
if not value:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
trimmed = value.strip()
|
|
66
|
+
if not trimmed:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# Check for dangerous characters
|
|
70
|
+
if '\0' in trimmed:
|
|
71
|
+
return False
|
|
72
|
+
if CONTROL_CHARS.search(trimmed):
|
|
73
|
+
return False
|
|
74
|
+
if SHELL_METACHARS.search(trimmed):
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_executable(name: str) -> str | None:
|
|
81
|
+
"""Resolve an executable name to its full path."""
|
|
82
|
+
import os
|
|
83
|
+
|
|
84
|
+
# If it's already a path (check both Unix and Windows separators)
|
|
85
|
+
if '/' in name or '\\' in name or os.sep in name:
|
|
86
|
+
path = Path(name).expanduser().resolve()
|
|
87
|
+
if path.exists() and path.is_file():
|
|
88
|
+
return str(path)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Use shutil.which to find in PATH
|
|
92
|
+
return shutil.which(name)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_safe_bin(executable: str, args: list[str]) -> bool:
|
|
96
|
+
"""Check if command is a safe stdin-only binary with safe args."""
|
|
97
|
+
import os
|
|
98
|
+
|
|
99
|
+
# Get basename (handle both Unix and Windows paths)
|
|
100
|
+
name = Path(executable).name if ('/' in executable or '\\' in executable or os.sep in executable) else executable
|
|
101
|
+
|
|
102
|
+
if name not in DEFAULT_SAFE_BINS:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
# Check args don't reference files
|
|
106
|
+
for arg in args:
|
|
107
|
+
# Skip flags
|
|
108
|
+
if arg.startswith('-'):
|
|
109
|
+
continue
|
|
110
|
+
# Check if arg looks like a path
|
|
111
|
+
if '/' in arg or '\\' in arg or arg.startswith('~'):
|
|
112
|
+
return False
|
|
113
|
+
# Check if arg is an existing file
|
|
114
|
+
if Path(arg).exists():
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def has_dangerous_pattern(command: str) -> bool:
|
|
121
|
+
"""Check if command matches any dangerous patterns."""
|
|
122
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
123
|
+
if pattern.search(command):
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def split_pipeline(command: str) -> tuple[bool, str | None, list[str]]:
|
|
129
|
+
"""
|
|
130
|
+
Split a command into pipeline segments.
|
|
131
|
+
|
|
132
|
+
Returns (ok, reason, segments).
|
|
133
|
+
"""
|
|
134
|
+
# Check for disallowed operators
|
|
135
|
+
for op in DISALLOWED_PIPELINE_OPS:
|
|
136
|
+
if op in command:
|
|
137
|
+
return False, f"Disallowed operator: {op}", []
|
|
138
|
+
|
|
139
|
+
# Check for command substitution
|
|
140
|
+
if '$(' in command or '`' in command:
|
|
141
|
+
return False, "Command substitution not allowed", []
|
|
142
|
+
|
|
143
|
+
# Check for redirection
|
|
144
|
+
if re.search(r'[<>]', command):
|
|
145
|
+
return False, "Redirection not allowed in allowlist mode", []
|
|
146
|
+
|
|
147
|
+
# Split by pipe
|
|
148
|
+
segments = [s.strip() for s in command.split('|')]
|
|
149
|
+
|
|
150
|
+
# Validate each segment
|
|
151
|
+
for seg in segments:
|
|
152
|
+
if not seg:
|
|
153
|
+
return False, "Empty pipeline segment", []
|
|
154
|
+
|
|
155
|
+
return True, None, segments
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_command(command: str) -> tuple[str | None, list[str]]:
|
|
159
|
+
"""Parse a command into executable and arguments."""
|
|
160
|
+
try:
|
|
161
|
+
parts = shlex.split(command)
|
|
162
|
+
if not parts:
|
|
163
|
+
return None, []
|
|
164
|
+
return parts[0], parts[1:]
|
|
165
|
+
except ValueError:
|
|
166
|
+
return None, []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def analyze_command(command: str) -> CommandAnalysis:
|
|
170
|
+
"""
|
|
171
|
+
Analyze a shell command for safety.
|
|
172
|
+
|
|
173
|
+
Returns a CommandAnalysis with details about the command.
|
|
174
|
+
"""
|
|
175
|
+
command = command.strip()
|
|
176
|
+
|
|
177
|
+
if not command:
|
|
178
|
+
return CommandAnalysis(ok=False, reason="Empty command")
|
|
179
|
+
|
|
180
|
+
# Check for control characters
|
|
181
|
+
if CONTROL_CHARS.search(command):
|
|
182
|
+
return CommandAnalysis(
|
|
183
|
+
ok=False,
|
|
184
|
+
reason="Control characters not allowed",
|
|
185
|
+
has_dangerous_chars=True
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Check for dangerous patterns
|
|
189
|
+
if has_dangerous_pattern(command):
|
|
190
|
+
return CommandAnalysis(
|
|
191
|
+
ok=False,
|
|
192
|
+
reason="Command matches dangerous pattern",
|
|
193
|
+
has_dangerous_chars=True
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Check if it's a pipeline
|
|
197
|
+
is_pipeline = '|' in command and '||' not in command
|
|
198
|
+
|
|
199
|
+
if is_pipeline:
|
|
200
|
+
ok, reason, segments = split_pipeline(command)
|
|
201
|
+
if not ok:
|
|
202
|
+
return CommandAnalysis(
|
|
203
|
+
ok=False,
|
|
204
|
+
reason=reason,
|
|
205
|
+
is_pipeline=True,
|
|
206
|
+
has_dangerous_chars=True
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# For pipelines, analyze the first segment
|
|
210
|
+
executable, args = parse_command(segments[0])
|
|
211
|
+
resolved = resolve_executable(executable) if executable else None
|
|
212
|
+
|
|
213
|
+
return CommandAnalysis(
|
|
214
|
+
ok=True,
|
|
215
|
+
executable=executable,
|
|
216
|
+
resolved_path=resolved,
|
|
217
|
+
args=args,
|
|
218
|
+
is_pipeline=True,
|
|
219
|
+
segments=segments,
|
|
220
|
+
is_safe_bin=is_safe_bin(executable, args) if executable else False
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Single command
|
|
224
|
+
executable, args = parse_command(command)
|
|
225
|
+
|
|
226
|
+
if not executable:
|
|
227
|
+
return CommandAnalysis(ok=False, reason="Could not parse command")
|
|
228
|
+
|
|
229
|
+
# Check for shell metachars in the executable
|
|
230
|
+
if SHELL_METACHARS.search(executable):
|
|
231
|
+
return CommandAnalysis(
|
|
232
|
+
ok=False,
|
|
233
|
+
reason="Shell metacharacters in executable",
|
|
234
|
+
has_dangerous_chars=True
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Resolve the executable path
|
|
238
|
+
resolved = resolve_executable(executable)
|
|
239
|
+
|
|
240
|
+
return CommandAnalysis(
|
|
241
|
+
ok=True,
|
|
242
|
+
executable=executable,
|
|
243
|
+
resolved_path=resolved,
|
|
244
|
+
args=args,
|
|
245
|
+
is_pipeline=False,
|
|
246
|
+
is_safe_bin=is_safe_bin(executable, args)
|
|
247
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Type definitions for secure command execution."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
# Security modes
|
|
7
|
+
ExecSecurity = Literal["deny", "allowlist", "full"]
|
|
8
|
+
|
|
9
|
+
# Ask modes for approval
|
|
10
|
+
ExecAsk = Literal["off", "on-miss", "always"]
|
|
11
|
+
|
|
12
|
+
# Execution host
|
|
13
|
+
ExecHost = Literal["local", "sandbox"]
|
|
14
|
+
|
|
15
|
+
# Approval decisions
|
|
16
|
+
ExecApprovalDecision = Literal["allow-once", "allow-always", "deny"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ExecConfig:
|
|
21
|
+
"""Configuration for command execution."""
|
|
22
|
+
enabled: bool = False
|
|
23
|
+
security: ExecSecurity = "deny"
|
|
24
|
+
ask: ExecAsk = "on-miss"
|
|
25
|
+
ask_fallback: ExecSecurity = "deny"
|
|
26
|
+
host: ExecHost = "local"
|
|
27
|
+
timeout_seconds: int = 300 # 5 minutes default
|
|
28
|
+
max_output_chars: int = 200_000 # 200KB
|
|
29
|
+
approval_timeout_seconds: int = 120 # 2 minutes to approve
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AllowlistEntry:
|
|
34
|
+
"""An entry in the exec allowlist."""
|
|
35
|
+
pattern: str
|
|
36
|
+
last_used_at: int | None = None
|
|
37
|
+
last_used_command: str | None = None
|
|
38
|
+
last_resolved_path: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ExecRequest:
|
|
43
|
+
"""A request to execute a command."""
|
|
44
|
+
command: str
|
|
45
|
+
cwd: str | None = None
|
|
46
|
+
timeout: int | None = None
|
|
47
|
+
env: dict[str, str] | None = None
|
|
48
|
+
session_key: str | None = None
|
|
49
|
+
background: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ExecResult:
|
|
54
|
+
"""Result of command execution."""
|
|
55
|
+
success: bool
|
|
56
|
+
exit_code: int | None = None
|
|
57
|
+
stdout: str = ""
|
|
58
|
+
stderr: str = ""
|
|
59
|
+
error: str | None = None
|
|
60
|
+
timed_out: bool = False
|
|
61
|
+
denied: bool = False
|
|
62
|
+
approval_required: bool = False
|
|
63
|
+
pid: int | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CommandAnalysis:
|
|
68
|
+
"""Analysis of a shell command for safety."""
|
|
69
|
+
ok: bool
|
|
70
|
+
reason: str | None = None
|
|
71
|
+
executable: str | None = None
|
|
72
|
+
resolved_path: str | None = None
|
|
73
|
+
args: list[str] = field(default_factory=list)
|
|
74
|
+
is_pipeline: bool = False
|
|
75
|
+
segments: list[str] = field(default_factory=list)
|
|
76
|
+
has_dangerous_chars: bool = False
|
|
77
|
+
is_safe_bin: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class PendingApproval:
|
|
82
|
+
"""A pending approval request."""
|
|
83
|
+
id: str
|
|
84
|
+
request: ExecRequest
|
|
85
|
+
created_at: float
|
|
86
|
+
expires_at: float
|
|
87
|
+
session_key: str | None = None
|
|
88
|
+
resolved_path: str | None = None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""HTTP API server for gateway integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from flowly_code.activity.bus import ActivityBus
|
|
12
|
+
|
|
13
|
+
# Maximum allowed request body size (1MB)
|
|
14
|
+
_MAX_BODY_SIZE = 1024 * 1024
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GatewayServer:
|
|
18
|
+
"""
|
|
19
|
+
HTTP API server for health check and integrations.
|
|
20
|
+
|
|
21
|
+
Provides endpoints for:
|
|
22
|
+
- Health check (GET /health)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
host: str = "127.0.0.1",
|
|
28
|
+
port: int = 18790,
|
|
29
|
+
activity_bus: ActivityBus | None = None,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Initialize the gateway server.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
host: Host to bind to.
|
|
36
|
+
port: Port to listen on.
|
|
37
|
+
activity_bus: Optional activity bus for real-time event streaming.
|
|
38
|
+
"""
|
|
39
|
+
self.host = host
|
|
40
|
+
self.port = port
|
|
41
|
+
self.activity_bus = activity_bus
|
|
42
|
+
self._app: web.Application | None = None
|
|
43
|
+
self._runner: web.AppRunner | None = None
|
|
44
|
+
self._site: web.TCPSite | None = None
|
|
45
|
+
|
|
46
|
+
def _create_app(self) -> web.Application:
|
|
47
|
+
"""Create the aiohttp application."""
|
|
48
|
+
app = web.Application(client_max_size=_MAX_BODY_SIZE)
|
|
49
|
+
app.router.add_get("/health", self._handle_health)
|
|
50
|
+
if self.activity_bus is not None:
|
|
51
|
+
app.router.add_get(
|
|
52
|
+
"/api/activity/stream", self._handle_activity_stream
|
|
53
|
+
)
|
|
54
|
+
return app
|
|
55
|
+
|
|
56
|
+
async def _handle_health(self, request: web.Request) -> web.Response:
|
|
57
|
+
"""Health check endpoint."""
|
|
58
|
+
return web.json_response({"status": "ok"})
|
|
59
|
+
|
|
60
|
+
async def _handle_activity_stream(
|
|
61
|
+
self, request: web.Request
|
|
62
|
+
) -> web.StreamResponse:
|
|
63
|
+
"""SSE endpoint for real-time agent activity events."""
|
|
64
|
+
response = web.StreamResponse(
|
|
65
|
+
status=200,
|
|
66
|
+
reason="OK",
|
|
67
|
+
headers={
|
|
68
|
+
"Content-Type": "text/event-stream",
|
|
69
|
+
"Cache-Control": "no-cache",
|
|
70
|
+
"Connection": "keep-alive",
|
|
71
|
+
"Access-Control-Allow-Origin": "http://127.0.0.1:7777",
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
await response.prepare(request)
|
|
75
|
+
|
|
76
|
+
sub_id, events = await self.activity_bus.subscribe()
|
|
77
|
+
try:
|
|
78
|
+
async for event in events:
|
|
79
|
+
data = json.dumps(event.to_dict())
|
|
80
|
+
await response.write(f"data: {data}\n\n".encode("utf-8"))
|
|
81
|
+
except (ConnectionResetError, asyncio.CancelledError):
|
|
82
|
+
pass
|
|
83
|
+
finally:
|
|
84
|
+
await self.activity_bus.unsubscribe(sub_id)
|
|
85
|
+
|
|
86
|
+
return response
|
|
87
|
+
|
|
88
|
+
async def start(self) -> None:
|
|
89
|
+
"""Start the HTTP server."""
|
|
90
|
+
self._app = self._create_app()
|
|
91
|
+
self._runner = web.AppRunner(self._app)
|
|
92
|
+
await self._runner.setup()
|
|
93
|
+
self._site = web.TCPSite(self._runner, self.host, self.port)
|
|
94
|
+
await self._site.start()
|
|
95
|
+
logger.info(f"Gateway API listening on http://{self.host}:{self.port}")
|
|
96
|
+
|
|
97
|
+
async def stop(self) -> None:
|
|
98
|
+
"""Stop the HTTP server."""
|
|
99
|
+
if self._site:
|
|
100
|
+
await self._site.stop()
|
|
101
|
+
if self._runner:
|
|
102
|
+
await self._runner.cleanup()
|
|
103
|
+
logger.info("Gateway API stopped")
|