Skip to content

Commit 5b5b522

Browse files
dcramercodex
andcommitted
Harden integration auth secret handling and enforce fixed policy
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
1 parent c449f6b commit 5b5b522

21 files changed

Lines changed: 559 additions & 59 deletions

File tree

specs/capabilities.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ per-user credential isolation rules.
5656
- Capability auth flow handles are short-lived, unguessable, and bound to the requesting user scope.
5757
- Capability execution emits structured audit events without logging raw bearer tokens.
5858
- Capability responses must never include raw credential artifacts (access tokens, refresh tokens, cookie jars, client secrets).
59+
- Provider auth `credential_material` must contain only opaque references/metadata (no raw tokens/secrets).
5960
- Provider-side credential artifacts must be persisted via a dedicated vault abstraction (not graph collections or sandbox-readable mounts).
6061

6162
### SHOULD
@@ -257,6 +258,7 @@ Rules:
257258
- Provider responses are user-facing payloads and must be credential-safe.
258259
- Host rejects provider outputs containing credential-like keys (`access_token`,
259260
`refresh_token`, `id_token`, `client_secret`, cookie/auth headers).
261+
- Host rejects provider auth `credential_material` containing credential-like keys.
260262

261263
### RPC Methods
262264

specs/capability-auth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Parent spec: `specs/capabilities.md`
88

99
## Status
1010

11-
Spec only — not yet implemented.
11+
Implemented.
1212

1313
## Intent
1414

specs/config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ timeout_seconds = 30
9999
[skills.code-review]
100100
model = "sonnet"
101101

102+
102103
[sandbox]
103104
timeout = 60
104105
memory_limit = "512m"

specs/integration-auth-security.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Integration Auth Security
2+
3+
> Unified secret-handling policy for tools, skills, and integration providers.
4+
5+
## Intent
6+
7+
Ash should not hand raw API keys, OAuth access tokens, refresh tokens, or client
8+
secrets directly to tool/skill execution environments.
9+
10+
Authentication and authorization for sensitive external systems must be mediated by
11+
host-managed boundaries (capabilities, provider bridges, authenticated proxies).
12+
13+
## Requirements
14+
15+
### MUST
16+
17+
- Tools/skills MUST NOT receive secret-like env vars by default.
18+
- Secret-like env var names are detected by fixed built-in name patterns.
19+
- Delivery of secret-like env vars to skills/tools is blocked by policy.
20+
- Capability/provider auth responses MUST NOT include raw credential material.
21+
- `credential_material` from providers is limited to opaque references (for example
22+
`credential_key`) and metadata.
23+
- Provider-side credential artifacts MUST be persisted in host vault storage.
24+
- Capability invocation responses MUST remain credential-safe.
25+
- Identity/routing for auth and invoke MUST be token-derived (`ASH_CONTEXT_TOKEN`),
26+
not caller-provided ids.
27+
28+
### SHOULD
29+
30+
- External provider bridges should run with minimal inherited process environment.
31+
- Authenticated sidecar/proxy patterns should inject authorization headers server-side
32+
from vault-backed references when feasible.
33+
34+
## Policy
35+
36+
The secret-delivery block is enforced as a runtime policy and is not user-configurable.

specs/interactive-agents.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,12 @@ The provider runs a while loop that processes TurnResults until it needs user in
186186

187187
```python
188188
while True:
189-
result = execute_turn(stack.top, user_message=..., tool_result=...)
189+
result = execute_turn(
190+
stack.top,
191+
user_message=...,
192+
tool_result=...,
193+
tool_overrides={"send_message": progress_tool},
194+
)
190195

191196
match result.action:
192197
case SEND_TEXT:
@@ -217,14 +222,23 @@ while True:
217222
# Same as MAX_ITERATIONS but with error text
218223
```
219224

225+
Provider orchestration SHOULD pass a per-request `send_message` override that
226+
funnels subagent progress into the current response thread (thinking/progress
227+
buffer) rather than emitting separate direct messages.
228+
220229
Every iteration either `return`s (waiting for user input) or `continue`s (cascading). No recursion.
221230

222231
### execute_turn (AgentExecutor)
223232

224233
Runs one logical turn for a stack frame:
225234

226235
```python
227-
async def execute_turn(frame, user_message=None, tool_result=None) -> TurnResult:
236+
async def execute_turn(
237+
frame,
238+
user_message=None,
239+
tool_result=None,
240+
tool_overrides=None,
241+
) -> TurnResult:
228242
session = frame.session
229243

230244
if user_message: session.add_user_message(user_message)
@@ -245,7 +259,11 @@ async def execute_turn(frame, user_message=None, tool_result=None) -> TurnResult
245259
if tool_use.name == "complete":
246260
return TurnResult(COMPLETE, text=tool_use.input["result"])
247261
try:
248-
result = await tools.execute(tool_use.name, tool_use.input, ctx)
262+
tool_impl = tool_overrides.get(tool_use.name) if tool_overrides else None
263+
if tool_impl:
264+
result = await tool_impl.execute(tool_use.input, ctx)
265+
else:
266+
result = await tools.execute(tool_use.name, tool_use.input, ctx)
249267
session.add_tool_result(tool_use.id, result.content, result.is_error)
250268
except ChildActivated as ca:
251269
return TurnResult(CHILD_ACTIVATED, child_frame=ca.child_frame)

specs/skills.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Files: src/ash/skills/base.py, src/ash/skills/registry.py, src/ash/tools/builtin
99
Skills are markdown files that define specialized subagents. Unlike the current model where the main agent reads skill files, skills are now **invoked explicitly** via the `use_skill` tool and run in **isolated LLM loops** with scoped environments.
1010

1111
This enables:
12-
- **API key isolation**: Skills declare needed env vars, config provides values
12+
- **Scoped env injection**: Skills declare non-secret env vars, config provides values
1313
- **Tool restrictions**: Skills can limit which tools the subagent uses
1414
- **Context compression**: Main agent passes relevant context, not full history
1515
- **Model flexibility**: Skills can specify different models (e.g., haiku for simple tasks)
@@ -47,6 +47,7 @@ skills consume those capabilities through stable public surfaces.
4747
- Invoke skills via `use_skill` tool (not by reading files)
4848
- Run skill as subagent with isolated session
4949
- Inject env vars from config into skill execution
50+
- Block secret-like env var delivery to skills by policy
5051
- Support capability-mediated calls for sensitive external systems (contract in `specs/capabilities.md`)
5152
- Keep skill execution on public host interfaces; no direct integration hook registration path for skills
5253
- Treat bundled skills as regular skill surfaces (no privileged wiring semantics)
@@ -91,7 +92,7 @@ access:
9192
chat_types: # Optional invocation chat-type allowlist
9293
- private
9394
env: # Env vars to inject from config
94-
- PERPLEXITY_API_KEY
95+
- SERVICE_ENDPOINT
9596
packages: # System packages to install (apt)
9697
- jq
9798
- curl
@@ -108,7 +109,7 @@ You are a research assistant with access to Perplexity AI.
108109
Given a research query, search for accurate, up-to-date information
109110
and return a structured summary with sources.
110111

111-
Use the PERPLEXITY_API_KEY environment variable for API calls.
112+
Use the SERVICE_ENDPOINT environment variable for API calls.
112113
```
113114

114115
### Capability-Backed Skills (Contract)
@@ -158,7 +159,7 @@ declare container/command wiring.
158159
# ~/.ash/config.toml
159160

160161
[skills.research]
161-
PERPLEXITY_API_KEY = "pplx-..." # Direct match - injected as $PERPLEXITY_API_KEY
162+
SERVICE_ENDPOINT = "https://api.example.com" # Direct match - injected as $SERVICE_ENDPOINT
162163
model = "haiku" # Override skill's default model
163164
enabled = true # Can disable without removing file
164165
allow_chat_ids = ["12345"] # Optional per-skill chat allowlist override
@@ -181,6 +182,7 @@ enabled = false # Disabled
181182

182183
Config keys match env var names exactly (UPPER_CASE). No case conversion.
183184
`allow_chat_ids` can be set globally in `[skills.defaults]` and overridden per skill.
185+
Secret-like env var names are blocked by policy and must use host-managed capability/proxy auth.
184186

185187
`[skills.gog].enabled = true` applies default `gog` provider wiring.
186188
`[skills.gog.capability_provider]` can override provider command/namespace/timeout

src/ash/agents/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
Use `send_message` to keep the user informed during long-running tasks:
1212
- Share what you're working on at each major step
1313
- Keep updates brief (one line)
14+
- Use it for progress only, not final instructions or final results
15+
- If user action is needed (auth codes, confirmations), provide that once in the final response path
1416
1517
Example: "Searching documentation...", "Found 3 results, analyzing..."""
1618

src/ash/agents/executor.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,7 @@ async def execute_turn(
904904
user_message: str | None = None,
905905
tool_result: tuple[str, str, bool] | None = None,
906906
session_manager: "SessionManager | None" = None,
907+
tool_overrides: dict[str, Any] | None = None,
907908
) -> TurnResult:
908909
"""Run one logical turn for a stack frame.
909910
@@ -917,6 +918,8 @@ async def execute_turn(
917918
user_message: Optional user message to inject.
918919
tool_result: Optional (tool_use_id, content, is_error) from completed child.
919920
session_manager: Optional session manager for logging to context.jsonl.
921+
tool_overrides: Optional map of tool name -> tool implementation to use
922+
for this turn instead of the shared executor registry.
920923
921924
Returns:
922925
TurnResult indicating what happened.
@@ -1082,9 +1085,16 @@ async def execute_turn(
10821085
session_manager=session_manager,
10831086
tool_use_id=tool_use.id,
10841087
)
1085-
result = await self._tools.execute(
1086-
tool_use.name, tool_use.input, per_tool_context
1087-
)
1088+
override_tool = (tool_overrides or {}).get(tool_use.name)
1089+
if override_tool is not None:
1090+
result = await override_tool.execute(
1091+
tool_use.input,
1092+
per_tool_context,
1093+
)
1094+
else:
1095+
result = await self._tools.execute(
1096+
tool_use.name, tool_use.input, per_tool_context
1097+
)
10881098
sanitized = self._sanitize_tool_result(
10891099
tool_name=tool_use.name,
10901100
tool_use_id=tool_use.id,

src/ash/capabilities/manager.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,12 @@ async def auth_complete(
379379
code="capability_invalid_output",
380380
message="auth completion must return account_ref",
381381
)
382+
credential_material = dict(complete_result.credential_material)
383+
if _find_sensitive_key_path(credential_material, path="credential_material"):
384+
raise CapabilityError(
385+
"capability_invalid_output",
386+
"provider auth completion returned credential material",
387+
)
382388
now = datetime.now(UTC)
383389
async with self._lock:
384390
self._accounts[(flow.user_id, flow.capability_id, account_ref)] = (
@@ -387,7 +393,7 @@ async def auth_complete(
387393
user_id=flow.user_id,
388394
account_ref=account_ref,
389395
created_at=now,
390-
credential_material=dict(complete_result.credential_material),
396+
credential_material=credential_material,
391397
metadata=dict(complete_result.metadata),
392398
)
393399
)
@@ -465,6 +471,14 @@ async def auth_poll(
465471
code="capability_invalid_output",
466472
message="auth poll completion must return account_ref",
467473
)
474+
credential_material = dict(poll_result.credential_material)
475+
if _find_sensitive_key_path(
476+
credential_material, path="credential_material"
477+
):
478+
raise CapabilityError(
479+
"capability_invalid_output",
480+
"provider auth poll returned credential material",
481+
)
468482
now = datetime.now(UTC)
469483
async with self._lock:
470484
self._accounts[(flow.user_id, flow.capability_id, account_ref)] = (
@@ -473,7 +487,7 @@ async def auth_poll(
473487
user_id=flow.user_id,
474488
account_ref=account_ref,
475489
created_at=now,
476-
credential_material=dict(poll_result.credential_material),
490+
credential_material=credential_material,
477491
metadata=dict(poll_result.metadata),
478492
)
479493
)

src/ash/capabilities/providers/subprocess.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@
3434

3535
_BRIDGE_PROTOCOL_VERSION = 1
3636
_BRIDGE_CONTEXT_TOKEN_TTL_SECONDS = 900
37+
_BRIDGE_BASE_ENV_KEYS = (
38+
"HOME",
39+
"LANG",
40+
"LC_ALL",
41+
"PATH",
42+
"PYTHONPATH",
43+
"TMP",
44+
"TEMP",
45+
"TMPDIR",
46+
"USER",
47+
)
3748

3849

3950
class SubprocessCapabilityProvider(CapabilityProvider):
@@ -301,7 +312,14 @@ async def _execute_command(self, payload: dict[str, Any]) -> dict[str, Any]:
301312
return parsed
302313

303314
def _bridge_environment(self) -> dict[str, str]:
304-
env = dict(os.environ)
315+
env: dict[str, str] = {}
316+
for key in _BRIDGE_BASE_ENV_KEYS:
317+
value = os.environ.get(key)
318+
if value is not None:
319+
env[key] = value
320+
for key, value in os.environ.items():
321+
if key.startswith("GOGCLI_"):
322+
env[key] = value
305323
if self._extra_env:
306324
env.update(self._extra_env)
307325
env[ENV_SECRET] = self._context_token_service.export_verifier_secret()

0 commit comments

Comments
 (0)