agentconfigsafe 0.1.1__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.
- agentconfigsafe-0.1.1.dist-info/METADATA +93 -0
- agentconfigsafe-0.1.1.dist-info/RECORD +14 -0
- agentconfigsafe-0.1.1.dist-info/WHEEL +4 -0
- agentconfigsafe-0.1.1.dist-info/entry_points.txt +5 -0
- agentconfigsafe-0.1.1.dist-info/licenses/LICENSE +21 -0
- agentsafe/__init__.py +12 -0
- agentsafe/cli.py +89 -0
- agentsafe/config.py +62 -0
- agentsafe/exceptions.py +17 -0
- agentsafe/kms/__init__.py +46 -0
- agentsafe/kms/base.py +46 -0
- agentsafe/kms/oci_provider.py +88 -0
- agentsafe/sdk.py +77 -0
- agentsafe/store.py +112 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentconfigsafe
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: KMS-backed configuration storage for AI agent applications
|
|
5
|
+
Project-URL: Homepage, https://github.com/kiranthakkar/agentsafe
|
|
6
|
+
Project-URL: Repository, https://github.com/kiranthakkar/agentsafe
|
|
7
|
+
Project-URL: Issues, https://github.com/kiranthakkar/agentsafe/issues
|
|
8
|
+
Author: Kiran Thakkar
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,configuration,kms,oci,secrets
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: filelock>=3.13
|
|
24
|
+
Requires-Dist: typer>=0.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
27
|
+
Requires-Dist: oci>=2.100; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
+
Provides-Extra: oci
|
|
31
|
+
Requires-Dist: oci>=2.100; extra == 'oci'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# agentsafe
|
|
35
|
+
|
|
36
|
+
`agentsafe` stores encrypted configuration values in a project-local `appconfig`
|
|
37
|
+
file. Encryption and decryption are performed by a customer-managed OCI KMS key;
|
|
38
|
+
the file never contains plaintext or key material.
|
|
39
|
+
|
|
40
|
+
```console
|
|
41
|
+
agentsafe init --profile DEFAULT --compartment <compartment-ocid> \
|
|
42
|
+
--crypto-endpoint <vault-crypto-url> --key-id <key-ocid>
|
|
43
|
+
agentsafe set OPENAI_API_KEY
|
|
44
|
+
agentsafe get OPENAI_API_KEY
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`agentsafe set NAME VALUE` is available for automation, but command-line
|
|
48
|
+
arguments can be exposed in shell history and process listings. Prefer the
|
|
49
|
+
hidden prompt (omit `VALUE`) or pipe the value to standard input.
|
|
50
|
+
|
|
51
|
+
## Python SDK
|
|
52
|
+
|
|
53
|
+
Install the OCI provider extra in the application environment:
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
python -m pip install "agentconfigsafe[oci]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Configure the KMS settings once with `agentsafe init` (shown above), or supply
|
|
60
|
+
them directly when constructing `AgentSafe`. The default store is `appconfig`
|
|
61
|
+
in the process's current directory; pass an explicit path when the application
|
|
62
|
+
does not run from its project directory.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
|
|
67
|
+
from agentsafe import AgentSafe, KeyNotFoundError
|
|
68
|
+
|
|
69
|
+
safe = AgentSafe(
|
|
70
|
+
Path("/srv/my-service/appconfig"),
|
|
71
|
+
profile="DEFAULT",
|
|
72
|
+
compartment="ocid1.compartment.oc1..example",
|
|
73
|
+
crypto_endpoint="https://example-crypto.kms.us-phoenix-1.oraclecloud.com",
|
|
74
|
+
key_id="ocid1.key.oc1..example",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
api_key = safe.get("OPENAI_API_KEY")
|
|
79
|
+
except KeyNotFoundError:
|
|
80
|
+
# Configure the secret before starting the application.
|
|
81
|
+
raise RuntimeError("OPENAI_API_KEY has not been configured") from None
|
|
82
|
+
|
|
83
|
+
# Pass ``api_key`` directly to your API client; do not log it or write it to disk.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Settings resolve in this order: constructor arguments, `AGENTSAFE_*`
|
|
87
|
+
environment variables, then `~/.agentsafe/config`. The KMS provider defaults
|
|
88
|
+
to OCI; the OCI profile, compartment OCID, crypto endpoint, and key OCID must
|
|
89
|
+
all be configured. `get()` decrypts only for the duration of the call;
|
|
90
|
+
`list_keys()` returns names without decrypting values.
|
|
91
|
+
|
|
92
|
+
The PyPI distribution is named `agentconfigsafe`; the Python import and CLI
|
|
93
|
+
remain `agentsafe`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
agentsafe/__init__.py,sha256=XcBtNBQqlvPNSrRsGJCnvp44enE1El1FihKs62Y0XNw,279
|
|
2
|
+
agentsafe/cli.py,sha256=LxvpfE64RY3uYWlVZsLG57iizhKQaU8-nBSGnbEsf7Y,2364
|
|
3
|
+
agentsafe/config.py,sha256=30CCbQUfABhzZ81dGpPfGZ6ytTGORdM6y5GmXOrJ9jE,2357
|
|
4
|
+
agentsafe/exceptions.py,sha256=sjQZxaxe7XEULpfBG7jy88Y33UdmazB58Upq0u7RsK8,431
|
|
5
|
+
agentsafe/sdk.py,sha256=nsbjlpgN8lUY7TW2lMkBont2VKtPIKe9AVnfmfgw_zM,3093
|
|
6
|
+
agentsafe/store.py,sha256=ivs3hfIxr0L-5RWirhzAoKZB1ePS7DnixbMAapt0rnA,4365
|
|
7
|
+
agentsafe/kms/__init__.py,sha256=ZnQLL9aGeRCpOLCn5vyEJrFeyTc0RIDP9SwilDF5NKo,1702
|
|
8
|
+
agentsafe/kms/base.py,sha256=AiKKaafYOaZZZGouahpYZeIbho3vhe213dopA_9y3KE,1498
|
|
9
|
+
agentsafe/kms/oci_provider.py,sha256=rtSql42bDwAO7fWNpHdxKct3BJR7klYHY8TkcsPY3RA,3610
|
|
10
|
+
agentconfigsafe-0.1.1.dist-info/METADATA,sha256=2EY5uonOuMqchKsVQTYxmeTFcFzqPHaUj0X7EmLBJiE,3435
|
|
11
|
+
agentconfigsafe-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
agentconfigsafe-0.1.1.dist-info/entry_points.txt,sha256=S3pKY-LQGDKNX-apco6tn21MCTWgZbPE7yT-6XX1tng,120
|
|
13
|
+
agentconfigsafe-0.1.1.dist-info/licenses/LICENSE,sha256=4_6M7TBAKE8rHUfoXkbX-BskUPXTgAMINktJsTB5tdU,1070
|
|
14
|
+
agentconfigsafe-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kiran Thakkar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
agentsafe/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Public SDK surface for agentsafe."""
|
|
2
|
+
|
|
3
|
+
from agentsafe.exceptions import AgentSafeError, ConfigError, KeyNotFoundError, KMSError
|
|
4
|
+
from agentsafe.sdk import AgentSafe
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AgentSafe",
|
|
8
|
+
"AgentSafeError",
|
|
9
|
+
"ConfigError",
|
|
10
|
+
"KMSError",
|
|
11
|
+
"KeyNotFoundError",
|
|
12
|
+
]
|
agentsafe/cli.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Typer command-line interface for agentsafe."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from agentsafe.exceptions import AgentSafeError
|
|
8
|
+
from agentsafe.sdk import AgentSafe
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _safe(path: Path, **settings: str | None) -> AgentSafe:
|
|
14
|
+
return AgentSafe(path, **settings)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _handle(action: object) -> None:
|
|
18
|
+
try:
|
|
19
|
+
action() # type: ignore[operator]
|
|
20
|
+
except AgentSafeError as error:
|
|
21
|
+
typer.echo(str(error), err=True)
|
|
22
|
+
raise typer.Exit(1) from error
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def init(
|
|
27
|
+
profile: str | None = typer.Option(None),
|
|
28
|
+
compartment: str | None = typer.Option(None),
|
|
29
|
+
crypto_endpoint: str | None = typer.Option(None, "--crypto-endpoint"),
|
|
30
|
+
key_id: str | None = typer.Option(None, "--key-id"),
|
|
31
|
+
path: Path = typer.Option(Path("appconfig"), "--path"),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Create global OCI settings and an empty appconfig without overwriting either."""
|
|
34
|
+
_handle(
|
|
35
|
+
lambda: AgentSafe.init(
|
|
36
|
+
path,
|
|
37
|
+
profile=profile,
|
|
38
|
+
compartment=compartment,
|
|
39
|
+
crypto_endpoint=crypto_endpoint,
|
|
40
|
+
key_id=key_id,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def set(
|
|
47
|
+
key: str,
|
|
48
|
+
value: str | None = typer.Argument(None),
|
|
49
|
+
path: Path = typer.Option(Path("appconfig"), "--path"),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Encrypt and store a value; omit VALUE to enter it through a hidden prompt."""
|
|
52
|
+
secret = value if value is not None else typer.prompt("Value", hide_input=True)
|
|
53
|
+
_handle(lambda: _safe(path).set(key, secret))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
app.command(name="store")(set)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def get(key: str, path: Path = typer.Option(Path("appconfig"), "--path")) -> None:
|
|
61
|
+
"""Decrypt and print one value."""
|
|
62
|
+
|
|
63
|
+
def action() -> None:
|
|
64
|
+
typer.echo(_safe(path).get(key))
|
|
65
|
+
|
|
66
|
+
_handle(action)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
app.command(name="retrieve")(get)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command(name="remove")
|
|
73
|
+
def remove(key: str, path: Path = typer.Option(Path("appconfig"), "--path")) -> None:
|
|
74
|
+
"""Remove one value."""
|
|
75
|
+
_handle(lambda: _safe(path).remove(key))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
app.command(name="rm")(remove)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command(name="list")
|
|
82
|
+
def list_values(path: Path = typer.Option(Path("appconfig"), "--path")) -> None:
|
|
83
|
+
"""List names only; this command never decrypts values."""
|
|
84
|
+
|
|
85
|
+
def action() -> None:
|
|
86
|
+
for key in _safe(path).list_keys():
|
|
87
|
+
typer.echo(key)
|
|
88
|
+
|
|
89
|
+
_handle(action)
|
agentsafe/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Resolution and persistence of agentsafe's machine-wide settings."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentsafe.exceptions import ConfigError
|
|
9
|
+
|
|
10
|
+
CONFIG_PATH = Path.home() / ".agentsafe" / "config"
|
|
11
|
+
ENVIRONMENT_SETTINGS = {
|
|
12
|
+
"kms_provider": "AGENTSAFE_KMS_PROVIDER",
|
|
13
|
+
"profile": "AGENTSAFE_PROFILE",
|
|
14
|
+
"compartment": "AGENTSAFE_COMPARTMENT",
|
|
15
|
+
"crypto_endpoint": "AGENTSAFE_CRYPTO_ENDPOINT",
|
|
16
|
+
"key_id": "AGENTSAFE_KEY_ID",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_config(path: Path = CONFIG_PATH) -> dict[str, str]:
|
|
21
|
+
"""Read settings from the optional INI configuration file."""
|
|
22
|
+
if not path.exists():
|
|
23
|
+
return {}
|
|
24
|
+
parser = configparser.ConfigParser()
|
|
25
|
+
try:
|
|
26
|
+
parser.read(path)
|
|
27
|
+
except (OSError, configparser.Error) as error:
|
|
28
|
+
raise ConfigError(f"could not read agentsafe configuration at {path}") from error
|
|
29
|
+
return dict(parser["agentsafe"]) if parser.has_section("agentsafe") else {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_settings(
|
|
33
|
+
explicit: dict[str, Any], *, config_path: Path = CONFIG_PATH
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""Resolve settings: explicit, environment, config file, then OCI provider default."""
|
|
36
|
+
file_settings = read_config(config_path)
|
|
37
|
+
resolved: dict[str, Any] = {}
|
|
38
|
+
for key, environment_name in ENVIRONMENT_SETTINGS.items():
|
|
39
|
+
value = explicit.get(key)
|
|
40
|
+
if value is None:
|
|
41
|
+
value = os.environ.get(environment_name)
|
|
42
|
+
if value is None:
|
|
43
|
+
value = file_settings.get(key)
|
|
44
|
+
if value is not None:
|
|
45
|
+
resolved[key] = value
|
|
46
|
+
resolved.setdefault("kms_provider", "oci")
|
|
47
|
+
return resolved
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_config(settings: dict[str, Any], path: Path = CONFIG_PATH) -> None:
|
|
51
|
+
"""Create the global config once, without overwriting an existing file."""
|
|
52
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
53
|
+
parser = configparser.ConfigParser()
|
|
54
|
+
parser["agentsafe"] = {key: str(value) for key, value in settings.items() if value is not None}
|
|
55
|
+
try:
|
|
56
|
+
with path.open("x", encoding="utf-8") as handle:
|
|
57
|
+
os.chmod(path, 0o600)
|
|
58
|
+
parser.write(handle)
|
|
59
|
+
except FileExistsError as error:
|
|
60
|
+
raise ConfigError(f"agentsafe configuration already exists at {path}") from error
|
|
61
|
+
except OSError as error:
|
|
62
|
+
raise ConfigError(f"could not write agentsafe configuration at {path}") from error
|
agentsafe/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Typed errors raised by agentsafe."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentSafeError(Exception):
|
|
5
|
+
"""Base class for all agentsafe errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(AgentSafeError):
|
|
9
|
+
"""Raised for invalid or missing agentsafe configuration."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KeyNotFoundError(AgentSafeError):
|
|
13
|
+
"""Raised when a requested configuration name is absent."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KMSError(AgentSafeError):
|
|
17
|
+
"""Raised when a KMS provider cannot complete an operation."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Entry-point discovery and lazy KMS provider construction."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from importlib import metadata
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from agentsafe.exceptions import ConfigError
|
|
8
|
+
from agentsafe.kms.base import KMSProvider
|
|
9
|
+
|
|
10
|
+
ProviderFactory = Callable[..., KMSProvider]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _distribution_name(entry_point: metadata.EntryPoint) -> str:
|
|
14
|
+
distribution = getattr(entry_point, "dist", None)
|
|
15
|
+
return distribution.name if distribution is not None else "unknown distribution"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def discover_providers() -> dict[str, metadata.EntryPoint]:
|
|
19
|
+
"""Discover provider entry points, rejecting security-sensitive name collisions."""
|
|
20
|
+
entries = list(metadata.entry_points(group="agentsafe.kms_providers"))
|
|
21
|
+
providers: dict[str, metadata.EntryPoint] = {}
|
|
22
|
+
for entry in entries:
|
|
23
|
+
previous = providers.get(entry.name)
|
|
24
|
+
if previous is not None:
|
|
25
|
+
raise ConfigError(
|
|
26
|
+
f"KMS provider name collision for '{entry.name}': "
|
|
27
|
+
f"{_distribution_name(previous)} and {_distribution_name(entry)}"
|
|
28
|
+
)
|
|
29
|
+
providers[entry.name] = entry
|
|
30
|
+
return providers
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_provider(name: str, **settings: Any) -> KMSProvider:
|
|
34
|
+
"""Load and instantiate one selected provider without importing others."""
|
|
35
|
+
entry = discover_providers().get(name)
|
|
36
|
+
if entry is None:
|
|
37
|
+
raise ConfigError(f"KMS provider '{name}' is not installed")
|
|
38
|
+
try:
|
|
39
|
+
factory = entry.load()
|
|
40
|
+
return cast(KMSProvider, factory(**settings))
|
|
41
|
+
except ConfigError:
|
|
42
|
+
raise
|
|
43
|
+
except ImportError as error:
|
|
44
|
+
raise ConfigError(
|
|
45
|
+
f"provider '{name}' is unavailable; install its optional extra"
|
|
46
|
+
) from error
|
agentsafe/kms/base.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Public provider contract."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class EncryptedBlob:
|
|
9
|
+
"""A provider-owned ciphertext envelope persisted in ``appconfig``."""
|
|
10
|
+
|
|
11
|
+
provider: str
|
|
12
|
+
ciphertext: str
|
|
13
|
+
metadata: dict[str, Any]
|
|
14
|
+
|
|
15
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
16
|
+
return {
|
|
17
|
+
"provider": self.provider,
|
|
18
|
+
"ciphertext": self.ciphertext,
|
|
19
|
+
"metadata": self.metadata,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_mapping(cls, value: dict[str, Any]) -> "EncryptedBlob":
|
|
24
|
+
try:
|
|
25
|
+
provider = value["provider"]
|
|
26
|
+
ciphertext = value["ciphertext"]
|
|
27
|
+
metadata = value["metadata"]
|
|
28
|
+
except KeyError as error:
|
|
29
|
+
raise ValueError("ciphertext envelope is missing a required field") from error
|
|
30
|
+
if (
|
|
31
|
+
not isinstance(provider, str)
|
|
32
|
+
or not isinstance(ciphertext, str)
|
|
33
|
+
or not isinstance(metadata, dict)
|
|
34
|
+
):
|
|
35
|
+
raise TypeError("ciphertext envelope has invalid routing fields")
|
|
36
|
+
return cls(provider=provider, ciphertext=ciphertext, metadata=metadata)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KMSProvider(Protocol):
|
|
40
|
+
"""A KMS backend that owns encryption and ciphertext interpretation."""
|
|
41
|
+
|
|
42
|
+
def encrypt(self, plaintext: str) -> EncryptedBlob:
|
|
43
|
+
"""Encrypt plaintext and return a provider-owned envelope."""
|
|
44
|
+
|
|
45
|
+
def decrypt(self, blob: EncryptedBlob) -> str:
|
|
46
|
+
"""Decrypt an envelope created by this provider."""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""OCI KMS provider implementation, imported only when OCI is selected."""
|
|
2
|
+
|
|
3
|
+
from base64 import b64decode, b64encode
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from agentsafe.exceptions import ConfigError, KMSError
|
|
7
|
+
from agentsafe.kms.base import EncryptedBlob
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OCIProvider:
|
|
11
|
+
"""Encrypt and decrypt values through an OCI vault crypto endpoint."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
profile: str | None = None,
|
|
17
|
+
compartment: str | None = None,
|
|
18
|
+
crypto_endpoint: str | None = None,
|
|
19
|
+
key_id: str | None = None,
|
|
20
|
+
**_: Any,
|
|
21
|
+
) -> None:
|
|
22
|
+
missing = [
|
|
23
|
+
label
|
|
24
|
+
for label, value in {
|
|
25
|
+
"profile": profile,
|
|
26
|
+
"compartment": compartment,
|
|
27
|
+
"crypto_endpoint": crypto_endpoint,
|
|
28
|
+
"key_id": key_id,
|
|
29
|
+
}.items()
|
|
30
|
+
if not value
|
|
31
|
+
]
|
|
32
|
+
if missing:
|
|
33
|
+
raise ConfigError(f"OCI provider requires: {', '.join(missing)}")
|
|
34
|
+
try:
|
|
35
|
+
import oci # type: ignore[import-untyped]
|
|
36
|
+
except ImportError as error:
|
|
37
|
+
raise ConfigError("provider 'oci' requires oci: pip install agentconfigsafe[oci]") from error
|
|
38
|
+
try:
|
|
39
|
+
configuration = oci.config.from_file(profile_name=profile)
|
|
40
|
+
self._client = oci.key_management.KmsCryptoClient(
|
|
41
|
+
configuration, service_endpoint=crypto_endpoint
|
|
42
|
+
)
|
|
43
|
+
except Exception as error:
|
|
44
|
+
raise KMSError("could not initialize OCI KMS client") from error
|
|
45
|
+
self._key_id = key_id
|
|
46
|
+
self._oci: Any = oci
|
|
47
|
+
|
|
48
|
+
def encrypt(self, plaintext: str) -> EncryptedBlob:
|
|
49
|
+
try:
|
|
50
|
+
details = self._oci.key_management.models.EncryptDataDetails(
|
|
51
|
+
key_id=self._key_id,
|
|
52
|
+
plaintext=b64encode(plaintext.encode("utf-8")).decode("ascii"),
|
|
53
|
+
)
|
|
54
|
+
response = self._client.encrypt(details).data
|
|
55
|
+
key_version = getattr(response, "key_version_id", None)
|
|
56
|
+
metadata = {"key_id": self._key_id}
|
|
57
|
+
if key_version is not None:
|
|
58
|
+
metadata["key_version"] = key_version
|
|
59
|
+
return EncryptedBlob(provider="oci", ciphertext=response.ciphertext, metadata=metadata)
|
|
60
|
+
except KMSError:
|
|
61
|
+
raise
|
|
62
|
+
except Exception as error:
|
|
63
|
+
raise self._operation_error("encryption", error) from error
|
|
64
|
+
|
|
65
|
+
def decrypt(self, blob: EncryptedBlob) -> str:
|
|
66
|
+
try:
|
|
67
|
+
key_id = blob.metadata.get("key_id")
|
|
68
|
+
if not isinstance(key_id, str) or not key_id:
|
|
69
|
+
raise KMSError("OCI ciphertext envelope is missing key_id metadata")
|
|
70
|
+
details = self._oci.key_management.models.DecryptDataDetails(
|
|
71
|
+
ciphertext=blob.ciphertext, key_id=key_id
|
|
72
|
+
)
|
|
73
|
+
response = self._client.decrypt(details).data
|
|
74
|
+
encoded_plaintext = cast(str, response.plaintext)
|
|
75
|
+
return b64decode(encoded_plaintext, validate=True).decode("utf-8")
|
|
76
|
+
except KMSError:
|
|
77
|
+
raise
|
|
78
|
+
except Exception as error:
|
|
79
|
+
raise self._operation_error("decryption", error) from error
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _operation_error(operation: str, error: Exception) -> KMSError:
|
|
83
|
+
"""Expose only safe OCI diagnostics; request text may contain a secret."""
|
|
84
|
+
status = getattr(error, "status", None)
|
|
85
|
+
code = getattr(error, "code", None)
|
|
86
|
+
if isinstance(status, int) and isinstance(code, str):
|
|
87
|
+
return KMSError(f"OCI KMS {operation} failed (HTTP {status}, {code})")
|
|
88
|
+
return KMSError(f"OCI KMS {operation} failed ({type(error).__name__})")
|
agentsafe/sdk.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""User-facing AgentSafe SDK."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentsafe.config import CONFIG_PATH, resolve_settings, write_config
|
|
7
|
+
from agentsafe.exceptions import ConfigError
|
|
8
|
+
from agentsafe.kms import get_provider
|
|
9
|
+
from agentsafe.kms.base import KMSProvider
|
|
10
|
+
from agentsafe.store import ConfigStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentSafe:
|
|
14
|
+
"""Store and retrieve string configuration values encrypted by a KMS provider."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, appconfig_path: Path | str = "appconfig", **settings: Any) -> None:
|
|
17
|
+
"""Create a client using explicit settings, environment, then global configuration."""
|
|
18
|
+
self.settings = resolve_settings(settings)
|
|
19
|
+
self.store = ConfigStore(appconfig_path)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def init(
|
|
23
|
+
cls,
|
|
24
|
+
appconfig_path: Path | str = "appconfig",
|
|
25
|
+
*,
|
|
26
|
+
config_path: Path = CONFIG_PATH,
|
|
27
|
+
**settings: Any,
|
|
28
|
+
) -> "AgentSafe":
|
|
29
|
+
"""Create global settings and an empty project store without overwriting either."""
|
|
30
|
+
resolved = resolve_settings(settings, config_path=config_path)
|
|
31
|
+
provider = resolved.get("kms_provider", "oci")
|
|
32
|
+
if provider == "oci":
|
|
33
|
+
missing = [
|
|
34
|
+
key
|
|
35
|
+
for key in ("profile", "compartment", "crypto_endpoint", "key_id")
|
|
36
|
+
if not resolved.get(key)
|
|
37
|
+
]
|
|
38
|
+
if missing:
|
|
39
|
+
raise ConfigError(f"OCI configuration requires: {', '.join(missing)}")
|
|
40
|
+
store = ConfigStore(appconfig_path)
|
|
41
|
+
if config_path.exists() or store.path.exists():
|
|
42
|
+
target = config_path if config_path.exists() else store.path
|
|
43
|
+
raise ConfigError(f"init refused to overwrite existing file: {target}")
|
|
44
|
+
write_config(resolved, config_path)
|
|
45
|
+
store.initialize()
|
|
46
|
+
return cls(appconfig_path, **resolved)
|
|
47
|
+
|
|
48
|
+
def set(self, key: str, value: str) -> None:
|
|
49
|
+
"""Encrypt and store a string value under a non-empty configuration name."""
|
|
50
|
+
self._validate_key(key)
|
|
51
|
+
if not isinstance(value, str):
|
|
52
|
+
raise ConfigError("configuration values must be strings")
|
|
53
|
+
provider = self._provider()
|
|
54
|
+
self.store.set(key, provider.encrypt(value))
|
|
55
|
+
|
|
56
|
+
def get(self, key: str) -> str:
|
|
57
|
+
"""Decrypt and return one configuration value; absent names raise KeyNotFoundError."""
|
|
58
|
+
self._validate_key(key)
|
|
59
|
+
blob = self.store.get(key)
|
|
60
|
+
return self._provider(blob.provider).decrypt(blob)
|
|
61
|
+
|
|
62
|
+
def remove(self, key: str) -> None:
|
|
63
|
+
"""Remove one encrypted configuration value."""
|
|
64
|
+
self._validate_key(key)
|
|
65
|
+
self.store.remove(key)
|
|
66
|
+
|
|
67
|
+
def list_keys(self) -> list[str]:
|
|
68
|
+
"""Return configuration names without decrypting any value."""
|
|
69
|
+
return self.store.list_keys()
|
|
70
|
+
|
|
71
|
+
def _provider(self, name: str | None = None) -> KMSProvider:
|
|
72
|
+
return get_provider(name or self.settings["kms_provider"], **self.settings)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _validate_key(key: str) -> None:
|
|
76
|
+
if not isinstance(key, str) or not key:
|
|
77
|
+
raise ConfigError("configuration name must be a non-empty string")
|
agentsafe/store.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Durable, locked access to the project-local ciphertext store."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from filelock import FileLock
|
|
12
|
+
|
|
13
|
+
from agentsafe.exceptions import ConfigError, KeyNotFoundError
|
|
14
|
+
from agentsafe.kms.base import EncryptedBlob
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigStore:
|
|
20
|
+
"""The JSON ``appconfig`` file; it contains ciphertext envelopes only."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: Path | str = "appconfig") -> None:
|
|
23
|
+
self.path = Path(path)
|
|
24
|
+
self.lock_path = Path(f"{self.path}.lock")
|
|
25
|
+
|
|
26
|
+
def initialize(self) -> None:
|
|
27
|
+
"""Create an empty store and fail safely if it already exists."""
|
|
28
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
29
|
+
try:
|
|
30
|
+
with self.path.open("x", encoding="utf-8") as handle:
|
|
31
|
+
os.chmod(self.path, 0o600)
|
|
32
|
+
json.dump({"schema_version": SCHEMA_VERSION, "entries": {}}, handle)
|
|
33
|
+
except FileExistsError as error:
|
|
34
|
+
raise ConfigError(f"appconfig already exists at {self.path}") from error
|
|
35
|
+
except OSError as error:
|
|
36
|
+
raise ConfigError(f"could not create appconfig at {self.path}") from error
|
|
37
|
+
|
|
38
|
+
def list_keys(self) -> list[str]:
|
|
39
|
+
return list(self._read_entries().keys())
|
|
40
|
+
|
|
41
|
+
def get(self, key: str) -> EncryptedBlob:
|
|
42
|
+
entries = self._read_entries()
|
|
43
|
+
try:
|
|
44
|
+
raw = entries[key]
|
|
45
|
+
except KeyError as error:
|
|
46
|
+
raise KeyNotFoundError(f"no configuration value named '{key}'") from error
|
|
47
|
+
if not isinstance(raw, dict):
|
|
48
|
+
raise ConfigError("stored ciphertext envelope cannot be read")
|
|
49
|
+
try:
|
|
50
|
+
return EncryptedBlob.from_mapping(raw)
|
|
51
|
+
except (TypeError, ValueError) as error:
|
|
52
|
+
raise ConfigError("stored ciphertext envelope cannot be routed") from error
|
|
53
|
+
|
|
54
|
+
def set(self, key: str, blob: EncryptedBlob) -> None:
|
|
55
|
+
with self._lock():
|
|
56
|
+
entries = self._read_entries()
|
|
57
|
+
entries[key] = blob.to_mapping()
|
|
58
|
+
self._write_entries(entries)
|
|
59
|
+
|
|
60
|
+
def remove(self, key: str) -> None:
|
|
61
|
+
with self._lock():
|
|
62
|
+
entries = self._read_entries()
|
|
63
|
+
if key not in entries:
|
|
64
|
+
raise KeyNotFoundError(f"no configuration value named '{key}'")
|
|
65
|
+
del entries[key]
|
|
66
|
+
self._write_entries(entries)
|
|
67
|
+
|
|
68
|
+
@contextmanager
|
|
69
|
+
def _lock(self) -> Iterator[None]:
|
|
70
|
+
lock = FileLock(str(self.lock_path))
|
|
71
|
+
with lock:
|
|
72
|
+
# filelock creates this lazily; chmod after acquisition covers supported POSIX systems.
|
|
73
|
+
try:
|
|
74
|
+
os.chmod(self.lock_path, 0o600)
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
yield
|
|
78
|
+
|
|
79
|
+
def _read_entries(self) -> dict[str, Any]:
|
|
80
|
+
try:
|
|
81
|
+
with self.path.open(encoding="utf-8") as handle:
|
|
82
|
+
document = json.load(handle)
|
|
83
|
+
except FileNotFoundError as error:
|
|
84
|
+
raise ConfigError(f"appconfig does not exist at {self.path}") from error
|
|
85
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
86
|
+
raise ConfigError(f"could not read appconfig at {self.path}") from error
|
|
87
|
+
if not isinstance(document, dict):
|
|
88
|
+
raise ConfigError("appconfig root must be a JSON object")
|
|
89
|
+
entries = document.get("entries", {})
|
|
90
|
+
if not isinstance(entries, dict):
|
|
91
|
+
raise ConfigError("appconfig entries must be a JSON object")
|
|
92
|
+
return entries
|
|
93
|
+
|
|
94
|
+
def _write_entries(self, entries: dict[str, Any]) -> None:
|
|
95
|
+
descriptor, temp_name = tempfile.mkstemp(
|
|
96
|
+
prefix=".appconfig-", dir=self.path.parent, text=True
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
os.fchmod(descriptor, 0o600)
|
|
100
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
101
|
+
json.dump({"schema_version": SCHEMA_VERSION, "entries": entries}, handle, indent=2)
|
|
102
|
+
handle.write("\n")
|
|
103
|
+
handle.flush()
|
|
104
|
+
os.fsync(handle.fileno())
|
|
105
|
+
os.replace(temp_name, self.path)
|
|
106
|
+
os.chmod(self.path, 0o600)
|
|
107
|
+
except OSError as error:
|
|
108
|
+
try:
|
|
109
|
+
os.unlink(temp_name)
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
pass
|
|
112
|
+
raise ConfigError(f"could not write appconfig at {self.path}") from error
|