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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ agentsafe = agentsafe.cli:app
3
+
4
+ [agentsafe.kms_providers]
5
+ oci = agentsafe.kms.oci_provider:OCIProvider
@@ -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
@@ -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