apcore-cli 0.1.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.
- apcore_cli/__init__.py +3 -0
- apcore_cli/__main__.py +142 -0
- apcore_cli/_sandbox_runner.py +25 -0
- apcore_cli/approval.py +167 -0
- apcore_cli/cli.py +315 -0
- apcore_cli/config.py +94 -0
- apcore_cli/discovery.py +75 -0
- apcore_cli/output.py +190 -0
- apcore_cli/ref_resolver.py +113 -0
- apcore_cli/schema_parser.py +168 -0
- apcore_cli/security/__init__.py +8 -0
- apcore_cli/security/audit.py +53 -0
- apcore_cli/security/auth.py +37 -0
- apcore_cli/security/config_encryptor.py +94 -0
- apcore_cli/security/sandbox.py +60 -0
- apcore_cli/shell.py +185 -0
- apcore_cli-0.1.0.dist-info/METADATA +459 -0
- apcore_cli-0.1.0.dist-info/RECORD +20 -0
- apcore_cli-0.1.0.dist-info/WHEEL +4 -0
- apcore_cli-0.1.0.dist-info/entry_points.txt +2 -0
apcore_cli/__init__.py
ADDED
apcore_cli/__main__.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Entry point for apcore-cli (FE-01)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from apcore_cli import __version__
|
|
12
|
+
from apcore_cli.cli import LazyModuleGroup, set_audit_logger
|
|
13
|
+
from apcore_cli.config import ConfigResolver
|
|
14
|
+
from apcore_cli.discovery import register_discovery_commands
|
|
15
|
+
from apcore_cli.security.audit import AuditLogger
|
|
16
|
+
from apcore_cli.shell import register_shell_commands
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("apcore_cli")
|
|
19
|
+
|
|
20
|
+
EXIT_CONFIG_NOT_FOUND = 47
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _extract_extensions_dir(argv: list[str] | None = None) -> str | None:
|
|
24
|
+
"""Extract --extensions-dir value from argv before Click parses it.
|
|
25
|
+
|
|
26
|
+
This is needed because the registry must be created before Click runs,
|
|
27
|
+
but --extensions-dir is a Click option parsed at runtime.
|
|
28
|
+
Returns None if the flag is not present.
|
|
29
|
+
"""
|
|
30
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
31
|
+
for i, arg in enumerate(args):
|
|
32
|
+
if arg == "--extensions-dir" and i + 1 < len(args):
|
|
33
|
+
return args[i + 1]
|
|
34
|
+
if arg.startswith("--extensions-dir="):
|
|
35
|
+
return arg.split("=", 1)[1]
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_cli(extensions_dir: str | None = None) -> click.Group:
|
|
40
|
+
"""Create the CLI application.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
extensions_dir: Override for extensions directory.
|
|
44
|
+
When None, resolves via ConfigResolver (env/file/default).
|
|
45
|
+
"""
|
|
46
|
+
if extensions_dir is not None:
|
|
47
|
+
ext_dir = extensions_dir
|
|
48
|
+
else:
|
|
49
|
+
config = ConfigResolver()
|
|
50
|
+
ext_dir = config.resolve(
|
|
51
|
+
"extensions.root",
|
|
52
|
+
cli_flag="--extensions-dir",
|
|
53
|
+
env_var="APCORE_EXTENSIONS_ROOT",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
ext_dir_missing = not os.path.exists(ext_dir)
|
|
57
|
+
ext_dir_unreadable = not ext_dir_missing and not os.access(ext_dir, os.R_OK)
|
|
58
|
+
|
|
59
|
+
if ext_dir_missing:
|
|
60
|
+
click.echo(
|
|
61
|
+
f"Error: Extensions directory not found: '{ext_dir}'. " "Set APCORE_EXTENSIONS_ROOT or verify the path.",
|
|
62
|
+
err=True,
|
|
63
|
+
)
|
|
64
|
+
sys.exit(EXIT_CONFIG_NOT_FOUND)
|
|
65
|
+
|
|
66
|
+
if ext_dir_unreadable:
|
|
67
|
+
click.echo(
|
|
68
|
+
f"Error: Cannot read extensions directory: '{ext_dir}'. Check permissions.",
|
|
69
|
+
err=True,
|
|
70
|
+
)
|
|
71
|
+
sys.exit(EXIT_CONFIG_NOT_FOUND)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
from apcore import Executor, Registry
|
|
75
|
+
|
|
76
|
+
registry = Registry(extensions_dir=ext_dir)
|
|
77
|
+
try:
|
|
78
|
+
logger.debug("Loading extensions from %s", ext_dir)
|
|
79
|
+
count = registry.discover()
|
|
80
|
+
logger.info("Initialized apcore-cli with %d modules.", count)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.warning("Discovery failed: %s", e)
|
|
83
|
+
|
|
84
|
+
executor = Executor(registry)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
click.echo(f"Error: Failed to initialize registry: {e}", err=True)
|
|
87
|
+
sys.exit(EXIT_CONFIG_NOT_FOUND)
|
|
88
|
+
|
|
89
|
+
# Initialize audit logger
|
|
90
|
+
try:
|
|
91
|
+
audit_logger = AuditLogger()
|
|
92
|
+
set_audit_logger(audit_logger)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.warning("Failed to initialize audit logger: %s", e)
|
|
95
|
+
|
|
96
|
+
@click.group(
|
|
97
|
+
cls=LazyModuleGroup,
|
|
98
|
+
registry=registry,
|
|
99
|
+
executor=executor,
|
|
100
|
+
name="apcore-cli",
|
|
101
|
+
help="CLI adapter for the apcore module ecosystem.",
|
|
102
|
+
)
|
|
103
|
+
@click.version_option(
|
|
104
|
+
version=__version__,
|
|
105
|
+
prog_name="apcore-cli",
|
|
106
|
+
)
|
|
107
|
+
@click.option(
|
|
108
|
+
"--extensions-dir",
|
|
109
|
+
"extensions_dir_opt",
|
|
110
|
+
default=None,
|
|
111
|
+
help="Path to apcore extensions directory.",
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--log-level",
|
|
115
|
+
default=None,
|
|
116
|
+
type=click.Choice(["DEBUG", "INFO", "WARN", "ERROR"], case_sensitive=False),
|
|
117
|
+
help="Log level.",
|
|
118
|
+
)
|
|
119
|
+
@click.pass_context
|
|
120
|
+
def cli(ctx: click.Context, extensions_dir_opt: str | None = None, log_level: str | None = None) -> None:
|
|
121
|
+
ctx.ensure_object(dict)
|
|
122
|
+
ctx.obj["extensions_dir"] = ext_dir
|
|
123
|
+
|
|
124
|
+
# Register discovery commands
|
|
125
|
+
register_discovery_commands(cli, registry)
|
|
126
|
+
|
|
127
|
+
# Register shell integration commands
|
|
128
|
+
register_shell_commands(cli)
|
|
129
|
+
|
|
130
|
+
return cli
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main() -> None:
|
|
134
|
+
"""Main entry point for apcore-cli."""
|
|
135
|
+
# Extract --extensions-dir from argv before Click parses
|
|
136
|
+
ext_dir = _extract_extensions_dir()
|
|
137
|
+
cli = create_cli(extensions_dir=ext_dir)
|
|
138
|
+
cli(standalone_mode=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Entry point for sandboxed module execution (FE-05)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
module_id = sys.argv[1]
|
|
12
|
+
input_data = json.loads(sys.stdin.read())
|
|
13
|
+
extensions_root = os.environ.get("APCORE_EXTENSIONS_ROOT", "./extensions")
|
|
14
|
+
|
|
15
|
+
from apcore import Executor, Registry
|
|
16
|
+
|
|
17
|
+
registry = Registry(extensions_dir=extensions_root)
|
|
18
|
+
registry.discover()
|
|
19
|
+
executor = Executor(registry)
|
|
20
|
+
result = executor.call(module_id, input_data)
|
|
21
|
+
json.dump(result, sys.stdout)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
apcore_cli/approval.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Approval Gate — TTY-aware HITL approval (FE-03)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("apcore_cli.approval")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApprovalTimeoutError(Exception):
|
|
17
|
+
"""Raised when the approval prompt times out."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_annotation(annotations: Any, key: str, default: Any = None) -> Any:
|
|
23
|
+
"""Get an annotation value from either a dict or a ModuleAnnotations object."""
|
|
24
|
+
if isinstance(annotations, dict):
|
|
25
|
+
return annotations.get(key, default)
|
|
26
|
+
return getattr(annotations, key, default)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_approval(module_def: Any, auto_approve: bool, ctx: click.Context) -> None:
|
|
30
|
+
"""Check if module requires approval and handle accordingly.
|
|
31
|
+
|
|
32
|
+
Returns None if approved (or approval not required).
|
|
33
|
+
Calls sys.exit(46) if denied/timed out/pending.
|
|
34
|
+
"""
|
|
35
|
+
annotations = getattr(module_def, "annotations", None)
|
|
36
|
+
if annotations is None or (not isinstance(annotations, dict) and not hasattr(annotations, "requires_approval")):
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
requires = _get_annotation(annotations, "requires_approval", False)
|
|
40
|
+
if requires is not True:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
module_id = getattr(module_def, "module_id", getattr(module_def, "canonical_id", "unknown"))
|
|
44
|
+
|
|
45
|
+
# Bypass: --yes flag (highest priority)
|
|
46
|
+
if auto_approve is True:
|
|
47
|
+
logger.info("Approval bypassed via --yes flag for module '%s'.", module_id)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Bypass: APCORE_CLI_AUTO_APPROVE env var
|
|
51
|
+
env_val = os.environ.get("APCORE_CLI_AUTO_APPROVE", "")
|
|
52
|
+
if env_val == "1":
|
|
53
|
+
logger.info(
|
|
54
|
+
"Approval bypassed via APCORE_CLI_AUTO_APPROVE for module '%s'.",
|
|
55
|
+
module_id,
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
if env_val != "" and env_val != "1":
|
|
59
|
+
logger.warning(
|
|
60
|
+
"APCORE_CLI_AUTO_APPROVE is set to '%s', expected '1'. Ignoring.",
|
|
61
|
+
env_val,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Non-TTY check
|
|
65
|
+
is_tty = sys.stdin.isatty()
|
|
66
|
+
if not is_tty:
|
|
67
|
+
click.echo(
|
|
68
|
+
f"Error: Module '{module_id}' requires approval but no interactive "
|
|
69
|
+
"terminal is available. Use --yes or set APCORE_CLI_AUTO_APPROVE=1 "
|
|
70
|
+
"to bypass.",
|
|
71
|
+
err=True,
|
|
72
|
+
)
|
|
73
|
+
logger.error(
|
|
74
|
+
"Non-interactive environment, no bypass provided for module '%s'.",
|
|
75
|
+
module_id,
|
|
76
|
+
)
|
|
77
|
+
sys.exit(46)
|
|
78
|
+
|
|
79
|
+
# TTY prompt
|
|
80
|
+
_prompt_with_timeout(module_def, timeout=60)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _prompt_with_timeout(module_def: Any, timeout: int = 60) -> None:
|
|
84
|
+
"""Display approval prompt with timeout."""
|
|
85
|
+
# Clamp timeout to valid range
|
|
86
|
+
timeout = max(1, min(timeout, 3600))
|
|
87
|
+
|
|
88
|
+
module_id = getattr(module_def, "module_id", getattr(module_def, "canonical_id", "unknown"))
|
|
89
|
+
annotations = getattr(module_def, "annotations", None) or {}
|
|
90
|
+
message = _get_annotation(annotations, "approval_message", None)
|
|
91
|
+
if message is None:
|
|
92
|
+
message = f"Module '{module_id}' requires approval to execute."
|
|
93
|
+
|
|
94
|
+
click.echo(message, err=True)
|
|
95
|
+
|
|
96
|
+
if sys.platform != "win32":
|
|
97
|
+
# Unix: use SIGALRM for timeout
|
|
98
|
+
_prompt_unix(module_id, timeout)
|
|
99
|
+
else:
|
|
100
|
+
# Windows: use threading.Timer for timeout
|
|
101
|
+
_prompt_windows(module_id, timeout)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _prompt_unix(module_id: str, timeout: int) -> None:
|
|
105
|
+
"""Unix approval prompt using SIGALRM."""
|
|
106
|
+
import signal
|
|
107
|
+
|
|
108
|
+
def _timeout_handler(signum: int, frame: Any) -> None:
|
|
109
|
+
raise ApprovalTimeoutError()
|
|
110
|
+
|
|
111
|
+
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
|
|
112
|
+
signal.alarm(timeout)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
approved = click.confirm("Proceed?", default=False)
|
|
116
|
+
signal.alarm(0)
|
|
117
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
118
|
+
|
|
119
|
+
if approved:
|
|
120
|
+
logger.info("User approved execution of module '%s'.", module_id)
|
|
121
|
+
return
|
|
122
|
+
else:
|
|
123
|
+
logger.warning("Approval rejected by user for module '%s'.", module_id)
|
|
124
|
+
click.echo("Error: Approval denied.", err=True)
|
|
125
|
+
sys.exit(46)
|
|
126
|
+
except ApprovalTimeoutError:
|
|
127
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
128
|
+
logger.warning("Approval timed out after %ds for module '%s'.", timeout, module_id)
|
|
129
|
+
click.echo(
|
|
130
|
+
f"Error: Approval prompt timed out after {timeout} seconds.",
|
|
131
|
+
err=True,
|
|
132
|
+
)
|
|
133
|
+
sys.exit(46)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _prompt_windows(module_id: str, timeout: int) -> None:
|
|
137
|
+
"""Windows approval prompt using threading.Timer + ctypes."""
|
|
138
|
+
import ctypes
|
|
139
|
+
|
|
140
|
+
def _interrupt_main() -> None:
|
|
141
|
+
ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
|
142
|
+
ctypes.c_ulong(threading.main_thread().ident),
|
|
143
|
+
ctypes.py_object(ApprovalTimeoutError),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
timer = threading.Timer(timeout, _interrupt_main)
|
|
147
|
+
timer.start()
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
approved = click.confirm("Proceed?", default=False)
|
|
151
|
+
timer.cancel()
|
|
152
|
+
|
|
153
|
+
if approved:
|
|
154
|
+
logger.info("User approved execution of module '%s'.", module_id)
|
|
155
|
+
return
|
|
156
|
+
else:
|
|
157
|
+
logger.warning("Approval rejected by user for module '%s'.", module_id)
|
|
158
|
+
click.echo("Error: Approval denied.", err=True)
|
|
159
|
+
sys.exit(46)
|
|
160
|
+
except ApprovalTimeoutError:
|
|
161
|
+
timer.cancel()
|
|
162
|
+
logger.warning("Approval timed out after %ds for module '%s'.", timeout, module_id)
|
|
163
|
+
click.echo(
|
|
164
|
+
f"Error: Approval prompt timed out after {timeout} seconds.",
|
|
165
|
+
err=True,
|
|
166
|
+
)
|
|
167
|
+
sys.exit(46)
|
apcore_cli/cli.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Core Dispatcher — CLI entry point and module routing (FE-01)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import jsonschema
|
|
14
|
+
|
|
15
|
+
from apcore_cli.approval import check_approval
|
|
16
|
+
from apcore_cli.output import format_exec_result
|
|
17
|
+
from apcore_cli.ref_resolver import resolve_refs
|
|
18
|
+
from apcore_cli.schema_parser import reconvert_enum_values, schema_to_click_options
|
|
19
|
+
from apcore_cli.security.sandbox import Sandbox
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from apcore import Executor, Registry
|
|
23
|
+
from apcore.registry.types import ModuleDescriptor
|
|
24
|
+
|
|
25
|
+
from apcore_cli.security.audit import AuditLogger
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("apcore_cli.cli")
|
|
28
|
+
|
|
29
|
+
BUILTIN_COMMANDS = ["exec", "list", "describe", "completion", "man"]
|
|
30
|
+
|
|
31
|
+
# Module-level audit logger, set during CLI init
|
|
32
|
+
_audit_logger: AuditLogger | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_audit_logger(audit_logger: AuditLogger) -> None:
|
|
36
|
+
"""Set the global audit logger instance."""
|
|
37
|
+
global _audit_logger
|
|
38
|
+
_audit_logger = audit_logger
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LazyModuleGroup(click.Group):
|
|
42
|
+
"""Custom Click Group that lazily loads apcore modules as subcommands."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, registry: Registry, executor: Executor, **kwargs: Any) -> None:
|
|
45
|
+
super().__init__(**kwargs)
|
|
46
|
+
self._registry = registry
|
|
47
|
+
self._executor = executor
|
|
48
|
+
self._module_cache: dict[str, click.Command] = {}
|
|
49
|
+
|
|
50
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
51
|
+
builtin = list(BUILTIN_COMMANDS)
|
|
52
|
+
try:
|
|
53
|
+
module_ids = self._registry.list()
|
|
54
|
+
except Exception:
|
|
55
|
+
logger.warning("Failed to list modules from registry")
|
|
56
|
+
module_ids = []
|
|
57
|
+
return sorted(set(builtin + module_ids))
|
|
58
|
+
|
|
59
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
60
|
+
# Check built-in commands first
|
|
61
|
+
if cmd_name in self.commands:
|
|
62
|
+
return self.commands[cmd_name]
|
|
63
|
+
|
|
64
|
+
# Check cache
|
|
65
|
+
if cmd_name in self._module_cache:
|
|
66
|
+
return self._module_cache[cmd_name]
|
|
67
|
+
|
|
68
|
+
# Look up in registry
|
|
69
|
+
module_def = self._registry.get_definition(cmd_name)
|
|
70
|
+
if module_def is None:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
cmd = build_module_command(module_def, self._executor)
|
|
74
|
+
self._module_cache[cmd_name] = cmd
|
|
75
|
+
return cmd
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Error code mapping from apcore error codes to CLI exit codes
|
|
79
|
+
_ERROR_CODE_MAP = {
|
|
80
|
+
"MODULE_NOT_FOUND": 44,
|
|
81
|
+
"MODULE_LOAD_ERROR": 44,
|
|
82
|
+
"MODULE_DISABLED": 44,
|
|
83
|
+
"SCHEMA_VALIDATION_ERROR": 45,
|
|
84
|
+
"SCHEMA_CIRCULAR_REF": 48,
|
|
85
|
+
"APPROVAL_DENIED": 46,
|
|
86
|
+
"APPROVAL_TIMEOUT": 46,
|
|
87
|
+
"APPROVAL_PENDING": 46,
|
|
88
|
+
"CONFIG_NOT_FOUND": 47,
|
|
89
|
+
"CONFIG_INVALID": 47,
|
|
90
|
+
"MODULE_EXECUTE_ERROR": 1,
|
|
91
|
+
"MODULE_TIMEOUT": 1,
|
|
92
|
+
"ACL_DENIED": 77,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_module_id(module_def: ModuleDescriptor) -> str:
|
|
97
|
+
"""Get the canonical module ID, falling back to module_id."""
|
|
98
|
+
cid = getattr(module_def, "canonical_id", None)
|
|
99
|
+
if isinstance(cid, str):
|
|
100
|
+
return cid
|
|
101
|
+
return module_def.module_id
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_module_command(module_def: ModuleDescriptor, executor: Executor) -> click.Command:
|
|
105
|
+
"""Build a Click command from an apcore module definition.
|
|
106
|
+
|
|
107
|
+
Generates Click options from the module's input_schema, wires up
|
|
108
|
+
STDIN input collection, schema validation, approval gating,
|
|
109
|
+
execution, audit logging, and output formatting.
|
|
110
|
+
"""
|
|
111
|
+
# Resolve $refs and generate Click options from input_schema
|
|
112
|
+
raw_schema = getattr(module_def, "input_schema", None)
|
|
113
|
+
module_id = _get_module_id(module_def)
|
|
114
|
+
|
|
115
|
+
# Defensively convert Pydantic model class to dict
|
|
116
|
+
if raw_schema is None:
|
|
117
|
+
input_schema: dict = {}
|
|
118
|
+
elif isinstance(raw_schema, dict):
|
|
119
|
+
input_schema = raw_schema
|
|
120
|
+
elif hasattr(raw_schema, "model_json_schema"):
|
|
121
|
+
# Pydantic v2 BaseModel class
|
|
122
|
+
input_schema = raw_schema.model_json_schema()
|
|
123
|
+
elif hasattr(raw_schema, "schema"):
|
|
124
|
+
# Pydantic v1 BaseModel class
|
|
125
|
+
input_schema = raw_schema.schema()
|
|
126
|
+
else:
|
|
127
|
+
input_schema = {}
|
|
128
|
+
|
|
129
|
+
if input_schema.get("properties"):
|
|
130
|
+
try:
|
|
131
|
+
resolved_schema = resolve_refs(input_schema, max_depth=32, module_id=module_id)
|
|
132
|
+
except SystemExit:
|
|
133
|
+
raise
|
|
134
|
+
except Exception:
|
|
135
|
+
resolved_schema = input_schema
|
|
136
|
+
else:
|
|
137
|
+
resolved_schema = input_schema
|
|
138
|
+
|
|
139
|
+
schema_options = schema_to_click_options(resolved_schema)
|
|
140
|
+
|
|
141
|
+
def callback(**kwargs: Any) -> None:
|
|
142
|
+
# Separate built-in options from schema-generated kwargs
|
|
143
|
+
stdin_input = kwargs.pop("input", None)
|
|
144
|
+
auto_approve = kwargs.pop("yes", False)
|
|
145
|
+
large_input = kwargs.pop("large_input", False)
|
|
146
|
+
output_format = kwargs.pop("format", None)
|
|
147
|
+
sandbox_flag = kwargs.pop("sandbox", False)
|
|
148
|
+
|
|
149
|
+
merged: dict[str, Any] = {}
|
|
150
|
+
try:
|
|
151
|
+
# 1. Collect and merge input (STDIN + CLI flags)
|
|
152
|
+
merged = collect_input(stdin_input, kwargs, large_input)
|
|
153
|
+
|
|
154
|
+
# 2. Reconvert enum values to original types
|
|
155
|
+
merged = reconvert_enum_values(merged, schema_options)
|
|
156
|
+
|
|
157
|
+
# 3. Validate against schema (if schema has properties)
|
|
158
|
+
if resolved_schema.get("properties"):
|
|
159
|
+
try:
|
|
160
|
+
jsonschema.validate(merged, resolved_schema)
|
|
161
|
+
except jsonschema.ValidationError as ve:
|
|
162
|
+
click.echo(
|
|
163
|
+
f"Error: Validation failed for '{ve.path}': {ve.message}.",
|
|
164
|
+
err=True,
|
|
165
|
+
)
|
|
166
|
+
sys.exit(45)
|
|
167
|
+
|
|
168
|
+
# 4. Check approval gate
|
|
169
|
+
check_approval(module_def, auto_approve, click.get_current_context())
|
|
170
|
+
|
|
171
|
+
# 5. Execute with timing (optionally sandboxed)
|
|
172
|
+
audit_start = time.monotonic()
|
|
173
|
+
sandbox = Sandbox(enabled=sandbox_flag)
|
|
174
|
+
result = sandbox.execute(module_id, merged, executor)
|
|
175
|
+
duration_ms = int((time.monotonic() - audit_start) * 1000)
|
|
176
|
+
|
|
177
|
+
# 6. Audit log (success)
|
|
178
|
+
if _audit_logger is not None:
|
|
179
|
+
_audit_logger.log_execution(module_id, merged, "success", 0, duration_ms)
|
|
180
|
+
|
|
181
|
+
# 7. Format and print result
|
|
182
|
+
format_exec_result(result, output_format)
|
|
183
|
+
|
|
184
|
+
except KeyboardInterrupt:
|
|
185
|
+
click.echo("Execution cancelled.", err=True)
|
|
186
|
+
sys.exit(130)
|
|
187
|
+
except SystemExit:
|
|
188
|
+
raise
|
|
189
|
+
except Exception as e:
|
|
190
|
+
error_code = getattr(e, "code", None)
|
|
191
|
+
exit_code = _ERROR_CODE_MAP.get(error_code, 1)
|
|
192
|
+
|
|
193
|
+
# Audit log (error)
|
|
194
|
+
if _audit_logger is not None:
|
|
195
|
+
_audit_logger.log_execution(module_id, merged, "error", exit_code, 0)
|
|
196
|
+
|
|
197
|
+
click.echo(f"Error: {e}", err=True)
|
|
198
|
+
sys.exit(exit_code)
|
|
199
|
+
|
|
200
|
+
# Build the command with schema-generated options + built-in options
|
|
201
|
+
cmd = click.Command(
|
|
202
|
+
name=module_id,
|
|
203
|
+
help=module_def.description,
|
|
204
|
+
callback=callback,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Add built-in options
|
|
208
|
+
cmd.params.append(
|
|
209
|
+
click.Option(
|
|
210
|
+
["--input"],
|
|
211
|
+
default=None,
|
|
212
|
+
help="Read input from file or STDIN ('-').",
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
cmd.params.append(
|
|
216
|
+
click.Option(
|
|
217
|
+
["--yes", "-y"],
|
|
218
|
+
is_flag=True,
|
|
219
|
+
default=False,
|
|
220
|
+
help="Bypass approval prompts.",
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
cmd.params.append(
|
|
224
|
+
click.Option(
|
|
225
|
+
["--large-input"],
|
|
226
|
+
is_flag=True,
|
|
227
|
+
default=False,
|
|
228
|
+
help="Allow STDIN input larger than 10MB.",
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
cmd.params.append(
|
|
232
|
+
click.Option(
|
|
233
|
+
["--format"],
|
|
234
|
+
type=click.Choice(["json", "table"]),
|
|
235
|
+
default=None,
|
|
236
|
+
help="Output format.",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
cmd.params.append(
|
|
240
|
+
click.Option(
|
|
241
|
+
["--sandbox"],
|
|
242
|
+
is_flag=True,
|
|
243
|
+
default=False,
|
|
244
|
+
help="Run module in subprocess sandbox.",
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Add schema-generated options
|
|
249
|
+
cmd.params.extend(schema_options)
|
|
250
|
+
|
|
251
|
+
return cmd
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def validate_module_id(module_id: str) -> None:
|
|
255
|
+
"""Validate module ID format and length."""
|
|
256
|
+
if len(module_id) > 128:
|
|
257
|
+
click.echo(
|
|
258
|
+
f"Error: Invalid module ID format: '{module_id}'. Maximum length is 128 characters.",
|
|
259
|
+
err=True,
|
|
260
|
+
)
|
|
261
|
+
sys.exit(2)
|
|
262
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*", module_id):
|
|
263
|
+
click.echo(
|
|
264
|
+
f"Error: Invalid module ID format: '{module_id}'.",
|
|
265
|
+
err=True,
|
|
266
|
+
)
|
|
267
|
+
sys.exit(2)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def collect_input(
|
|
271
|
+
stdin_flag: str | None,
|
|
272
|
+
cli_kwargs: dict[str, Any],
|
|
273
|
+
large_input: bool = False,
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
"""Collect and merge input from STDIN and CLI flags."""
|
|
276
|
+
# Remove None values from CLI kwargs
|
|
277
|
+
cli_kwargs_non_none = {k: v for k, v in cli_kwargs.items() if v is not None}
|
|
278
|
+
|
|
279
|
+
if not stdin_flag:
|
|
280
|
+
return cli_kwargs_non_none
|
|
281
|
+
|
|
282
|
+
if stdin_flag == "-":
|
|
283
|
+
raw = sys.stdin.read()
|
|
284
|
+
raw_size = len(raw.encode("utf-8"))
|
|
285
|
+
|
|
286
|
+
if raw_size > 10_485_760 and not large_input:
|
|
287
|
+
click.echo(
|
|
288
|
+
"Error: STDIN input exceeds 10MB limit. Use --large-input to override.",
|
|
289
|
+
err=True,
|
|
290
|
+
)
|
|
291
|
+
sys.exit(2)
|
|
292
|
+
|
|
293
|
+
if not raw or raw_size == 0:
|
|
294
|
+
stdin_data: dict[str, Any] = {}
|
|
295
|
+
else:
|
|
296
|
+
try:
|
|
297
|
+
stdin_data = json.loads(raw)
|
|
298
|
+
except json.JSONDecodeError as e:
|
|
299
|
+
click.echo(
|
|
300
|
+
f"Error: STDIN does not contain valid JSON: {e.msg}.",
|
|
301
|
+
err=True,
|
|
302
|
+
)
|
|
303
|
+
sys.exit(2)
|
|
304
|
+
|
|
305
|
+
if not isinstance(stdin_data, dict):
|
|
306
|
+
click.echo(
|
|
307
|
+
f"Error: STDIN JSON must be an object, got {type(stdin_data).__name__}.",
|
|
308
|
+
err=True,
|
|
309
|
+
)
|
|
310
|
+
sys.exit(2)
|
|
311
|
+
|
|
312
|
+
# CLI flags override STDIN for duplicate keys
|
|
313
|
+
return {**stdin_data, **cli_kwargs_non_none}
|
|
314
|
+
|
|
315
|
+
return cli_kwargs_non_none
|