mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-03 03:12:07 +00:00
Harden agent policy and API import limits; fix docs links
This commit is contained in:
1 parent
ae4fa2498b
commit
0789dfcb55
6 files changed
+280
-60
No files matched your search
@@ -224,7 +224,6 @@ Follow these steps to set up SeedPass on your local machine.
|
||||
python -m pip install --require-hashes -r requirements.lock
|
||||
python -m pip install -e .
|
||||
```
|
||||
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
|
||||
After reinstalling, run `which seedpass` on Linux/macOS or `where seedpass` on Windows to confirm the command resolves to your virtual environment's `seedpass` executable.
|
||||
|
||||
#### Linux Clipboard Support
|
||||
@@ -322,7 +321,7 @@ seedpass util generate-password --length 20 --special-mode safe --exclude-ambigu
|
||||
# on an external drive.
|
||||
```
|
||||
|
||||
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
||||
For additional command examples, see [docs/docs/content/01-getting-started/01-advanced_cli.md](docs/docs/content/01-getting-started/01-advanced_cli.md). Details on the REST API can be found in [docs/docs/content/01-getting-started/02-api_reference.md](docs/docs/content/01-getting-started/02-api_reference.md).
|
||||
|
||||
### Getting Started with the GUI
|
||||
|
||||
@@ -436,14 +435,13 @@ If this command displays `usage: main.py` instead of the Typer help output, an o
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
```
|
||||
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
|
||||
You can confirm which executable will run with:
|
||||
|
||||
```bash
|
||||
which seedpass # or 'where seedpass' on Windows
|
||||
```
|
||||
|
||||
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
|
||||
For a full list of commands see [docs/docs/content/01-getting-started/01-advanced_cli.md](docs/docs/content/01-getting-started/01-advanced_cli.md). The REST API is described in [docs/docs/content/01-getting-started/02-api_reference.md](docs/docs/content/01-getting-started/02-api_reference.md).
|
||||
|
||||
### Agent Mode (Non-Interactive)
|
||||
|
||||
|
||||
@@ -245,8 +245,8 @@ seedpass list --filter totp
|
||||
# on an external drive.
|
||||
```
|
||||
|
||||
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md).
|
||||
Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
||||
For additional command examples, see [01-getting-started/01-advanced_cli.md](01-getting-started/01-advanced_cli.md).
|
||||
Details on the REST API can be found in [01-getting-started/02-api_reference.md](01-getting-started/02-api_reference.md).
|
||||
|
||||
### Vault JSON Layout
|
||||
|
||||
@@ -285,7 +285,7 @@ You can explore other CLI commands using:
|
||||
```bash
|
||||
seedpass --help
|
||||
```
|
||||
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
|
||||
For a full list of commands see [01-getting-started/01-advanced_cli.md](01-getting-started/01-advanced_cli.md). The REST API is described in [01-getting-started/02-api_reference.md](01-getting-started/02-api_reference.md).
|
||||
|
||||
### Running the Application
|
||||
|
||||
|
||||
+55
-43
@@ -7,23 +7,18 @@ import tempfile
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import queue
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
import asyncio
|
||||
import sys
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import bcrypt
|
||||
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from seedpass.core.entry_types import EntryType
|
||||
from seedpass.core.api import UtilityService
|
||||
@@ -31,13 +26,26 @@ from seedpass.core.api import UtilityService
|
||||
_RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100"))
|
||||
_RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60"))
|
||||
_RATE_LIMIT_STR = f"{_RATE_LIMIT}/{_RATE_WINDOW} seconds"
|
||||
_MAX_IMPORT_BYTES = int(os.getenv("SEEDPASS_MAX_IMPORT_BYTES", str(10 * 1024 * 1024)))
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=[_RATE_LIMIT_STR])
|
||||
app = FastAPI()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def dynamic_cors_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
origins = {
|
||||
o.strip() for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",") if o.strip()
|
||||
}
|
||||
request_origin = request.headers.get("origin")
|
||||
if request_origin and request_origin in origins:
|
||||
response.headers["access-control-allow-origin"] = request_origin
|
||||
response.headers["vary"] = "Origin"
|
||||
return response
|
||||
|
||||
|
||||
def _get_pm(request: Request) -> PasswordManager:
|
||||
pm = getattr(request.app.state, "pm", None)
|
||||
assert pm is not None
|
||||
@@ -47,10 +55,31 @@ def _get_pm(request: Request) -> PasswordManager:
|
||||
def _check_token(request: Request, auth: str | None) -> None:
|
||||
if auth is None or not auth.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
token = auth.split(" ", 1)[1].encode()
|
||||
raw_token = auth.split(" ", 1)[1]
|
||||
token = raw_token.encode()
|
||||
token_hash = getattr(request.app.state, "token_hash", b"")
|
||||
if not token_hash or not bcrypt.checkpw(token, token_hash):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_enforce_rate_limit(request, raw_token)
|
||||
|
||||
|
||||
def _enforce_rate_limit(request: Request, raw_token: str) -> None:
|
||||
now = time.monotonic()
|
||||
client = request.client.host if request.client else "unknown"
|
||||
key = f"{client}:{raw_token}"
|
||||
buckets = getattr(request.app.state, "rate_limit_buckets", None)
|
||||
if buckets is None:
|
||||
buckets = defaultdict(deque)
|
||||
request.app.state.rate_limit_buckets = buckets
|
||||
bucket = buckets[key]
|
||||
while bucket and (now - bucket[0]) > _RATE_WINDOW:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= _RATE_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Rate limit exceeded ({_RATE_LIMIT_STR})",
|
||||
)
|
||||
bucket.append(now)
|
||||
|
||||
|
||||
def _reload_relays(request: Request, relays: list[str]) -> None:
|
||||
@@ -82,22 +111,7 @@ def start_server(fingerprint: str | None = None) -> str:
|
||||
app.state.pm = pm
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
app.state.token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt())
|
||||
if not getattr(app.state, "limiter", None):
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
origins = [
|
||||
o.strip()
|
||||
for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",")
|
||||
if o.strip()
|
||||
]
|
||||
if origins and app.middleware_stack is None:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.state.rate_limit_buckets = defaultdict(deque)
|
||||
return raw_token
|
||||
|
||||
|
||||
@@ -126,7 +140,7 @@ async def search_entry(
|
||||
) -> List[Any]:
|
||||
_check_token(request, authorization)
|
||||
pm = _get_pm(request)
|
||||
results = await run_in_threadpool(pm.entry_manager.search_entries, query)
|
||||
results = pm.entry_manager.search_entries(query)
|
||||
return [
|
||||
{
|
||||
"id": idx,
|
||||
@@ -150,7 +164,7 @@ async def get_entry(
|
||||
_check_token(request, authorization)
|
||||
_require_password(request, password)
|
||||
pm = _get_pm(request)
|
||||
entry = await run_in_threadpool(pm.entry_manager.retrieve_entry, entry_id)
|
||||
entry = pm.entry_manager.retrieve_entry(entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return entry
|
||||
@@ -186,8 +200,7 @@ async def create_entry(
|
||||
]
|
||||
kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None}
|
||||
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_entry,
|
||||
index = pm.entry_manager.add_entry(
|
||||
entry.get("label"),
|
||||
int(entry.get("length", 12)),
|
||||
entry.get("username"),
|
||||
@@ -197,10 +210,9 @@ async def create_entry(
|
||||
return {"id": index}
|
||||
|
||||
if etype == "totp":
|
||||
index = await run_in_threadpool(pm.entry_manager.get_next_index)
|
||||
index = pm.entry_manager.get_next_index()
|
||||
|
||||
uri = await run_in_threadpool(
|
||||
pm.entry_manager.add_totp,
|
||||
uri = pm.entry_manager.add_totp(
|
||||
entry.get("label"),
|
||||
pm.KEY_TOTP_DET if entry.get("deterministic", False) else None,
|
||||
secret=entry.get("secret"),
|
||||
@@ -214,8 +226,7 @@ async def create_entry(
|
||||
return {"id": index, "uri": uri}
|
||||
|
||||
if etype == "ssh":
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_ssh_key,
|
||||
index = pm.entry_manager.add_ssh_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
@@ -225,8 +236,7 @@ async def create_entry(
|
||||
return {"id": index}
|
||||
|
||||
if etype == "pgp":
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_pgp_key,
|
||||
index = pm.entry_manager.add_pgp_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
@@ -238,8 +248,7 @@ async def create_entry(
|
||||
return {"id": index}
|
||||
|
||||
if etype == "nostr":
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_nostr_key,
|
||||
index = pm.entry_manager.add_nostr_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
@@ -249,8 +258,7 @@ async def create_entry(
|
||||
return {"id": index}
|
||||
|
||||
if etype == "key_value":
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_key_value,
|
||||
index = pm.entry_manager.add_key_value(
|
||||
entry.get("label"),
|
||||
entry.get("key"),
|
||||
entry.get("value"),
|
||||
@@ -264,8 +272,7 @@ async def create_entry(
|
||||
if etype == "seed"
|
||||
else pm.entry_manager.add_managed_account
|
||||
)
|
||||
index = await run_in_threadpool(
|
||||
func,
|
||||
index = func(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
@@ -638,7 +645,12 @@ async def import_vault(
|
||||
|
||||
if file is None:
|
||||
raise HTTPException(status_code=400, detail="Missing file")
|
||||
data = await file.read()
|
||||
data = await file.read(_MAX_IMPORT_BYTES + 1)
|
||||
if len(data) > _MAX_IMPORT_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Uploaded file exceeds max size of {_MAX_IMPORT_BYTES} bytes",
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
@@ -47,19 +47,45 @@ def _policy_path() -> Path:
|
||||
return APP_DIR / "agent_policy.json"
|
||||
|
||||
|
||||
def _load_policy() -> dict:
|
||||
def _deny_all_policy() -> dict:
|
||||
return {
|
||||
"allow_kinds": [],
|
||||
"deny_private_reveal": list(ALL_ENTRY_TYPES),
|
||||
"allow_export_import": False,
|
||||
}
|
||||
|
||||
|
||||
def _load_policy(*, strict: bool = False) -> dict:
|
||||
path = _policy_path()
|
||||
if not path.exists():
|
||||
return dict(DEFAULT_POLICY)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return dict(DEFAULT_POLICY)
|
||||
except Exception as exc:
|
||||
if strict:
|
||||
raise ValueError("agent policy file is not valid JSON") from exc
|
||||
return _deny_all_policy()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
if strict:
|
||||
raise ValueError("agent policy file must be a JSON object")
|
||||
return _deny_all_policy()
|
||||
|
||||
policy = dict(DEFAULT_POLICY)
|
||||
if isinstance(data.get("allow_kinds"), list):
|
||||
policy["allow_kinds"] = [str(v) for v in data["allow_kinds"]]
|
||||
allow_kinds = [str(v) for v in data["allow_kinds"]]
|
||||
invalid = [v for v in allow_kinds if v not in ALL_ENTRY_TYPES]
|
||||
if invalid and strict:
|
||||
raise ValueError(f"agent policy has invalid allow_kinds entries: {invalid}")
|
||||
policy["allow_kinds"] = [v for v in allow_kinds if v in ALL_ENTRY_TYPES]
|
||||
if isinstance(data.get("deny_private_reveal"), list):
|
||||
policy["deny_private_reveal"] = [str(v) for v in data["deny_private_reveal"]]
|
||||
deny_private = [str(v) for v in data["deny_private_reveal"]]
|
||||
invalid = [v for v in deny_private if v not in ALL_ENTRY_TYPES]
|
||||
if invalid and strict:
|
||||
raise ValueError(
|
||||
f"agent policy has invalid deny_private_reveal entries: {invalid}"
|
||||
)
|
||||
policy["deny_private_reveal"] = [v for v in deny_private if v in ALL_ENTRY_TYPES]
|
||||
if isinstance(data.get("allow_export_import"), bool):
|
||||
policy["allow_export_import"] = data["allow_export_import"]
|
||||
return policy
|
||||
@@ -187,7 +213,11 @@ def agent_init(
|
||||
@app.command("policy-show")
|
||||
def agent_policy_show() -> None:
|
||||
"""Show the active agent policy."""
|
||||
typer.echo(json.dumps(_load_policy(), indent=2))
|
||||
try:
|
||||
policy = _load_policy(strict=True)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc))
|
||||
typer.echo(json.dumps(policy, indent=2))
|
||||
|
||||
|
||||
@app.command("policy-set")
|
||||
@@ -211,7 +241,7 @@ def agent_policy_set(
|
||||
),
|
||||
) -> None:
|
||||
"""Set policy flags used by agent commands."""
|
||||
policy = _load_policy()
|
||||
policy = _load_policy(strict=False)
|
||||
if allow_kind:
|
||||
policy["allow_kinds"] = list(allow_kind)
|
||||
if deny_private_kind:
|
||||
@@ -240,7 +270,20 @@ def agent_get(
|
||||
if not fingerprint:
|
||||
raise typer.BadParameter("Specify target profile with --fingerprint.")
|
||||
password = _password_from_env(password_env)
|
||||
policy = _load_policy()
|
||||
try:
|
||||
policy = _load_policy(strict=True)
|
||||
except ValueError as exc:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "denied",
|
||||
"reason": "invalid_policy",
|
||||
"detail": str(exc),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
pm = PasswordManager(fingerprint=fingerprint, password=password)
|
||||
service = EntryService(pm)
|
||||
|
||||
@@ -284,6 +284,32 @@ async def test_vault_import_via_upload(client, tmp_path):
|
||||
assert called.get("sync") is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_vault_import_upload_too_large(client):
|
||||
cl, token = client
|
||||
called = {}
|
||||
api.app.state.pm.handle_import_database = lambda path: called.setdefault(
|
||||
"import_called", True
|
||||
)
|
||||
api.app.state.pm.sync_vault = lambda: called.setdefault("sync_called", True)
|
||||
|
||||
old_limit = api._MAX_IMPORT_BYTES
|
||||
api._MAX_IMPORT_BYTES = 8
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = await cl.post(
|
||||
"/api/v1/vault/import",
|
||||
files={"file": ("big.json.enc", b"123456789")},
|
||||
headers=headers,
|
||||
)
|
||||
finally:
|
||||
api._MAX_IMPORT_BYTES = old_limit
|
||||
|
||||
assert res.status_code == 413
|
||||
assert "exceeds max size" in res.json()["detail"]
|
||||
assert called == {}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_vault_import_invalid_extension(client):
|
||||
cl, token = client
|
||||
|
||||
@@ -67,6 +67,23 @@ def test_agent_init_creates_profile(monkeypatch, tmp_path):
|
||||
assert (profile_dir / "parent_seed.enc").exists()
|
||||
|
||||
|
||||
def test_agent_init_requires_password_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
monkeypatch.delenv("SEEDPASS_PASSWORD", raising=False)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"agent",
|
||||
"init",
|
||||
"--seed",
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 2
|
||||
combined = f"{result.stdout}{result.stderr}"
|
||||
assert "Missing password env var" in combined
|
||||
|
||||
|
||||
def test_agent_get_requires_fingerprint(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
result = runner.invoke(app, ["agent", "get", "example"])
|
||||
@@ -100,7 +117,7 @@ def test_agent_get_password_json(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda: {"allow_kinds": ["password"], "deny_private_reveal": []},
|
||||
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
|
||||
@@ -136,7 +153,7 @@ def test_agent_get_denied_by_policy(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda: {
|
||||
lambda **kwargs: {
|
||||
"allow_kinds": ["password", "totp", "key_value"],
|
||||
"deny_private_reveal": ["seed"],
|
||||
},
|
||||
@@ -147,3 +164,127 @@ def test_agent_get_denied_by_policy(monkeypatch):
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "denied"
|
||||
assert payload["reason"] in {"kind_not_allowed", "private_kind_blocked"}
|
||||
|
||||
|
||||
def test_agent_get_ambiguous_returns_error_payload(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def search_entries(self, _q):
|
||||
return [
|
||||
(2, "Example1", "", "", False, EntryType.PASSWORD),
|
||||
(3, "Example2", "", "", False, EntryType.PASSWORD),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["reason"] == "ambiguous_or_missing"
|
||||
assert payload["match_count"] == 2
|
||||
|
||||
|
||||
def test_agent_get_key_value_json(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def search_entries(self, _q):
|
||||
return [(2, "Key", "", "", False, EntryType.KEY_VALUE)]
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"kind": EntryType.KEY_VALUE.value, "label": "Key", "value": "v123"}
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda **kwargs: {"allow_kinds": ["key_value"], "deny_private_reveal": []},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "key"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["kind"] == "key_value"
|
||||
assert payload["secret"] == "v123"
|
||||
|
||||
|
||||
def test_agent_get_unsupported_kind_payload(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def search_entries(self, _q):
|
||||
return [(4, "SSH", "", "", False, EntryType.SSH)]
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"kind": EntryType.SSH.value, "label": "SSH"}
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda **kwargs: {"allow_kinds": ["ssh"], "deny_private_reveal": []},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "ssh"])
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["reason"] == "unsupported_kind_for_agent_get"
|
||||
|
||||
|
||||
def test_agent_policy_show_invalid_json(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
|
||||
result = runner.invoke(app, ["agent", "policy-show"])
|
||||
assert result.exit_code == 2
|
||||
combined = f"{result.stdout}{result.stderr}"
|
||||
assert "not valid JSON" in combined
|
||||
|
||||
|
||||
def test_agent_get_denied_when_policy_invalid(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "denied"
|
||||
assert payload["reason"] == "invalid_policy"
|
||||
Reference in new issue
Block a user