Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ and deserialized through a registry.
| `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. |
| `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. |
| `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. |
| `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. |
| `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. |
| `examples/` | The bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker), registered under `agent_env.bundles` in `pyproject.toml`. Tests run them, but they are examples for users, not fixtures. |

## Extension points
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ uv tool install agentenv-framework
agent-env run hello
```

`agent-env run hello` runs the built-in hello task, which needs no Docker, model or configuration. `agent-env run echo-mcp` needs Docker: it builds an MCP server from a Dockerfile, deploys it through the gateway and checks it by calling its tool. `agent-env run` lists both. To try hello without installing anything, run it with uvx:
`agent-env run hello` runs the built-in hello task, which needs no Docker, model or configuration. `agent-env run echo-mcp` needs Docker: it builds an MCP server from a Dockerfile, deploys it through the gateway and checks it by calling its tool. `agent-env run` lists both. `agent-env bundle init <folder> --from-task <id>` writes a task a store holds as a bundle folder, with each reference it makes pinned at the version the store holds, so you can run and edit it locally. To try hello without installing anything, run it with uvx:

```bash
uvx --from agentenv-framework agent-env run hello
Expand Down
60 changes: 60 additions & 0 deletions src/agent_env/bundle/_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Secrets a bundle import leaves out: env vars that name or hold one, and credentials anywhere else in a value."""

from __future__ import annotations

import re
from typing import Any

from agent_env.config.describe import is_secret_key

# The fields that map environment variable names to values, where a credential is most often set.
_ENV_VAR_MAPS = ("env_vars", "default_env_vars")
# Each credential a value can hold, how to find it and what masks it: a URL's user info when it holds a password or is
# long enough to be a token rather than a user name such as git, then values in the shape their issuers give them.
_CREDENTIALS = (
("a URL's credentials", re.compile(r"(?P<scheme>[a-zA-Z][\w+.-]*://)(?:[^/@\s]*:[^/@\s]*|[^/@\s:]{16,})@"),
r"\g<scheme>***@"),
("an API key", re.compile(r"\bsk[-_](?=[\w-]*\d)[\w-]{20,}"), "***"),
("a GitHub token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_\w{22,})"), "***"),
("an AWS access key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), "***"),
("a Slack token", re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}"), "***"),
("a Google API key", re.compile(r"\bAIza[\w-]{35}"), "***"),
("a Hugging Face token", re.compile(r"\bhf_[A-Za-z0-9]{30,}"), "***"),
("a JWT", re.compile(r"\beyJ[\w-]{8,}\.eyJ[\w-]{8,}\.[\w-]{8,}"), "***"),
("a private key", re.compile(r"-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----"
r"(?:.*?-----END (?:[A-Z]+ )*PRIVATE KEY-----|.*)", re.DOTALL), "***"),
)


def scrub(value: Any, path: str = "") -> list[tuple[str, str]]:
"""Drop secret env vars from ``value`` and mask credentials elsewhere in it, in place; each one's path and why."""
removed = []
items = value.items() if isinstance(value, dict) else enumerate(value) if isinstance(value, list) else ()
for key, item in list(items):
at = f"{path}.{key}" if path and isinstance(key, str) else f"{path}[{key}]" if path else str(key)
if isinstance(value, dict) and key in _ENV_VAR_MAPS and isinstance(item, dict):
for name, setting in list(item.items()):
secret = is_secret_key(name)
found = _masked(setting)[1] if isinstance(setting, str) and not secret else []
if secret or found:
del item[name]
why = "its name looks like a secret" if secret else f"its value holds {found[0]}"
removed.append((f"{at}.{name}", f"dropped: {why}"))
elif isinstance(item, str):
masked, found = _masked(item)
if found:
value[key] = masked
removed.append((at, f"masked: {', '.join(found)}"))
else:
removed += scrub(item, at)
return removed


def _masked(text: str) -> tuple[str, list[str]]:
"""``text`` with each credential replaced by ``***``, and the kinds it held."""
found = []
for what, pattern, mask in _CREDENTIALS:
text, count = pattern.subn(mask, text)
if count:
found.append(what)
return text, found
54 changes: 54 additions & 0 deletions src/agent_env/bundle/_toml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""A TOML writer for the tomls a bundle import writes, since ``tomllib`` only reads."""

from __future__ import annotations

import math
import re
from typing import Any

_BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")
_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"}


def dumps(document: dict[str, Any]) -> str:
"""``document`` as TOML, each dict an inline table; ValueError for a value TOML can't hold."""
return "".join(f"{_key(key)} = {_value(value)}\n" for key, value in document.items())


def _key(key: str) -> str:
return key if _BARE_KEY.fullmatch(key) else _string(key)


def _value(value: Any, *, top: bool = True) -> str:
"""``value`` as TOML; a list of several items spans lines only at the top, as an inline table can't."""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if not math.isfinite(value):
raise ValueError(f"{value!r} is not a TOML number")
return repr(value)
if isinstance(value, str):
return _string(value)
if isinstance(value, (list, tuple)):
items = [_value(item, top=False) for item in value]
if top and len(items) > 1:
return "[\n" + "".join(f" {item},\n" for item in items) + "]"
return f"[{', '.join(items)}]"
if isinstance(value, dict):
pairs = ", ".join(f"{_key(k)} = {_value(v, top=False)}" for k, v in value.items())
return f"{{ {pairs} }}" if pairs else "{}"
raise ValueError(f"TOML can't hold {value!r}")


def _string(text: str) -> str:
out = []
for char in text:
if char in _ESCAPES:
out.append(_ESCAPES[char])
elif ord(char) < 0x20 or ord(char) == 0x7F:
out.append(f"\\u{ord(char):04x}")
else:
out.append(char)
return '"' + "".join(out) + '"'
Loading
Loading