Skip to content

Commit 35c0738

Browse files
authored
feat: credential injection and offload for agent tool calls (#2481) (#2534)
Add CredentialVault, CredentialProfile, and CredentialInjector primitives so AGT can hold secrets on behalf of agents and inject them at the point of use. Agents only ever see opaque {{cred:NAME}} placeholders; resolved values stay inside the trust boundary. Key properties (addresses original AC + kayalopez review): - Encrypted-at-rest persistence via Fernet (cryptography). Fails closed (no plaintext on disk) when cryptography is unavailable. - Per-agent profiles bind credential handles to action capabilities (e.g. github:read_issues vs github:push_code), not just agent identities. - Workflow policy callback runs BEFORE any vault read, so prompt-injected tool args cannot smuggle credential references past policy. - Placeholder allowlist per call: handles not pre-authorized by the workflow are rejected. MCP server metadata and tool descriptions cannot inject credential references. - Deterministic DenyReceipt for missing / out-of-scope / policy-denied handles. - Audit events record agent DID, handle name, target service, action class, decision, policy version. Never the resolved value. - Rotation rebinds value in place; saved prompts/plans/MCP descriptions keep working. - HMAC-SHA256 audit_digest helper for tamper-evident logs. Surfaces: - agent_os.credential_vault module + public exports on agent_os. - agt cred {genkey,add,list,rotate,remove} CLI; never prints values. - examples/credential_vault_example.py end-to-end example. Also: add credential_vault.py to no-custom-crypto allowlist, extend cspell repo terms, and lower entropy of vault rotation test fixture. Tests: 28 unit tests + 5 CLI tests, all green. ruff clean. Signed-off-by: Ricky Gummadi <ricky.gummadi@outlook.com>
1 parent 4369137 commit 35c0738

9 files changed

Lines changed: 1624 additions & 0 deletions

File tree

.cspell-repo-terms.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,11 @@ workflows
305305
XACML
306306
xfail
307307
xunit
308+
Fernet
309+
fernet
310+
genkey
311+
importorskip
312+
digestmod
313+
urandom
314+
octo
315+
kayalopez

agent-governance-python/agent-compliance/src/agent_compliance/cli/agt.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import click
2626

2727
from agent_compliance.cli.red_team import red_team
28+
from agent_compliance.cli.cred import cred
2829

2930
_logger = logging.getLogger(__name__)
3031

@@ -194,6 +195,8 @@ def cli(
194195

195196
# Register the red-team subcommand group
196197
cli.add_command(red_team)
198+
# Register the credential vault subcommand group (issue #2481)
199+
cli.add_command(cred)
197200

198201

199202
@cli.command()
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""``agt cred`` — manage the credential vault for agent tool calls.
5+
6+
Subcommands:
7+
8+
agt cred add NAME VALUE Store or replace a credential.
9+
agt cred list List credential handle names.
10+
agt cred rotate NAME NEW_VALUE Rotate a credential value in place.
11+
agt cred remove NAME Delete a credential.
12+
agt cred genkey Generate a Fernet encryption key.
13+
14+
The CLI persists to ``$AGT_VAULT_PATH`` (default ``./.agt/vault.bin``)
15+
encrypted with ``$AGT_VAULT_KEY`` (a Fernet key — generate with
16+
``agt cred genkey``). The CLI never prints credential values.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import os
23+
import sys
24+
from pathlib import Path
25+
26+
import click
27+
28+
29+
_DEFAULT_VAULT_PATH = ".agt/vault.bin"
30+
31+
32+
def _resolve_vault() -> "object": # returns CredentialVault
33+
"""Construct a vault from environment-configured key/path."""
34+
try:
35+
from agent_os.credential_vault import (
36+
CredentialVault,
37+
EncryptionUnavailable,
38+
)
39+
except ImportError as exc:
40+
raise click.ClickException(
41+
f"agent_os.credential_vault is unavailable: {exc}"
42+
) from exc
43+
44+
path = os.environ.get("AGT_VAULT_PATH", _DEFAULT_VAULT_PATH)
45+
key = os.environ.get("AGT_VAULT_KEY")
46+
if not key:
47+
raise click.ClickException(
48+
"AGT_VAULT_KEY is not set. Generate one with 'agt cred genkey' "
49+
"and export it before running other 'agt cred' commands."
50+
)
51+
Path(os.path.dirname(path) or ".").mkdir(parents=True, exist_ok=True)
52+
try:
53+
return CredentialVault(persist_path=path, encryption_key=key.encode("utf-8"))
54+
except EncryptionUnavailable as exc:
55+
raise click.ClickException(str(exc)) from exc
56+
57+
58+
@click.group(name="cred")
59+
def cred() -> None:
60+
"""Manage the AGT credential vault."""
61+
62+
63+
@cred.command(name="genkey")
64+
def genkey() -> None:
65+
"""Print a fresh Fernet encryption key suitable for AGT_VAULT_KEY."""
66+
from agent_os.credential_vault import CredentialVault
67+
68+
click.echo(CredentialVault.generate_key().decode("ascii"))
69+
70+
71+
@cred.command(name="add")
72+
@click.argument("name")
73+
@click.argument("value")
74+
@click.option("--type", "cred_type", default="secret", show_default=True,
75+
help="Credential type label (e.g. bearer_token, basic_auth).")
76+
def add(name: str, value: str, cred_type: str) -> None:
77+
"""Store or replace a credential. Use '-' as VALUE to read from stdin."""
78+
vault = _resolve_vault()
79+
if value == "-":
80+
value = sys.stdin.read().rstrip("\n")
81+
handle = vault.put(name, value, cred_type=cred_type) # type: ignore[attr-defined]
82+
click.echo(f"stored: {handle.name}")
83+
84+
85+
@cred.command(name="list")
86+
@click.option("--json", "as_json", is_flag=True, default=False, help="Emit JSON.")
87+
def list_handles(as_json: bool) -> None:
88+
"""List credential handle names (no values are printed)."""
89+
vault = _resolve_vault()
90+
names = vault.list_handles() # type: ignore[attr-defined]
91+
if as_json:
92+
click.echo(json.dumps({"handles": names}))
93+
return
94+
if not names:
95+
click.echo("(no credentials)")
96+
return
97+
for n in names:
98+
meta = vault.get_metadata(n) or {} # type: ignore[attr-defined]
99+
click.echo(f"{n}\t{meta.get('cred_type', '')}\tv{meta.get('version', 1)}")
100+
101+
102+
@cred.command(name="rotate")
103+
@click.argument("name")
104+
@click.argument("new_value")
105+
def rotate(name: str, new_value: str) -> None:
106+
"""Rotate a credential's value while preserving its handle name."""
107+
vault = _resolve_vault()
108+
if new_value == "-":
109+
new_value = sys.stdin.read().rstrip("\n")
110+
try:
111+
handle = vault.rotate(name, new_value) # type: ignore[attr-defined]
112+
except KeyError:
113+
raise click.ClickException(f"unknown credential: {name}")
114+
click.echo(f"rotated: {handle.name}")
115+
116+
117+
@cred.command(name="remove")
118+
@click.argument("name")
119+
def remove(name: str) -> None:
120+
"""Delete a credential by handle name."""
121+
vault = _resolve_vault()
122+
removed = vault.delete(name) # type: ignore[attr-defined]
123+
if not removed:
124+
raise click.ClickException(f"unknown credential: {name}")
125+
click.echo(f"removed: {name}")
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
"""Tests for ``agt cred`` CLI (issue #2481)."""
4+
5+
from __future__ import annotations
6+
7+
import json
8+
from pathlib import Path
9+
10+
import pytest
11+
from click.testing import CliRunner
12+
13+
from agent_compliance.cli.agt import cli
14+
15+
16+
pytest.importorskip("cryptography")
17+
18+
19+
@pytest.fixture()
20+
def runner() -> CliRunner:
21+
try:
22+
return CliRunner(mix_stderr=False)
23+
except TypeError:
24+
return CliRunner()
25+
26+
27+
@pytest.fixture()
28+
def env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, str]:
29+
from agent_os.credential_vault import CredentialVault
30+
31+
key = CredentialVault.generate_key().decode("ascii")
32+
path = str(tmp_path / "vault.bin")
33+
monkeypatch.setenv("AGT_VAULT_KEY", key)
34+
monkeypatch.setenv("AGT_VAULT_PATH", path)
35+
return {"AGT_VAULT_KEY": key, "AGT_VAULT_PATH": path}
36+
37+
38+
def test_genkey_prints_valid_fernet_key(runner: CliRunner) -> None:
39+
result = runner.invoke(cli, ["cred", "genkey"])
40+
assert result.exit_code == 0, result.output
41+
key = result.output.strip()
42+
# 32-byte url-safe base64 = 44 chars including padding
43+
assert len(key) == 44
44+
45+
46+
def test_add_list_rotate_remove_roundtrip(
47+
runner: CliRunner, env: dict[str, str]
48+
) -> None:
49+
r = runner.invoke(cli, ["cred", "add", "gh", "secret-value", "--type", "bearer_token"])
50+
assert r.exit_code == 0, r.output
51+
assert "stored: gh" in r.output
52+
53+
r = runner.invoke(cli, ["cred", "list", "--json"])
54+
assert r.exit_code == 0, r.output
55+
payload = json.loads(r.output)
56+
assert payload == {"handles": ["gh"]}
57+
# Value never leaks
58+
assert "secret-value" not in r.output
59+
60+
r = runner.invoke(cli, ["cred", "rotate", "gh", "new-value"])
61+
assert r.exit_code == 0
62+
assert "rotated: gh" in r.output
63+
assert "new-value" not in r.output
64+
65+
r = runner.invoke(cli, ["cred", "remove", "gh"])
66+
assert r.exit_code == 0
67+
assert "removed: gh" in r.output
68+
69+
r = runner.invoke(cli, ["cred", "list"])
70+
assert "(no credentials)" in r.output
71+
72+
73+
def test_missing_key_fails_closed(
74+
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
75+
) -> None:
76+
monkeypatch.delenv("AGT_VAULT_KEY", raising=False)
77+
monkeypatch.setenv("AGT_VAULT_PATH", str(tmp_path / "v.bin"))
78+
r = runner.invoke(cli, ["cred", "add", "k", "v"])
79+
assert r.exit_code != 0
80+
assert "AGT_VAULT_KEY" in (r.output + (r.stderr or ""))
81+
82+
83+
def test_remove_unknown_errors(runner: CliRunner, env: dict[str, str]) -> None:
84+
r = runner.invoke(cli, ["cred", "remove", "nope"])
85+
assert r.exit_code != 0
86+
87+
88+
def test_rotate_unknown_errors(runner: CliRunner, env: dict[str, str]) -> None:
89+
r = runner.invoke(cli, ["cred", "rotate", "nope", "x"])
90+
assert r.exit_code != 0
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
"""Example: credential offload and injection for an agent tool call.
4+
5+
Run with: ``python examples/credential_vault_example.py``
6+
7+
This shows the issue #2481 flow end-to-end:
8+
9+
1. An operator provisions a credential in the vault.
10+
2. An agent's profile binds an action capability to that credential.
11+
3. The agent's prompt / saved plan only ever sees the opaque placeholder
12+
``{{cred:NAME}}`` — never the resolved value.
13+
4. The injector evaluates the workflow policy, resolves the placeholder
14+
inside the trust boundary, and returns the rendered request.
15+
5. Audit records show *who* used *which handle* for *which service* — but
16+
never the secret itself.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from agent_os.credential_vault import (
22+
CredentialInjector,
23+
CredentialProfile,
24+
CredentialVault,
25+
InjectionContext,
26+
PolicyOutcome,
27+
)
28+
29+
30+
def main() -> None:
31+
# 1. Operator provisions the credential.
32+
vault = CredentialVault()
33+
vault.put("github_pat", "ghp_real_token_value", cred_type="bearer_token")
34+
35+
# 2. Bind an agent identity to an action capability -> handle.
36+
vault.register_profile(
37+
CredentialProfile(
38+
agent_did="did:web:agent-ci",
39+
bindings={"github:read_issues": "github_pat"},
40+
)
41+
)
42+
43+
# 3. The agent's saved tool-call template uses only the placeholder.
44+
headers = {"Authorization": "Bearer {{cred:github_pat}}"}
45+
46+
# 4. Workflow policy decides whether this call is allowed at all,
47+
# before the injector ever reads the value.
48+
def policy(ctx: InjectionContext) -> PolicyOutcome:
49+
return PolicyOutcome(
50+
allow=ctx.action_class == "github:read_issues",
51+
reason="only read-only github calls are permitted in this workflow",
52+
)
53+
54+
injector = CredentialInjector(vault)
55+
result = injector.inject_headers(
56+
"did:web:agent-ci",
57+
headers,
58+
action_class="github:read_issues",
59+
target_service="api.github.com",
60+
allowed_handles=["github_pat"],
61+
policy_check=policy,
62+
policy_version="v1",
63+
)
64+
65+
print("allowed:", result.allowed)
66+
print("rendered Authorization length:", len(result.payload["Authorization"]))
67+
68+
# 5. Audit log has no value, only the handle name and decision.
69+
for ev in vault.audit_log():
70+
print("audit:", ev.to_dict())
71+
72+
73+
if __name__ == "__main__":
74+
main()

agent-governance-python/agent-os/src/agent_os/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,23 @@ def check_installation() -> None:
181181
ToolFingerprint,
182182
)
183183
from agent_os.credential_redactor import CredentialMatch, CredentialPattern, CredentialRedactor
184+
from agent_os.credential_vault import (
185+
DENY_REASON,
186+
CredentialDecision,
187+
CredentialError,
188+
CredentialHandle,
189+
CredentialInjector,
190+
CredentialProfile,
191+
CredentialRecord,
192+
CredentialVault,
193+
DenyReceipt,
194+
EncryptionUnavailable,
195+
InjectionContext,
196+
InjectionResult,
197+
PolicyOutcome,
198+
VaultAuditEvent,
199+
audit_digest,
200+
)
184201
from agent_os.mcp_message_signer import (
185202
MCPMessageSigner,
186203
MCPSignedEnvelope,
@@ -336,6 +353,22 @@ def check_installation() -> None:
336353
"CredentialRedactor",
337354
"CredentialPattern",
338355
"CredentialMatch",
356+
# Credential Vault & Injection (issue #2481)
357+
"CredentialVault",
358+
"CredentialInjector",
359+
"CredentialHandle",
360+
"CredentialProfile",
361+
"CredentialRecord",
362+
"CredentialDecision",
363+
"CredentialError",
364+
"EncryptionUnavailable",
365+
"VaultAuditEvent",
366+
"DenyReceipt",
367+
"InjectionContext",
368+
"InjectionResult",
369+
"PolicyOutcome",
370+
"DENY_REASON",
371+
"audit_digest",
339372
"MCPSessionStore",
340373
"MCPNonceStore",
341374
"MCPRateLimitStore",

0 commit comments

Comments
 (0)