agent-vault-sdk 0.1.0__tar.gz

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,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-vault-sdk
3
+ Version: 0.1.0
4
+ Summary: Zero-trust credential manager for AI agents — Python SDK
5
+ Author: agent-vault contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ewimsatt/agent-vault
8
+ Project-URL: Repository, https://github.com/ewimsatt/agent-vault
9
+ Project-URL: Issues, https://github.com/ewimsatt/agent-vault/issues
10
+ Keywords: security,encryption,credentials,ai-agents,age,mcp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: pyrage>=1.2.0
22
+ Requires-Dist: pyyaml>=6.0
23
+ Requires-Dist: gitpython>=3.1
24
+ Provides-Extra: mcp
25
+ Requires-Dist: mcp>=1.2.0; extra == "mcp"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
29
+
30
+ # agent-vault Python SDK
31
+
32
+ Read-only Python SDK for [agent-vault](https://github.com/ewimsatt/agent-vault) — a zero-trust credential manager for AI agents.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install agent-vault
38
+
39
+ # With MCP server support:
40
+ pip install 'agent-vault[mcp]'
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```python
46
+ from agent_vault import Vault
47
+
48
+ vault = Vault(
49
+ repo_path="/path/to/vault",
50
+ key_path="~/.agent-vault/agents/my-agent.key",
51
+ )
52
+
53
+ # Pull latest and decrypt
54
+ api_key = vault.get("stripe/api-key")
55
+ ```
56
+
57
+ ## Key Resolution
58
+
59
+ The SDK resolves the identity key in this order:
60
+
61
+ 1. `key_str=` parameter (raw key string)
62
+ 2. `key_path=` parameter (path to key file)
63
+ 3. `AGENT_VAULT_KEY` environment variable (key as string)
64
+ 4. `~/.agent-vault/owner.key` (default owner key)
65
+
66
+ ## API
67
+
68
+ ### `Vault(repo_path, key_path=None, key_str=None, auto_pull=True)`
69
+
70
+ Create a read-only vault connection.
71
+
72
+ - `repo_path`: Path to the Git repo containing `.agent-vault/`
73
+ - `key_path`: Path to an age private key file
74
+ - `key_str`: Raw age private key string
75
+ - `auto_pull`: Git pull before each `get()` (default: True)
76
+
77
+ ### `vault.get(secret_path) -> str`
78
+
79
+ Decrypt and return a secret. Raises `SecretNotFoundError` or `NotAuthorizedError`.
80
+
81
+ ### `vault.list_secrets(group=None) -> list[SecretMetadata]`
82
+
83
+ List secret metadata without decrypting.
84
+
85
+ ### `vault.list_agents() -> list[dict]`
86
+
87
+ List agents and their group memberships.
88
+
89
+ ### `vault.pull()`
90
+
91
+ Manually pull latest changes from Git remote.
92
+
93
+ ### `vault.reload()`
94
+
95
+ Reload the manifest from disk (e.g., after a pull).
96
+
97
+ ## MCP Server
98
+
99
+ The package includes an MCP server for use with MCP-compatible AI agents:
100
+
101
+ ```bash
102
+ agent-vault-mcp --repo /path/to/vault --key ~/.agent-vault/agents/my-agent.key
103
+ ```
104
+
105
+ This runs a stdio-based MCP server exposing:
106
+
107
+ - `agent_vault_get(secret)` — retrieve and decrypt a secret
108
+ - `agent_vault_list(group?)` — list available secrets
109
+
110
+ ### Claude Desktop Configuration
111
+
112
+ ```json
113
+ {
114
+ "mcpServers": {
115
+ "agent-vault": {
116
+ "command": "agent-vault-mcp",
117
+ "args": ["--repo", "/path/to/vault", "--key", "/path/to/agent.key"]
118
+ }
119
+ }
120
+ }
121
+ ```
@@ -0,0 +1,92 @@
1
+ # agent-vault Python SDK
2
+
3
+ Read-only Python SDK for [agent-vault](https://github.com/ewimsatt/agent-vault) — a zero-trust credential manager for AI agents.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install agent-vault
9
+
10
+ # With MCP server support:
11
+ pip install 'agent-vault[mcp]'
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```python
17
+ from agent_vault import Vault
18
+
19
+ vault = Vault(
20
+ repo_path="/path/to/vault",
21
+ key_path="~/.agent-vault/agents/my-agent.key",
22
+ )
23
+
24
+ # Pull latest and decrypt
25
+ api_key = vault.get("stripe/api-key")
26
+ ```
27
+
28
+ ## Key Resolution
29
+
30
+ The SDK resolves the identity key in this order:
31
+
32
+ 1. `key_str=` parameter (raw key string)
33
+ 2. `key_path=` parameter (path to key file)
34
+ 3. `AGENT_VAULT_KEY` environment variable (key as string)
35
+ 4. `~/.agent-vault/owner.key` (default owner key)
36
+
37
+ ## API
38
+
39
+ ### `Vault(repo_path, key_path=None, key_str=None, auto_pull=True)`
40
+
41
+ Create a read-only vault connection.
42
+
43
+ - `repo_path`: Path to the Git repo containing `.agent-vault/`
44
+ - `key_path`: Path to an age private key file
45
+ - `key_str`: Raw age private key string
46
+ - `auto_pull`: Git pull before each `get()` (default: True)
47
+
48
+ ### `vault.get(secret_path) -> str`
49
+
50
+ Decrypt and return a secret. Raises `SecretNotFoundError` or `NotAuthorizedError`.
51
+
52
+ ### `vault.list_secrets(group=None) -> list[SecretMetadata]`
53
+
54
+ List secret metadata without decrypting.
55
+
56
+ ### `vault.list_agents() -> list[dict]`
57
+
58
+ List agents and their group memberships.
59
+
60
+ ### `vault.pull()`
61
+
62
+ Manually pull latest changes from Git remote.
63
+
64
+ ### `vault.reload()`
65
+
66
+ Reload the manifest from disk (e.g., after a pull).
67
+
68
+ ## MCP Server
69
+
70
+ The package includes an MCP server for use with MCP-compatible AI agents:
71
+
72
+ ```bash
73
+ agent-vault-mcp --repo /path/to/vault --key ~/.agent-vault/agents/my-agent.key
74
+ ```
75
+
76
+ This runs a stdio-based MCP server exposing:
77
+
78
+ - `agent_vault_get(secret)` — retrieve and decrypt a secret
79
+ - `agent_vault_list(group?)` — list available secrets
80
+
81
+ ### Claude Desktop Configuration
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "agent-vault": {
87
+ "command": "agent-vault-mcp",
88
+ "args": ["--repo", "/path/to/vault", "--key", "/path/to/agent.key"]
89
+ }
90
+ }
91
+ }
92
+ ```
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agent-vault-sdk"
7
+ version = "0.1.0"
8
+ description = "Zero-trust credential manager for AI agents — Python SDK"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "agent-vault contributors"}]
13
+ keywords = ["security", "encryption", "credentials", "ai-agents", "age", "mcp"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Security :: Cryptography",
23
+ ]
24
+ dependencies = [
25
+ "pyrage>=1.2.0",
26
+ "pyyaml>=6.0",
27
+ "gitpython>=3.1",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/ewimsatt/agent-vault"
32
+ Repository = "https://github.com/ewimsatt/agent-vault"
33
+ Issues = "https://github.com/ewimsatt/agent-vault/issues"
34
+
35
+ [project.optional-dependencies]
36
+ mcp = ["mcp>=1.2.0"]
37
+ dev = [
38
+ "pytest>=7.0",
39
+ "pytest-asyncio>=0.21",
40
+ ]
41
+
42
+ [project.scripts]
43
+ agent-vault-mcp = "agent_vault.mcp_server:main"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """agent-vault: Zero-trust credential manager for AI agents."""
2
+
3
+ from agent_vault.vault import Vault
4
+ from agent_vault.errors import (
5
+ VaultError,
6
+ VaultNotFoundError,
7
+ SecretNotFoundError,
8
+ NotAuthorizedError,
9
+ )
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = [
13
+ "Vault",
14
+ "VaultError",
15
+ "VaultNotFoundError",
16
+ "SecretNotFoundError",
17
+ "NotAuthorizedError",
18
+ ]
@@ -0,0 +1,33 @@
1
+ """Cryptographic operations using pyrage (age encryption)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyrage import decrypt, x25519
6
+
7
+
8
+ def load_identity(key_path: str) -> x25519.Identity:
9
+ """Load an age x25519 identity (private key) from a file.
10
+
11
+ The file should contain a line starting with AGE-SECRET-KEY-.
12
+ """
13
+ with open(key_path, "r") as f:
14
+ for line in f:
15
+ line = line.strip()
16
+ if line.startswith("AGE-SECRET-KEY-"):
17
+ return x25519.Identity.from_str(line)
18
+ raise ValueError(f"No age secret key found in {key_path}")
19
+
20
+
21
+ def load_identity_from_str(key_str: str) -> x25519.Identity:
22
+ """Load an age x25519 identity from a string."""
23
+ for line in key_str.splitlines():
24
+ line = line.strip()
25
+ if line.startswith("AGE-SECRET-KEY-"):
26
+ return x25519.Identity.from_str(line)
27
+ raise ValueError("No age secret key found in provided string")
28
+
29
+
30
+ def decrypt_secret(ciphertext: bytes, identity: x25519.Identity) -> str:
31
+ """Decrypt an age-encrypted secret and return the plaintext string."""
32
+ plaintext_bytes = decrypt(ciphertext, [identity])
33
+ return plaintext_bytes.decode("utf-8")
@@ -0,0 +1,21 @@
1
+ """Error types for agent-vault."""
2
+
3
+
4
+ class VaultError(Exception):
5
+ """Base error for all vault operations."""
6
+
7
+
8
+ class VaultNotFoundError(VaultError):
9
+ """No .agent-vault directory found at the given path."""
10
+
11
+
12
+ class SecretNotFoundError(VaultError):
13
+ """The requested secret does not exist in the vault."""
14
+
15
+
16
+ class NotAuthorizedError(VaultError):
17
+ """The provided key cannot decrypt the requested secret."""
18
+
19
+
20
+ class ManifestError(VaultError):
21
+ """Error parsing or querying the manifest."""
@@ -0,0 +1,76 @@
1
+ """Manifest parsing for vault access control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import yaml
9
+
10
+ from agent_vault.errors import ManifestError
11
+
12
+
13
+ class Manifest:
14
+ """Parsed manifest.yaml — access control policy."""
15
+
16
+ def __init__(self, data: dict):
17
+ self._data = data
18
+ self._agents = {a["name"]: a for a in data.get("agents", [])}
19
+ self._groups = {g["name"]: g for g in data.get("groups", [])}
20
+
21
+ @classmethod
22
+ def load(cls, path: Path) -> "Manifest":
23
+ """Load a manifest from a YAML file."""
24
+ try:
25
+ with open(path, "r") as f:
26
+ data = yaml.safe_load(f) or {}
27
+ except FileNotFoundError:
28
+ raise ManifestError(f"Manifest not found: {path}")
29
+ except yaml.YAMLError as e:
30
+ raise ManifestError(f"Invalid manifest YAML: {e}")
31
+ return cls(data)
32
+
33
+ def agent_groups(self, agent_name: str) -> list[str]:
34
+ """Return the list of groups an agent belongs to."""
35
+ agent = self._agents.get(agent_name)
36
+ if agent is None:
37
+ return []
38
+ return list(agent.get("groups", []))
39
+
40
+ def group_secrets(self, group_name: str) -> list[str]:
41
+ """Return the list of secret paths in a group."""
42
+ group = self._groups.get(group_name)
43
+ if group is None:
44
+ return []
45
+ return list(group.get("secrets", []))
46
+
47
+ def agents_for_secret(self, secret_path: str) -> list[str]:
48
+ """Return agent names authorized for a given secret."""
49
+ agents = []
50
+ for agent_name, agent in self._agents.items():
51
+ for group_name in agent.get("groups", []):
52
+ group = self._groups.get(group_name)
53
+ if group and secret_path in group.get("secrets", []):
54
+ agents.append(agent_name)
55
+ break
56
+ return agents
57
+
58
+ def list_agents(self) -> list[dict]:
59
+ """Return all agents with their group memberships."""
60
+ return [
61
+ {"name": a["name"], "groups": list(a.get("groups", []))}
62
+ for a in self._data.get("agents", [])
63
+ ]
64
+
65
+ def list_groups(self) -> list[str]:
66
+ """Return all group names."""
67
+ return list(self._groups.keys())
68
+
69
+ def list_secrets(self, group: Optional[str] = None) -> list[str]:
70
+ """Return all secret paths, optionally filtered by group."""
71
+ if group is not None:
72
+ return self.group_secrets(group)
73
+ secrets = []
74
+ for g in self._groups.values():
75
+ secrets.extend(g.get("secrets", []))
76
+ return secrets
@@ -0,0 +1,117 @@
1
+ """MCP server exposing agent_vault_get tool via stdio transport.
2
+
3
+ Usage:
4
+ agent-vault-mcp --repo /path/to/vault --key ~/.agent-vault/agents/my-agent.key
5
+
6
+ The server holds the agent's private key in memory so the agent process
7
+ never touches key material directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+
18
+ def create_server(repo_path: str, key_path: str | None = None, key_str: str | None = None):
19
+ """Create and configure the MCP server.
20
+
21
+ Importing mcp is deferred so the module can be imported without the
22
+ mcp extra installed (e.g., for type checking or vault-only usage).
23
+ """
24
+ try:
25
+ from mcp.server.fastmcp import FastMCP
26
+ except ImportError:
27
+ print(
28
+ "Error: MCP server requires the 'mcp' extra.\n"
29
+ "Install with: pip install 'agent-vault[mcp]'",
30
+ file=sys.stderr,
31
+ )
32
+ sys.exit(1)
33
+
34
+ from agent_vault.vault import Vault
35
+
36
+ vault = Vault(
37
+ repo_path=repo_path,
38
+ key_path=key_path,
39
+ key_str=key_str,
40
+ auto_pull=True,
41
+ )
42
+
43
+ mcp = FastMCP("agent-vault")
44
+
45
+ @mcp.tool()
46
+ def agent_vault_get(secret: str) -> str:
47
+ """Retrieve a decrypted secret from the agent-vault.
48
+
49
+ Args:
50
+ secret: The secret path (e.g. "stripe/api-key").
51
+
52
+ Returns:
53
+ The decrypted plaintext value.
54
+ """
55
+ return vault.get(secret)
56
+
57
+ @mcp.tool()
58
+ def agent_vault_list(group: str | None = None) -> str:
59
+ """List available secrets in the vault.
60
+
61
+ Args:
62
+ group: Optional group name to filter by.
63
+
64
+ Returns:
65
+ A formatted list of secrets with metadata.
66
+ """
67
+ secrets = vault.list_secrets(group)
68
+ if not secrets:
69
+ return "No secrets found."
70
+
71
+ lines = []
72
+ for meta in secrets:
73
+ expires_str = ""
74
+ if meta.expires:
75
+ expires_str = f" expires={meta.expires.strftime('%Y-%m-%d')}"
76
+ lines.append(
77
+ f"{meta.name} group={meta.group} "
78
+ f"agents=[{', '.join(meta.authorized_agents)}] "
79
+ f"rotated={meta.rotated.strftime('%Y-%m-%d')}"
80
+ f"{expires_str}"
81
+ )
82
+ return "\n".join(lines)
83
+
84
+ return mcp
85
+
86
+
87
+ def main():
88
+ """Entry point for the agent-vault-mcp command."""
89
+ parser = argparse.ArgumentParser(
90
+ description="MCP server for agent-vault credential retrieval"
91
+ )
92
+ parser.add_argument(
93
+ "--repo",
94
+ default=os.getcwd(),
95
+ help="Path to the Git repository containing the vault (default: cwd)",
96
+ )
97
+ parser.add_argument(
98
+ "--key",
99
+ default=None,
100
+ help="Path to the agent's private key file",
101
+ )
102
+ args = parser.parse_args()
103
+
104
+ # Also support AGENT_VAULT_KEY env var (key as string)
105
+ key_str = os.environ.get("AGENT_VAULT_KEY")
106
+
107
+ server = create_server(
108
+ repo_path=args.repo,
109
+ key_path=args.key,
110
+ key_str=key_str if not args.key else None,
111
+ )
112
+
113
+ server.run(transport="stdio")
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1,46 @@
1
+ """Secret metadata parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import yaml
11
+
12
+
13
+ @dataclass
14
+ class SecretMetadata:
15
+ """Plaintext metadata for a single secret."""
16
+
17
+ name: str
18
+ group: str
19
+ created: datetime
20
+ rotated: datetime
21
+ expires: Optional[datetime]
22
+ authorized_agents: list[str]
23
+
24
+ @classmethod
25
+ def load(cls, path: Path) -> "SecretMetadata":
26
+ """Load metadata from a .meta YAML file."""
27
+ with open(path, "r") as f:
28
+ data = yaml.safe_load(f) or {}
29
+
30
+ return cls(
31
+ name=data.get("name", ""),
32
+ group=data.get("group", ""),
33
+ created=_parse_dt(data.get("created")),
34
+ rotated=_parse_dt(data.get("rotated")),
35
+ expires=_parse_dt(data.get("expires")) if data.get("expires") else None,
36
+ authorized_agents=list(data.get("authorized_agents", [])),
37
+ )
38
+
39
+
40
+ def _parse_dt(val) -> datetime:
41
+ """Parse a datetime from YAML (may be string or datetime)."""
42
+ if isinstance(val, datetime):
43
+ return val
44
+ if isinstance(val, str):
45
+ return datetime.fromisoformat(val)
46
+ return datetime.min
@@ -0,0 +1,233 @@
1
+ """Main Vault class — read-only agent access to secrets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from agent_vault.crypto import decrypt_secret, load_identity, load_identity_from_str
13
+ from agent_vault.errors import (
14
+ NotAuthorizedError,
15
+ SecretNotFoundError,
16
+ VaultNotFoundError,
17
+ )
18
+ from agent_vault.manifest import Manifest
19
+ from agent_vault.metadata import SecretMetadata
20
+
21
+ logger = logging.getLogger("agent_vault")
22
+
23
+
24
+ def _resolve_repo_path(repo_path: str | Path) -> Path:
25
+ """Resolve repo_path to a local directory.
26
+
27
+ If repo_path is a Git remote URL (https://, git@, ssh://, git://),
28
+ clones or updates a cached copy under ~/.agent-vault/cache/<hash>.
29
+ Otherwise returns the local path directly.
30
+ """
31
+ path_str = str(repo_path)
32
+
33
+ if not any(
34
+ path_str.startswith(prefix)
35
+ for prefix in ("https://", "git@", "ssh://", "git://")
36
+ ):
37
+ return Path(repo_path).expanduser().resolve()
38
+
39
+ # Compute stable cache directory from URL
40
+ url_hash = hashlib.sha256(path_str.encode()).hexdigest()[:16]
41
+ cache_dir = Path.home() / ".agent-vault" / "cache" / url_hash
42
+
43
+ try:
44
+ import git as gitmodule
45
+
46
+ if cache_dir.exists() and (cache_dir / ".git").exists():
47
+ try:
48
+ repo = gitmodule.Repo(str(cache_dir))
49
+ if repo.remotes:
50
+ repo.remotes[0].pull(rebase=False)
51
+ except Exception as e:
52
+ logger.warning("git pull failed for cached repo: %s", e)
53
+ print(
54
+ f"Warning: git pull failed for cached repo: {e}",
55
+ file=sys.stderr,
56
+ )
57
+ else:
58
+ cache_dir.parent.mkdir(parents=True, exist_ok=True)
59
+ gitmodule.Repo.clone_from(path_str, str(cache_dir))
60
+ except Exception as e:
61
+ raise VaultNotFoundError(f"Failed to clone/update {path_str}: {e}") from e
62
+
63
+ return cache_dir
64
+
65
+
66
+ class Vault:
67
+ """Read-only vault for agents to retrieve secrets.
68
+
69
+ Example::
70
+
71
+ vault = Vault(
72
+ repo_path="/path/to/vault",
73
+ key_path="~/.agent-vault/agents/my-agent.key",
74
+ )
75
+ api_key = vault.get("stripe/api-key")
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ repo_path: str | Path,
81
+ key_path: Optional[str | Path] = None,
82
+ key_str: Optional[str] = None,
83
+ auto_pull: bool = True,
84
+ ):
85
+ """Initialize the vault.
86
+
87
+ Args:
88
+ repo_path: Path to the Git repository (local or remote URL).
89
+ key_path: Path to the age private key file. If not provided,
90
+ falls back to AGENT_VAULT_KEY env var (as key string),
91
+ then ~/.agent-vault/owner.key.
92
+ key_str: Raw age private key string. Overrides key_path.
93
+ auto_pull: Whether to git pull before each get() call.
94
+ """
95
+ self._repo_path = _resolve_repo_path(repo_path)
96
+ self._vault_dir = self._repo_path / ".agent-vault"
97
+ self._auto_pull = auto_pull
98
+
99
+ if not self._vault_dir.is_dir():
100
+ raise VaultNotFoundError(
101
+ f"No vault found at {self._repo_path}. "
102
+ "Run 'agent-vault init' first."
103
+ )
104
+
105
+ # Load identity (private key)
106
+ if key_str is not None:
107
+ self._identity = load_identity_from_str(key_str)
108
+ elif key_path is not None:
109
+ self._identity = load_identity(str(Path(key_path).expanduser()))
110
+ elif os.environ.get("AGENT_VAULT_KEY"):
111
+ self._identity = load_identity_from_str(os.environ["AGENT_VAULT_KEY"])
112
+ else:
113
+ default_key = Path.home() / ".agent-vault" / "owner.key"
114
+ if default_key.exists():
115
+ self._identity = load_identity(str(default_key))
116
+ else:
117
+ raise VaultNotFoundError(
118
+ "No key provided. Pass key_path=, key_str=, "
119
+ "set AGENT_VAULT_KEY env var, or ensure "
120
+ "~/.agent-vault/owner.key exists."
121
+ )
122
+
123
+ # Load manifest
124
+ self._manifest = Manifest.load(self._vault_dir / "manifest.yaml")
125
+
126
+ def pull(self) -> None:
127
+ """Pull latest changes from the Git remote (best-effort)."""
128
+ try:
129
+ import git
130
+
131
+ repo = git.Repo(str(self._repo_path))
132
+ if repo.remotes:
133
+ origin = repo.remotes[0]
134
+ origin.pull(rebase=False)
135
+ except Exception as e:
136
+ logger.warning("git pull failed (continuing with local state): %s", e)
137
+ print(f"Warning: git pull failed: {e}", file=sys.stderr)
138
+
139
+ def get(self, secret_path: str) -> str:
140
+ """Retrieve and decrypt a secret.
141
+
142
+ Args:
143
+ secret_path: The secret path (e.g. "stripe/api-key").
144
+
145
+ Returns:
146
+ The decrypted plaintext value.
147
+
148
+ Raises:
149
+ SecretNotFoundError: If the secret doesn't exist.
150
+ NotAuthorizedError: If the key can't decrypt the secret.
151
+ """
152
+ if self._auto_pull:
153
+ self.pull()
154
+
155
+ # Resolve the encrypted file path
156
+ # Secret path "stripe/api-key" -> .agent-vault/secrets/stripe/api-key.enc
157
+ enc_path = self._vault_dir / "secrets" / _to_file_path(secret_path, ".enc")
158
+
159
+ if not enc_path.exists():
160
+ raise SecretNotFoundError(f"Secret not found: {secret_path}")
161
+
162
+ ciphertext = enc_path.read_bytes()
163
+
164
+ try:
165
+ return decrypt_secret(ciphertext, self._identity)
166
+ except Exception as e:
167
+ raise NotAuthorizedError(
168
+ f"Cannot decrypt '{secret_path}': {e}"
169
+ ) from e
170
+
171
+ def list_secrets(self, group: Optional[str] = None) -> list[SecretMetadata]:
172
+ """List secret metadata without decrypting.
173
+
174
+ Args:
175
+ group: Optional group name to filter by.
176
+
177
+ Returns:
178
+ List of SecretMetadata objects.
179
+ """
180
+ secrets_dir = self._vault_dir / "secrets"
181
+ if not secrets_dir.exists():
182
+ return []
183
+
184
+ results = []
185
+ for meta_path in sorted(secrets_dir.rglob("*.meta")):
186
+ try:
187
+ meta = SecretMetadata.load(meta_path)
188
+ if group is None or meta.group == group:
189
+ results.append(meta)
190
+ except Exception:
191
+ continue
192
+
193
+ return results
194
+
195
+ def list_agents(self) -> list[dict]:
196
+ """List all agents and their group memberships.
197
+
198
+ Returns:
199
+ List of dicts with "name" and "groups" keys.
200
+ """
201
+ return self._manifest.list_agents()
202
+
203
+ @property
204
+ def manifest(self) -> Manifest:
205
+ """Access the parsed manifest."""
206
+ return self._manifest
207
+
208
+ def reload(self) -> None:
209
+ """Reload the manifest from disk (e.g. after a pull)."""
210
+ self._manifest = Manifest.load(self._vault_dir / "manifest.yaml")
211
+
212
+ def __enter__(self) -> "Vault":
213
+ return self
214
+
215
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
216
+ return False
217
+
218
+
219
+ def _to_file_path(secret_path: str, suffix: str) -> Path:
220
+ """Convert a secret path like "stripe/api-key" to a file path.
221
+
222
+ The convention used by the Rust CLI is:
223
+ secret_path = "group/name"
224
+ file = secrets/group/name.enc (and .meta)
225
+
226
+ But the actual file path uses the last component as the filename.
227
+ e.g. "stripe/api-key" -> "stripe/api-key.enc"
228
+ """
229
+ parts = secret_path.split("/")
230
+ if len(parts) < 2:
231
+ return Path(parts[0] + suffix)
232
+ # group/name -> group/name.enc
233
+ return Path(*parts[:-1]) / (parts[-1] + suffix)
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-vault-sdk
3
+ Version: 0.1.0
4
+ Summary: Zero-trust credential manager for AI agents — Python SDK
5
+ Author: agent-vault contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ewimsatt/agent-vault
8
+ Project-URL: Repository, https://github.com/ewimsatt/agent-vault
9
+ Project-URL: Issues, https://github.com/ewimsatt/agent-vault/issues
10
+ Keywords: security,encryption,credentials,ai-agents,age,mcp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: pyrage>=1.2.0
22
+ Requires-Dist: pyyaml>=6.0
23
+ Requires-Dist: gitpython>=3.1
24
+ Provides-Extra: mcp
25
+ Requires-Dist: mcp>=1.2.0; extra == "mcp"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
29
+
30
+ # agent-vault Python SDK
31
+
32
+ Read-only Python SDK for [agent-vault](https://github.com/ewimsatt/agent-vault) — a zero-trust credential manager for AI agents.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install agent-vault
38
+
39
+ # With MCP server support:
40
+ pip install 'agent-vault[mcp]'
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```python
46
+ from agent_vault import Vault
47
+
48
+ vault = Vault(
49
+ repo_path="/path/to/vault",
50
+ key_path="~/.agent-vault/agents/my-agent.key",
51
+ )
52
+
53
+ # Pull latest and decrypt
54
+ api_key = vault.get("stripe/api-key")
55
+ ```
56
+
57
+ ## Key Resolution
58
+
59
+ The SDK resolves the identity key in this order:
60
+
61
+ 1. `key_str=` parameter (raw key string)
62
+ 2. `key_path=` parameter (path to key file)
63
+ 3. `AGENT_VAULT_KEY` environment variable (key as string)
64
+ 4. `~/.agent-vault/owner.key` (default owner key)
65
+
66
+ ## API
67
+
68
+ ### `Vault(repo_path, key_path=None, key_str=None, auto_pull=True)`
69
+
70
+ Create a read-only vault connection.
71
+
72
+ - `repo_path`: Path to the Git repo containing `.agent-vault/`
73
+ - `key_path`: Path to an age private key file
74
+ - `key_str`: Raw age private key string
75
+ - `auto_pull`: Git pull before each `get()` (default: True)
76
+
77
+ ### `vault.get(secret_path) -> str`
78
+
79
+ Decrypt and return a secret. Raises `SecretNotFoundError` or `NotAuthorizedError`.
80
+
81
+ ### `vault.list_secrets(group=None) -> list[SecretMetadata]`
82
+
83
+ List secret metadata without decrypting.
84
+
85
+ ### `vault.list_agents() -> list[dict]`
86
+
87
+ List agents and their group memberships.
88
+
89
+ ### `vault.pull()`
90
+
91
+ Manually pull latest changes from Git remote.
92
+
93
+ ### `vault.reload()`
94
+
95
+ Reload the manifest from disk (e.g., after a pull).
96
+
97
+ ## MCP Server
98
+
99
+ The package includes an MCP server for use with MCP-compatible AI agents:
100
+
101
+ ```bash
102
+ agent-vault-mcp --repo /path/to/vault --key ~/.agent-vault/agents/my-agent.key
103
+ ```
104
+
105
+ This runs a stdio-based MCP server exposing:
106
+
107
+ - `agent_vault_get(secret)` — retrieve and decrypt a secret
108
+ - `agent_vault_list(group?)` — list available secrets
109
+
110
+ ### Claude Desktop Configuration
111
+
112
+ ```json
113
+ {
114
+ "mcpServers": {
115
+ "agent-vault": {
116
+ "command": "agent-vault-mcp",
117
+ "args": ["--repo", "/path/to/vault", "--key", "/path/to/agent.key"]
118
+ }
119
+ }
120
+ }
121
+ ```
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/agent_vault/__init__.py
4
+ src/agent_vault/crypto.py
5
+ src/agent_vault/errors.py
6
+ src/agent_vault/manifest.py
7
+ src/agent_vault/mcp_server.py
8
+ src/agent_vault/metadata.py
9
+ src/agent_vault/vault.py
10
+ src/agent_vault_sdk.egg-info/PKG-INFO
11
+ src/agent_vault_sdk.egg-info/SOURCES.txt
12
+ src/agent_vault_sdk.egg-info/dependency_links.txt
13
+ src/agent_vault_sdk.egg-info/entry_points.txt
14
+ src/agent_vault_sdk.egg-info/requires.txt
15
+ src/agent_vault_sdk.egg-info/top_level.txt
16
+ tests/test_vault.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-vault-mcp = agent_vault.mcp_server:main
@@ -0,0 +1,10 @@
1
+ pyrage>=1.2.0
2
+ pyyaml>=6.0
3
+ gitpython>=3.1
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ pytest-asyncio>=0.21
8
+
9
+ [mcp]
10
+ mcp>=1.2.0
@@ -0,0 +1,337 @@
1
+ """Tests for agent-vault Python SDK.
2
+
3
+ These tests create a vault using the Rust CLI, then verify the Python SDK
4
+ can read secrets, list metadata, and handle errors correctly.
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from agent_vault import Vault, SecretNotFoundError, NotAuthorizedError, VaultNotFoundError
16
+
17
+
18
+ # Resolve the pre-built binary path once at module load
19
+ _BINARY = str(Path(__file__).parent.parent.parent / "target" / "debug" / "agent-vault")
20
+
21
+
22
+ def _run_cli(*args, cwd, env):
23
+ """Run the agent-vault CLI binary."""
24
+ result = subprocess.run(
25
+ [_BINARY] + list(args),
26
+ cwd=cwd,
27
+ env=env,
28
+ capture_output=True,
29
+ text=True,
30
+ )
31
+ if result.returncode != 0:
32
+ raise RuntimeError(
33
+ f"CLI failed: {args}\nstdout: {result.stdout}\nstderr: {result.stderr}"
34
+ )
35
+ return result
36
+
37
+
38
+ @pytest.fixture
39
+ def vault_env(tmp_path):
40
+ """Set up a temporary vault with a secret for testing."""
41
+ repo = tmp_path / "repo"
42
+ repo.mkdir()
43
+ fake_home = tmp_path / "fakehome"
44
+ fake_home.mkdir()
45
+
46
+ env = os.environ.copy()
47
+ env["HOME"] = str(fake_home)
48
+
49
+ # Init git repo
50
+ subprocess.run(["git", "init", str(repo)], capture_output=True, check=True)
51
+ subprocess.run(
52
+ ["git", "config", "user.email", "test@test.com"],
53
+ cwd=str(repo), capture_output=True, check=True
54
+ )
55
+ subprocess.run(
56
+ ["git", "config", "user.name", "Test"],
57
+ cwd=str(repo), capture_output=True, check=True
58
+ )
59
+
60
+ # Init vault
61
+ _run_cli("init", cwd=str(repo), env=env)
62
+
63
+ # Add agent
64
+ _run_cli("add-agent", "test-bot", cwd=str(repo), env=env)
65
+
66
+ # Set a secret
67
+ _run_cli("set", "stripe/api-key", "sk_test_123", cwd=str(repo), env=env)
68
+
69
+ # Grant agent access
70
+ _run_cli("grant", "test-bot", "stripe", cwd=str(repo), env=env)
71
+
72
+ owner_key = fake_home / ".agent-vault" / "owner.key"
73
+ agent_key = fake_home / ".agent-vault" / "agents" / "test-bot.key"
74
+
75
+ return {
76
+ "repo": repo,
77
+ "fake_home": fake_home,
78
+ "env": env,
79
+ "owner_key": owner_key,
80
+ "agent_key": agent_key,
81
+ }
82
+
83
+
84
+ class TestVaultInit:
85
+ def test_vault_not_found(self, tmp_path):
86
+ """Opening a vault on a dir without .agent-vault raises."""
87
+ with pytest.raises(VaultNotFoundError):
88
+ Vault(repo_path=tmp_path, key_str="AGE-SECRET-KEY-1FAKE")
89
+
90
+ def test_no_key_raises(self, vault_env):
91
+ """Vault without key raises an error."""
92
+ # Clear env so no fallback key is found
93
+ env = vault_env["env"].copy()
94
+ env.pop("AGENT_VAULT_KEY", None)
95
+ # Use a fake home with no owner.key
96
+ fake_home2 = vault_env["fake_home"].parent / "emptyhome"
97
+ fake_home2.mkdir()
98
+ env["HOME"] = str(fake_home2)
99
+
100
+ # Temporarily change HOME for the Vault constructor
101
+ old_home = os.environ.get("HOME")
102
+ try:
103
+ os.environ["HOME"] = str(fake_home2)
104
+ with pytest.raises(VaultNotFoundError, match="No key"):
105
+ Vault(repo_path=vault_env["repo"])
106
+ finally:
107
+ if old_home:
108
+ os.environ["HOME"] = old_home
109
+
110
+
111
+ class TestVaultGet:
112
+ def test_get_with_owner_key(self, vault_env):
113
+ """Owner can decrypt secrets."""
114
+ vault = Vault(
115
+ repo_path=vault_env["repo"],
116
+ key_path=vault_env["owner_key"],
117
+ auto_pull=False,
118
+ )
119
+ assert vault.get("stripe/api-key") == "sk_test_123"
120
+
121
+ def test_get_with_agent_key(self, vault_env):
122
+ """Agent with granted access can decrypt secrets."""
123
+ vault = Vault(
124
+ repo_path=vault_env["repo"],
125
+ key_path=vault_env["agent_key"],
126
+ auto_pull=False,
127
+ )
128
+ assert vault.get("stripe/api-key") == "sk_test_123"
129
+
130
+ def test_get_nonexistent_secret(self, vault_env):
131
+ """Requesting a missing secret raises SecretNotFoundError."""
132
+ vault = Vault(
133
+ repo_path=vault_env["repo"],
134
+ key_path=vault_env["owner_key"],
135
+ auto_pull=False,
136
+ )
137
+ with pytest.raises(SecretNotFoundError):
138
+ vault.get("nope/missing")
139
+
140
+ def test_get_unauthorized(self, vault_env):
141
+ """Agent without access gets NotAuthorizedError."""
142
+ # Add a second agent without granting access
143
+ _run_cli("add-agent", "no-access-bot", cwd=str(vault_env["repo"]), env=vault_env["env"])
144
+ no_access_key = vault_env["fake_home"] / ".agent-vault" / "agents" / "no-access-bot.key"
145
+
146
+ vault = Vault(
147
+ repo_path=vault_env["repo"],
148
+ key_path=no_access_key,
149
+ auto_pull=False,
150
+ )
151
+ with pytest.raises(NotAuthorizedError):
152
+ vault.get("stripe/api-key")
153
+
154
+ def test_get_with_key_str(self, vault_env):
155
+ """Can load key from string instead of file."""
156
+ key_content = vault_env["owner_key"].read_text()
157
+ vault = Vault(
158
+ repo_path=vault_env["repo"],
159
+ key_str=key_content,
160
+ auto_pull=False,
161
+ )
162
+ assert vault.get("stripe/api-key") == "sk_test_123"
163
+
164
+ def test_get_with_env_var(self, vault_env):
165
+ """Can load key from AGENT_VAULT_KEY env var."""
166
+ key_content = vault_env["owner_key"].read_text()
167
+ old_env = os.environ.get("AGENT_VAULT_KEY")
168
+ old_home = os.environ.get("HOME")
169
+ try:
170
+ os.environ["AGENT_VAULT_KEY"] = key_content
171
+ # Set HOME to empty dir so it doesn't find owner.key
172
+ empty = vault_env["fake_home"].parent / "emptyhome2"
173
+ empty.mkdir(exist_ok=True)
174
+ os.environ["HOME"] = str(empty)
175
+
176
+ vault = Vault(
177
+ repo_path=vault_env["repo"],
178
+ auto_pull=False,
179
+ )
180
+ assert vault.get("stripe/api-key") == "sk_test_123"
181
+ finally:
182
+ if old_env is None:
183
+ os.environ.pop("AGENT_VAULT_KEY", None)
184
+ else:
185
+ os.environ["AGENT_VAULT_KEY"] = old_env
186
+ if old_home:
187
+ os.environ["HOME"] = old_home
188
+
189
+
190
+ class TestVaultList:
191
+ def test_list_secrets(self, vault_env):
192
+ """Can list secrets with metadata."""
193
+ vault = Vault(
194
+ repo_path=vault_env["repo"],
195
+ key_path=vault_env["owner_key"],
196
+ auto_pull=False,
197
+ )
198
+ secrets = vault.list_secrets()
199
+ assert len(secrets) == 1
200
+ assert secrets[0].name == "stripe/api-key"
201
+ assert secrets[0].group == "stripe"
202
+
203
+ def test_list_secrets_by_group(self, vault_env):
204
+ """Can filter secrets by group."""
205
+ # Add another secret in a different group
206
+ _run_cli(
207
+ "set", "postgres/conn", "postgres://...",
208
+ "--group", "postgres",
209
+ cwd=str(vault_env["repo"]),
210
+ env=vault_env["env"],
211
+ )
212
+
213
+ vault = Vault(
214
+ repo_path=vault_env["repo"],
215
+ key_path=vault_env["owner_key"],
216
+ auto_pull=False,
217
+ )
218
+ all_secrets = vault.list_secrets()
219
+ assert len(all_secrets) == 2
220
+
221
+ stripe_only = vault.list_secrets(group="stripe")
222
+ assert len(stripe_only) == 1
223
+ assert stripe_only[0].name == "stripe/api-key"
224
+
225
+ def test_list_agents(self, vault_env):
226
+ """Can list agents with group memberships."""
227
+ vault = Vault(
228
+ repo_path=vault_env["repo"],
229
+ key_path=vault_env["owner_key"],
230
+ auto_pull=False,
231
+ )
232
+ agents = vault.list_agents()
233
+ assert len(agents) == 1
234
+ assert agents[0]["name"] == "test-bot"
235
+ assert "stripe" in agents[0]["groups"]
236
+
237
+
238
+ class TestMultipleSecrets:
239
+ def test_multiple_secrets_and_groups(self, vault_env):
240
+ """Can handle multiple secrets across groups."""
241
+ _run_cli(
242
+ "set", "stripe/webhook-secret", "whsec_456",
243
+ cwd=str(vault_env["repo"]),
244
+ env=vault_env["env"],
245
+ )
246
+ _run_cli(
247
+ "set", "postgres/conn", "postgres://localhost",
248
+ "--group", "postgres",
249
+ cwd=str(vault_env["repo"]),
250
+ env=vault_env["env"],
251
+ )
252
+
253
+ vault = Vault(
254
+ repo_path=vault_env["repo"],
255
+ key_path=vault_env["owner_key"],
256
+ auto_pull=False,
257
+ )
258
+
259
+ assert vault.get("stripe/api-key") == "sk_test_123"
260
+ assert vault.get("stripe/webhook-secret") == "whsec_456"
261
+ assert vault.get("postgres/conn") == "postgres://localhost"
262
+
263
+ assert len(vault.list_secrets()) == 3
264
+ assert len(vault.list_secrets(group="stripe")) == 2
265
+ assert len(vault.list_secrets(group="postgres")) == 1
266
+
267
+
268
+ class TestPullWarnings:
269
+ def test_pull_warns_on_failure(self, vault_env, capsys):
270
+ """Pull failure logs to stderr instead of silently swallowing."""
271
+ import shutil
272
+
273
+ vault = Vault(
274
+ repo_path=vault_env["repo"],
275
+ key_path=vault_env["owner_key"],
276
+ auto_pull=False,
277
+ )
278
+ # Break git by temporarily renaming .git
279
+ git_dir = vault_env["repo"] / ".git"
280
+ git_dir_backup = vault_env["repo"] / ".git_backup"
281
+ shutil.move(str(git_dir), str(git_dir_backup))
282
+
283
+ try:
284
+ vault.pull() # Should warn, not raise
285
+ captured = capsys.readouterr()
286
+ assert "Warning" in captured.err
287
+ finally:
288
+ shutil.move(str(git_dir_backup), str(git_dir))
289
+
290
+
291
+ class TestResolveRepoPath:
292
+ def test_local_path_unchanged(self):
293
+ """Local paths pass through unchanged."""
294
+ from agent_vault.vault import _resolve_repo_path
295
+
296
+ result = _resolve_repo_path("/tmp/some/path")
297
+ # On macOS /tmp -> /private/tmp, so compare resolved paths
298
+ assert result == Path("/tmp/some/path").resolve()
299
+
300
+ def test_url_detected(self):
301
+ """URL-like strings are detected as remote."""
302
+ from agent_vault.vault import _resolve_repo_path
303
+
304
+ # These should be detected as URLs (will fail to clone, but
305
+ # we're testing detection, not actual cloning)
306
+ for url in [
307
+ "https://github.com/example/repo.git",
308
+ "git@github.com:example/repo.git",
309
+ "ssh://git@github.com/example/repo.git",
310
+ "git://github.com/example/repo.git",
311
+ ]:
312
+ from agent_vault.errors import VaultNotFoundError
313
+ try:
314
+ _resolve_repo_path(url)
315
+ except VaultNotFoundError:
316
+ pass # Expected — can't actually clone
317
+ except Exception:
318
+ pass # Network error is also fine
319
+
320
+ def test_relative_path_not_url(self):
321
+ """Relative paths are not treated as URLs."""
322
+ from agent_vault.vault import _resolve_repo_path
323
+
324
+ result = _resolve_repo_path("./my-repo")
325
+ assert not str(result).startswith("https://")
326
+ assert result.is_absolute()
327
+
328
+
329
+ class TestContextManager:
330
+ def test_with_statement(self, vault_env):
331
+ """Vault works as a context manager."""
332
+ with Vault(
333
+ repo_path=vault_env["repo"],
334
+ key_path=vault_env["owner_key"],
335
+ auto_pull=False,
336
+ ) as vault:
337
+ assert vault.get("stripe/api-key") == "sk_test_123"