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.
@@ -0,0 +1,53 @@
1
+ """Audit logging in JSON Lines format (FE-05)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import os
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ logger = logging.getLogger("apcore_cli.security")
14
+
15
+
16
+ class AuditLogger:
17
+ DEFAULT_PATH = Path.home() / ".apcore-cli" / "audit.jsonl"
18
+
19
+ def __init__(self, path: Path | None = None) -> None:
20
+ self._path = path or self.DEFAULT_PATH
21
+ self._ensure_directory()
22
+
23
+ def _ensure_directory(self) -> None:
24
+ self._path.parent.mkdir(parents=True, exist_ok=True)
25
+
26
+ def log_execution(
27
+ self,
28
+ module_id: str,
29
+ input_data: dict,
30
+ status: Literal["success", "error"],
31
+ exit_code: int,
32
+ duration_ms: int,
33
+ ) -> None:
34
+ entry = {
35
+ "timestamp": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
36
+ "user": self._get_user(),
37
+ "module_id": module_id,
38
+ "input_hash": hashlib.sha256(json.dumps(input_data, sort_keys=True).encode()).hexdigest(),
39
+ "status": status,
40
+ "exit_code": exit_code,
41
+ "duration_ms": duration_ms,
42
+ }
43
+ try:
44
+ with open(self._path, "a") as f:
45
+ f.write(json.dumps(entry) + "\n")
46
+ except OSError as e:
47
+ logger.warning("Could not write audit log: %s", e)
48
+
49
+ def _get_user(self) -> str:
50
+ try:
51
+ return os.getlogin()
52
+ except OSError:
53
+ return os.getenv("USER", os.getenv("USERNAME", "unknown"))
@@ -0,0 +1,37 @@
1
+ """API key authentication (FE-05)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from apcore_cli.config import ConfigResolver
9
+
10
+
11
+ class AuthenticationError(Exception):
12
+ pass
13
+
14
+
15
+ class AuthProvider:
16
+ def __init__(self, config: ConfigResolver) -> None:
17
+ self._config = config
18
+
19
+ def get_api_key(self) -> str | None:
20
+ result = self._config.resolve("auth.api_key", cli_flag="--api-key", env_var="APCORE_AUTH_API_KEY")
21
+ if result is not None and (result.startswith("keyring:") or result.startswith("enc:")):
22
+ result = self._config.encryptor.retrieve(result, "auth.api_key")
23
+ return result
24
+
25
+ def authenticate_request(self, headers: dict) -> dict:
26
+ key = self.get_api_key()
27
+ if key is None:
28
+ raise AuthenticationError(
29
+ "Remote registry requires authentication. "
30
+ "Set --api-key, APCORE_AUTH_API_KEY, or auth.api_key in config."
31
+ )
32
+ headers["Authorization"] = f"Bearer {key}"
33
+ return headers
34
+
35
+ def handle_response(self, status_code: int) -> None:
36
+ if status_code in (401, 403):
37
+ raise AuthenticationError("Authentication failed. Verify your API key.")
@@ -0,0 +1,94 @@
1
+ """Encrypted configuration storage (FE-05)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import logging
8
+ import os
9
+ import socket
10
+
11
+ from cryptography.exceptions import InvalidTag
12
+
13
+ logger = logging.getLogger("apcore_cli.security")
14
+
15
+
16
+ class ConfigDecryptionError(Exception):
17
+ pass
18
+
19
+
20
+ class ConfigEncryptor:
21
+ SERVICE_NAME = "apcore-cli"
22
+
23
+ def store(self, key: str, value: str) -> str:
24
+ if self._keyring_available():
25
+ import keyring as kr
26
+
27
+ kr.set_password(self.SERVICE_NAME, key, value)
28
+ return f"keyring:{key}"
29
+ else:
30
+ logger.warning("OS keyring unavailable. Using file-based encryption.")
31
+ ciphertext = self._aes_encrypt(value)
32
+ return f"enc:{base64.b64encode(ciphertext).decode()}"
33
+
34
+ def retrieve(self, config_value: str, key: str) -> str:
35
+ if config_value.startswith("keyring:"):
36
+ import keyring as kr
37
+
38
+ ref_key = config_value[len("keyring:") :]
39
+ result = kr.get_password(self.SERVICE_NAME, ref_key)
40
+ if result is None:
41
+ raise ConfigDecryptionError(f"Keyring entry not found for '{ref_key}'.")
42
+ return result
43
+ elif config_value.startswith("enc:"):
44
+ ciphertext = base64.b64decode(config_value[len("enc:") :])
45
+ try:
46
+ return self._aes_decrypt(ciphertext)
47
+ except (InvalidTag, ValueError) as exc:
48
+ raise ConfigDecryptionError(
49
+ f"Failed to decrypt configuration value '{key}'. Re-configure with 'apcore-cli config set {key}'."
50
+ ) from exc
51
+ else:
52
+ return config_value
53
+
54
+ def _keyring_available(self) -> bool:
55
+ try:
56
+ import keyring as kr
57
+
58
+ backend = kr.get_keyring()
59
+ return not (
60
+ hasattr(kr, "backends")
61
+ and hasattr(kr.backends, "fail")
62
+ and isinstance(backend, kr.backends.fail.Keyring)
63
+ )
64
+ except Exception:
65
+ return False
66
+
67
+ def _derive_key(self) -> bytes:
68
+ hostname = socket.gethostname()
69
+ username = os.getenv("USER", os.getenv("USERNAME", "unknown"))
70
+ salt = b"apcore-cli-config-v1"
71
+ material = f"{hostname}:{username}".encode()
72
+ return hashlib.pbkdf2_hmac("sha256", material, salt, iterations=100_000)
73
+
74
+ def _aes_encrypt(self, plaintext: str) -> bytes:
75
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
76
+
77
+ key = self._derive_key()
78
+ nonce = os.urandom(12)
79
+ encryptor = Cipher(algorithms.AES(key), modes.GCM(nonce)).encryptor()
80
+ ct = encryptor.update(plaintext.encode("utf-8")) + encryptor.finalize()
81
+ tag = encryptor.tag
82
+ # Wire format: nonce(12) + tag(16) + ciphertext
83
+ return nonce + tag + ct
84
+
85
+ def _aes_decrypt(self, data: bytes) -> str:
86
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
87
+
88
+ key = self._derive_key()
89
+ nonce = data[:12]
90
+ tag = data[12:28]
91
+ ct = data[28:]
92
+ decryptor = Cipher(algorithms.AES(key), modes.GCM(nonce, tag)).decryptor()
93
+ plaintext = decryptor.update(ct) + decryptor.finalize()
94
+ return plaintext.decode("utf-8")
@@ -0,0 +1,60 @@
1
+ """Subprocess-based execution sandboxing (FE-05)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from apcore import Executor
14
+
15
+
16
+ class ModuleExecutionError(Exception):
17
+ """Raised when a sandboxed module execution fails."""
18
+
19
+ pass
20
+
21
+
22
+ class Sandbox:
23
+ def __init__(self, enabled: bool = False) -> None:
24
+ self._enabled = enabled
25
+
26
+ def execute(self, module_id: str, input_data: dict, executor: Executor) -> Any:
27
+ if not self._enabled:
28
+ return executor.call(module_id, input_data)
29
+ return self._sandboxed_execute(module_id, input_data)
30
+
31
+ def _sandboxed_execute(self, module_id: str, input_data: dict) -> Any:
32
+ env = {}
33
+ for key in ("PATH", "PYTHONPATH", "LANG", "LC_ALL"):
34
+ if key in os.environ:
35
+ env[key] = os.environ[key]
36
+ for key, value in os.environ.items():
37
+ if key.startswith("APCORE_"):
38
+ env[key] = value
39
+
40
+ with tempfile.TemporaryDirectory(prefix="apcore_sandbox_") as tmpdir:
41
+ env["HOME"] = tmpdir
42
+ env["TMPDIR"] = tmpdir
43
+
44
+ try:
45
+ result = subprocess.run(
46
+ [sys.executable, "-m", "apcore_cli._sandbox_runner", module_id],
47
+ input=json.dumps(input_data),
48
+ capture_output=True,
49
+ text=True,
50
+ env=env,
51
+ cwd=tmpdir,
52
+ timeout=300,
53
+ )
54
+ except subprocess.TimeoutExpired as err:
55
+ raise ModuleExecutionError(f"Error: Module '{module_id}' timed out in sandbox.") from err
56
+
57
+ if result.returncode != 0:
58
+ raise ModuleExecutionError(f"Error: Module '{module_id}' execution failed: {result.stderr}")
59
+
60
+ return json.loads(result.stdout)
apcore_cli/shell.py ADDED
@@ -0,0 +1,185 @@
1
+ """Shell Integration — completion scripts and man pages (FE-06)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import date
7
+
8
+ import click
9
+
10
+ from apcore_cli import __version__
11
+
12
+ # Shell snippet for dynamic module ID completion (shared across shells)
13
+ _MODULE_LIST_CMD = (
14
+ "apcore-cli list --format json 2>/dev/null"
15
+ ' | python3 -c "import sys,json;'
16
+ "[print(m['id']) for m in json.load(sys.stdin)]\" 2>/dev/null"
17
+ )
18
+
19
+
20
+ def _generate_bash_completion() -> str:
21
+ return (
22
+ "_apcore_cli_completion() {\n"
23
+ " local cur prev opts\n"
24
+ " COMPREPLY=()\n"
25
+ ' cur="${COMP_WORDS[COMP_CWORD]}"\n'
26
+ ' prev="${COMP_WORDS[COMP_CWORD-1]}"\n'
27
+ "\n"
28
+ " if [[ ${COMP_CWORD} -eq 1 ]]; then\n"
29
+ ' opts="exec list describe completion man"\n'
30
+ ' COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )\n'
31
+ " return 0\n"
32
+ " fi\n"
33
+ "\n"
34
+ ' if [[ "${COMP_WORDS[1]}" == "exec" && ${COMP_CWORD} -eq 2 ]]; then\n'
35
+ f" local modules=$({_MODULE_LIST_CMD})\n"
36
+ ' COMPREPLY=( $(compgen -W "${modules}" -- ${cur}) )\n'
37
+ " return 0\n"
38
+ " fi\n"
39
+ "}\n"
40
+ "complete -F _apcore_cli_completion apcore-cli\n"
41
+ )
42
+
43
+
44
+ def _generate_zsh_completion() -> str:
45
+ return (
46
+ "#compdef apcore-cli\n"
47
+ "\n"
48
+ "_apcore_cli() {\n"
49
+ " local -a commands\n"
50
+ " commands=(\n"
51
+ " 'exec:Execute an apcore module'\n"
52
+ " 'list:List available modules'\n"
53
+ " 'describe:Show module details'\n"
54
+ " 'completion:Generate shell completion script'\n"
55
+ " 'man:Generate man page'\n"
56
+ " )\n"
57
+ "\n"
58
+ " _arguments -C \\\n"
59
+ " '1:command:->command' \\\n"
60
+ " '*::arg:->args'\n"
61
+ "\n"
62
+ ' case "$state" in\n'
63
+ " command)\n"
64
+ " _describe -t commands 'apcore-cli commands' commands\n"
65
+ " ;;\n"
66
+ " args)\n"
67
+ ' case "${words[1]}" in\n'
68
+ " exec)\n"
69
+ " local modules\n"
70
+ f" modules=($({_MODULE_LIST_CMD}))\n"
71
+ " compadd -a modules\n"
72
+ " ;;\n"
73
+ " esac\n"
74
+ " ;;\n"
75
+ " esac\n"
76
+ "}\n"
77
+ "\n"
78
+ "compdef _apcore_cli apcore-cli\n"
79
+ )
80
+
81
+
82
+ def _generate_fish_completion() -> str:
83
+ # Build the dynamic completion command for fish (needs escaped quotes)
84
+ fish_dyn = _MODULE_LIST_CMD.replace('"', '\\"').replace("'", "\\'")
85
+ return (
86
+ "# Fish completions for apcore-cli\n"
87
+ 'complete -c apcore-cli -n "__fish_use_subcommand"'
88
+ ' -a exec -d "Execute an apcore module"\n'
89
+ 'complete -c apcore-cli -n "__fish_use_subcommand"'
90
+ ' -a list -d "List available modules"\n'
91
+ 'complete -c apcore-cli -n "__fish_use_subcommand"'
92
+ ' -a describe -d "Show module details"\n'
93
+ 'complete -c apcore-cli -n "__fish_use_subcommand"'
94
+ ' -a completion -d "Generate shell completion script"\n'
95
+ 'complete -c apcore-cli -n "__fish_use_subcommand"'
96
+ ' -a man -d "Generate man page"\n'
97
+ "\n"
98
+ 'complete -c apcore-cli -n "__fish_seen_subcommand_from exec"'
99
+ f' -a "({fish_dyn})"\n'
100
+ )
101
+
102
+
103
+ def _generate_man_page(command_name: str, command: click.Command | None, cli: click.Group) -> str:
104
+ """Generate a roff-formatted man page for a command."""
105
+ today = date.today().strftime("%Y-%m-%d")
106
+ cmd_upper = command_name.upper()
107
+
108
+ sections = []
109
+ sections.append(f'.TH "APCORE-CLI-{cmd_upper}" "1" "{today}" "apcore-cli {__version__}" "apcore-cli Manual"')
110
+ sections.append(".SH NAME")
111
+ if command:
112
+ desc = command.help or command_name
113
+ sections.append(f"apcore-cli-{command_name} \\- {desc}")
114
+ else:
115
+ sections.append(f"apcore-cli-{command_name}")
116
+
117
+ sections.append(".SH SYNOPSIS")
118
+ sections.append(f"\\fBapcore-cli {command_name}\\fR [OPTIONS] [ARGUMENTS]")
119
+
120
+ if command and command.help:
121
+ sections.append(".SH DESCRIPTION")
122
+ sections.append(command.help)
123
+
124
+ if command and command.params:
125
+ sections.append(".SH OPTIONS")
126
+ for param in command.params:
127
+ if isinstance(param, click.Option):
128
+ flag = ", ".join(param.opts + getattr(param, "secondary_opts", []))
129
+ type_name = param.type.name if hasattr(param.type, "name") else "VALUE"
130
+ sections.append(".TP")
131
+ sections.append(f"\\fB{flag}\\fR \\fI{type_name}\\fR")
132
+ if param.help:
133
+ sections.append(param.help)
134
+
135
+ sections.append(".SH EXIT CODES")
136
+ sections.append(".TP\n\\fB0\\fR\nSuccess.")
137
+ sections.append(".TP\n\\fB1\\fR\nModule execution error.")
138
+ sections.append(".TP\n\\fB2\\fR\nInvalid CLI input.")
139
+ sections.append(".TP\n\\fB44\\fR\nModule not found, disabled, or load error.")
140
+ sections.append(".TP\n\\fB45\\fR\nSchema validation error.")
141
+ sections.append(".TP\n\\fB46\\fR\nApproval denied or timed out.")
142
+ sections.append(".TP\n\\fB47\\fR\nConfiguration error.")
143
+ sections.append(".TP\n\\fB48\\fR\nSchema circular reference.")
144
+ sections.append(".TP\n\\fB77\\fR\nACL denied.")
145
+ sections.append(".TP\n\\fB130\\fR\nExecution cancelled (SIGINT).")
146
+
147
+ sections.append(".SH SEE ALSO")
148
+ sections.append("\\fBapcore-cli\\fR(1), \\fBapcore-cli-list\\fR(1), \\fBapcore-cli-describe\\fR(1)")
149
+
150
+ return "\n".join(sections)
151
+
152
+
153
+ def register_shell_commands(cli: click.Group) -> None:
154
+ """Register completion and man commands."""
155
+
156
+ @cli.command("completion")
157
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
158
+ def completion_cmd(shell: str) -> None:
159
+ """Generate shell completion script."""
160
+ generators = {
161
+ "bash": _generate_bash_completion,
162
+ "zsh": _generate_zsh_completion,
163
+ "fish": _generate_fish_completion,
164
+ }
165
+ click.echo(generators[shell]())
166
+
167
+ @cli.command("man")
168
+ @click.argument("command")
169
+ @click.pass_context
170
+ def man_cmd(ctx: click.Context, command: str) -> None:
171
+ """Generate man page for a command."""
172
+ parent = ctx.parent
173
+ if parent is None:
174
+ click.echo(f"Error: Unknown command '{command}'.", err=True)
175
+ sys.exit(2)
176
+
177
+ parent_group = parent.command
178
+ cmd = parent_group.commands.get(command) if isinstance(parent_group, click.Group) else None
179
+
180
+ if cmd is None and command not in ("exec", "list", "describe", "completion", "man"):
181
+ click.echo(f"Error: Unknown command '{command}'.", err=True)
182
+ sys.exit(2)
183
+
184
+ roff = _generate_man_page(command, cmd, cli)
185
+ click.echo(roff)