Setting the file. One moment.
Asm Exec · AWS Secrets Manager · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 69
Creating Amazon Aurora Db Cluster With Instances
92
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Debugging Lambda Timeouts
Raw file
references/ asm-exec
ASM-EXEC · 430 lines · 18 KB
import hmac
20 import json
21 import os
22 import re
23 import subprocess
24 import sys
25 import urllib.error
26 import urllib.parse
27 import urllib.request
28
29
30 PATTERN = re.compile(r'\{\{resolve:secretsmanager:([^}]+)\}\}')
31 SMA_ENDPOINT = os.environ.get('AWS_SECRETS_MANAGER_AGENT_ENDPOINT', 'http://localhost:2773')
32 SSRF_TOKEN = os.environ.get('AWS_SESSION_TOKEN', os.environ.get('AWS_TOKEN', ''))
33 MCP_ENDPOINT = os.environ.get('ASM_EXEC_MCP_ENDPOINT', 'https://aws-mcp.us-east-1.api.aws/mcp')
34 # run_script performs a server-side boto3 round trip (measured ~13s), well beyond
35 # the old hard-coded 10s ceiling. Keep the transport timeout generous and let
36 # operators raise it further via ASM_EXEC_MCP_TIMEOUT (seconds).
37 MCP_TIMEOUT = float(os.environ.get('ASM_EXEC_MCP_TIMEOUT', '30'))
38
39 _sma_available = None
40
41
42 def _check_sma():
43 global _sma_available
44 if _sma_available is None:
45 try:
46 req = urllib.request.Request(f'{SMA_ENDPOINT}/ping', method='GET')
47 urllib.request.urlopen(req, timeout=1)
48 _sma_available = True
49 except (urllib.error.URLError, OSError):
50 _sma_available = False
51 return _sma_available
52
53
54 def _get_aws_credentials():
55 """Resolve AWS credentials for SigV4 signing.
56
57 Order: environment variables, then `aws configure export-credentials`
58 (AWS CLI v2), then `aws configure get` (AWS CLI v1, which lacks
59 export-credentials). Returns a dict with access_key/secret_key/token or None.
60 """
61 if os.environ.get('AWS_ACCESS_KEY_ID'):
62 return {
63 'access_key': os.environ['AWS_ACCESS_KEY_ID'],
64 'secret_key': os.environ.get('AWS_SECRET_ACCESS_KEY', ''),
65 'token': os.environ.get('AWS_SESSION_TOKEN', ''),
66 }
67 # AWS CLI v2: export-credentials emits resolved (possibly assumed-role) creds.
68 try:
69 result = subprocess.run(
70 ['aws', 'configure', 'export-credentials', '--format', 'env'],
71 capture_output=True, text=True, check=True, timeout=5
72 )
73 creds = {}
74 for line in result.stdout.splitlines():
75 if '=' in line:
76 line = line.removeprefix('export ')
77 k, v = line.split('=', 1)
78 if k == 'AWS_ACCESS_KEY_ID':
79 creds['access_key'] = v
80 elif k == 'AWS_SECRET_ACCESS_KEY':
81 creds['secret_key'] = v
82 elif k == 'AWS_SESSION_TOKEN':
83 creds['token'] = v
84 if creds.get('access_key'):
85 return creds
86 except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
87 pass
88 # AWS CLI v1 fallback: read static creds from the configured profile.
89 try:
90 def _cfg(key):
91 r = subprocess.run(['aws', 'configure', 'get', key],
92 capture_output=True, text=True, timeout=5)
93 return r.stdout.strip() if r.returncode == 0 else ''
94 access_key = _cfg('aws_access_key_id')
95 if access_key:
96 return {
97 'access_key': access_key,
98 'secret_key': _cfg('aws_secret_access_key'),
99 'token': _cfg('aws_session_token'),
100 }
101 except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
102 pass
103 return None
104
105
106 def _signing_service_region(endpoint):
107 """Derive (service, region) for SigV4 from an AWS MCP endpoint hostname.
108
109 Mirrors mcp-proxy-for-aws-cli: 'service.region.api.aws' -> (service, region);
110 'bedrock-agentcore' style is handled as a special case. The signing region
111 is the endpoint's own region, independent of any secret's region.
112 """
113 host = urllib.parse.urlparse(endpoint).hostname or ''
114 parts = host.split('.')
115 if len(parts) >= 5 and parts[-4] == 'bedrock-agentcore' and parts[-2:] == ['amazonaws', 'com']:
116 return 'bedrock-agentcore', parts[-3]
117 if len(parts) == 4 and parts[2:] == ['api', 'aws']:
118 return parts[0], parts[1]
119 # Fallback: first segment as service, region from environment.
120 region = os.environ.get('AWS_REGION') or os.environ.get('AWS_DEFAULT_REGION') or 'us-east-1'
121 return (parts[0] if parts else 'aws-mcp'), region
122
123
124 def _sign_v4(method, path, body, creds, service, region, now):
125 """Compute SigV4 headers (stdlib only) for a request. Returns a header dict.
126
127 botocore is not available in asm-exec's runtime, so signing is implemented
128 directly with hashlib/hmac following the AWS SigV4 spec.
129 """
130 host = urllib.parse.urlparse(MCP_ENDPOINT).hostname or ''
131 amzdate = now.strftime('%Y%m%dT%H%M%SZ')
132 datestamp = now.strftime('%Y%m%d')
133 payload_hash = hashlib.sha256(body).hexdigest()
134
135 headers = {
136 'host': host,
137 'x-amz-date': amzdate,
138 'x-amz-content-sha256': payload_hash,
139 }
140 if creds.get('token'):
141 headers['x-amz-security-token'] = creds['token']
142
143 signed_keys = sorted(headers)
144 canonical_headers = ''.join(f'{k}:{headers[k].strip()}\n' for k in signed_keys)
145 signed_headers_str = ';'.join(signed_keys)
146 canonical_request = (f'{method}\n{path}\n\n{canonical_headers}\n'
147 f'{signed_headers_str}\n{payload_hash}')
148
149 scope = f'{datestamp}/{region}/{service}/aws4_request'
150 string_to_sign = (f'AWS4-HMAC-SHA256\n{amzdate}\n{scope}\n'
151 f'{hashlib.sha256(canonical_request.encode()).hexdigest()}')
152
153 def _hmac(key, msg):
154 return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
155
156 k_date = _hmac(('AWS4' + creds['secret_key']).encode('utf-8'), datestamp)
157 k_region = _hmac(k_date, region)
158 k_service = _hmac(k_region, service)
159 k_signing = _hmac(k_service, 'aws4_request')
160 signature = hmac.new(k_signing, string_to_sign.encode('utf-8'),
161 hashlib.sha256).hexdigest()
162
163 headers['Authorization'] = (
164 f'AWS4-HMAC-SHA256 Credential={creds["access_key"]}/{scope}, '
165 f'SignedHeaders={signed_headers_str}, Signature={signature}'
166 )
167 return headers
168
169
170 def _mcp_post(payload, session_id=None):
171 """POST a SigV4-signed JSON-RPC request to the AWS MCP endpoint.
172
173 Returns (parsed_response, session_id_from_response). The caller passes the
174 session id returned by 'initialize' back into subsequent calls.
175 """
176 creds = _get_aws_credentials()
177 if not creds or not creds.get('access_key'):
178 raise RuntimeError('no AWS credentials available for MCP signing')
179
180 body = json.dumps(payload).encode()
181 service, region = _signing_service_region(MCP_ENDPOINT)
182 path = urllib.parse.urlparse(MCP_ENDPOINT).path or '/'
183 now = datetime.datetime.utcnow()
184
185 sig_headers = _sign_v4('POST', path, body, creds, service, region, now)
186 req = urllib.request.Request(MCP_ENDPOINT, data=body, method='POST')
187 req.add_header('Content-Type', 'application/json')
188 req.add_header('Accept', 'application/json, text/event-stream')
189 req.add_header('User-Agent', 'ASMExecWrapper/1.0.0')
190 if session_id:
191 req.add_header('Mcp-Session-Id', session_id)
192 for k, v in sig_headers.items():
193 req.add_header(k, v)
194
195 resp = urllib.request.urlopen(req, timeout=MCP_TIMEOUT)
196 session_out = resp.headers.get('Mcp-Session-Id')
197 raw = resp.read()
198 parsed = json.loads(raw) if raw else {}
199 return parsed, session_out
200
201
202 def _extract_secret_string(payload):
203 """Pull SecretString out of a parsed get-secret-value response dict."""
204 if isinstance(payload, dict):
205 if "SecretString" in payload:
206 return payload["SecretString"]
207 # run_script returns the API response under `return_value`; the legacy
208 # call_aws path nested CLI output under result/output/stdout. Traverse all.
209 for key in ("return_value", "result", "results", "output", "stdout"):
210 if key in payload:
211 nested = payload[key]
212 if isinstance(nested, str):
213 try:
214 nested = json.loads(nested)
215 except json.JSONDecodeError:
216 continue
217 found = _extract_secret_string(nested)
218 if found:
219 return found
220 return None
221
222
223 def _mcp_error(resp):
224 """Return the JSON-RPC error message from an MCP response, or None."""
225 if isinstance(resp, dict) and isinstance(resp.get("error"), dict):
226 return resp["error"].get("message") or str(resp["error"])
227 return None
228
229
230 def _resolve_via_mcp(secret_name, label, region):
231 """Resolve a secret via the SigV4-authenticated AWS MCP endpoint.
232
233 Uses the server's 'aws___run_script' tool (the successor to the removed
234 'aws___call_aws'): it runs a short Python script server-side that calls
235 call_boto3('secretsmanager', 'GetSecretValue', ...) and returns the API
236 response as the script's return_value. The SecretString returns into this
237 process and never reaches the agent.
238
239 Returns the resolved value, or None. On failure the specific cause is
240 printed to stderr -- a removed/renamed tool, a timeout, a signing or
241 permission error, and a missing secret each report distinctly instead of
242 collapsing into one message. Secret values are never included in diagnostics.
243 """
244 try:
245 # Initialize and capture the session id for subsequent calls.
246 init_resp, session_id = _mcp_post(
247 {"jsonrpc": "2.0", "id": 1, "method": "initialize",
248 "params": {"protocolVersion": "2024-11-05",
249 "clientInfo": {"name": "asm-exec", "version": "1.0.0"},
250 "capabilities": {}}})
251 err = _mcp_error(init_resp)
252 if err:
253 raise RuntimeError("MCP initialize rejected: %s" % err)
254 # Notify initialized
255 _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized"},
256 session_id)
257 # Build the Python script the sandbox will run. call_boto3 takes the
258 # secret id and version stage as params; region_name covers cross-region
259 # secrets regardless of the endpoint's home region. Values are embedded
260 # with %r (repr) so quotes and metacharacters are escaped into valid
261 # Python literals -- no shell quoting is involved on this path. The
262 # trailing bare `result` expression makes the API response the script's
263 # return_value.
264 params = {"SecretId": secret_name, "VersionStage": label}
265 call_args = ("service_name='secretsmanager', "
266 "operation_name='GetSecretValue', params=%r" % (params,))
267 if region:
268 call_args += ", region_name=%r" % (region,)
269 code = "result = await call_boto3(%s)\nresult" % (call_args,)
270 # Call tool
271 resp, _ = _mcp_post(
272 {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
273 "params": {"name": "aws___run_script",
274 "arguments": {"code": code}}},
275 session_id)
276 err = _mcp_error(resp)
277 if err:
278 raise RuntimeError("aws___run_script call rejected: %s" % err)
279 result = resp.get("result", {})
280 # tools/call returns a content array of text items; each item's text is
281 # the run_script envelope {status, stdout, stderr, return_value, ...}.
282 envelopes = []
283 if isinstance(result, dict) and "content" in result:
284 for item in result["content"]:
285 if item.get("type") == "text":
286 try:
287 envelopes.append(json.loads(item["text"]))
288 except (json.JSONDecodeError, TypeError):
289 continue
290 elif isinstance(result, dict):
291 envelopes.append(result)
292 for envelope in envelopes:
293 if isinstance(envelope, dict) and envelope.get("status") == "error":
294 raise RuntimeError(
295 "run_script reported an error: %s"
296 % (envelope.get("error") or envelope.get("stderr")
297 or "unknown error"))
298 found = _extract_secret_string(envelope)
299 if found:
300 return found
301 # The call succeeded but carried no SecretString -- typically a denied
302 # permission or a wrong secret id/stage rather than a transport fault.
303 raise RuntimeError(
304 "no SecretString in run_script response (check "
305 "secretsmanager:GetSecretValue permission and the secret id/stage)")
306 except TimeoutError as exc:
307 reason = ("timed out after %ss -- raise ASM_EXEC_MCP_TIMEOUT if the "
308 "server-side call needs longer (%s)" % (MCP_TIMEOUT, exc))
309 except urllib.error.HTTPError as exc:
310 reason = "HTTP %s from MCP endpoint (%s)" % (exc.code, exc.reason)
311 except urllib.error.URLError as exc:
312 reason = "cannot reach MCP endpoint: %s" % (exc.reason,)
313 except (OSError, json.JSONDecodeError, KeyError, TypeError, RuntimeError) as exc:
314 reason = "%s: %s" % (type(exc).__name__, exc)
315 print("asm-exec: MCP resolution failed: %s" % reason, file=sys.stderr)
316 return None
317
318
319 def resolve_one(ref):
320 """Resolve secret-id[:field-type[:json-key[:version-stage]]].
321
322 Secret-id may be an ARN (contains colons) or a plain name.
323 ARN format: arn:aws:secretsmanager:<Region>:<AccountId>:secret:<SecretName>-<6RandomChars>
324 """
325 # ARN-aware split: if ref starts with 'arn:', treat everything up to
326 # the 7th colon as the secret-id (6 colons in a standard ARN)
327 if ref.startswith('arn:'):
328 arn_parts = ref.split(':')
329 # Standard ARN has 7 segments (indices 0-6): arn:partition:service:region:account:resource-type:resource-id
330 if len(arn_parts) >= 7:
331 secret_name = ':'.join(arn_parts[:7])
332 remainder = arn_parts[7:]
333 else:
334 secret_name = ref
335 remainder = []
336 field_type = remainder[0] if len(remainder) > 0 else 'SecretString'
337 json_key = remainder[1] if len(remainder) > 1 else None
338 label = remainder[2] if len(remainder) > 2 else 'AWSCURRENT'
339 else:
340 parts = ref.split(':', 3)
341 secret_name = parts[0]
342 field_type = parts[1] if len(parts) > 1 else 'SecretString'
343 json_key = parts[2] if len(parts) > 2 else None
344 label = parts[3] if len(parts) > 3 else 'AWSCURRENT'
345
346 if field_type != 'SecretString':
347 print(f'asm-exec: ERROR: Only SecretString is supported, got: {field_type}', file=sys.stderr)
348 sys.exit(1)
349
350 value = None
351
352 # Region for cross-region secrets: honor an ARN's region segment first,
353 # then fall back to the ambient AWS_REGION / AWS_DEFAULT_REGION.
354 region = None
355 if secret_name.startswith('arn:'):
356 arn_segments = secret_name.split(':')
357 if len(arn_segments) >= 4 and arn_segments[3]:
358 region = arn_segments[3]
359 if not region:
360 region = os.environ.get('AWS_REGION') or os.environ.get('AWS_DEFAULT_REGION')
361
362 # 1. Try SMA daemon
363 if _check_sma():
364 url = f'{SMA_ENDPOINT}/secretsmanager/get?secretId={urllib.parse.quote(secret_name, safe="")}&versionStage={label}'
365 req = urllib.request.Request(url, method='GET')
366 if SSRF_TOKEN:
367 req.add_header('X-Aws-Parameters-Secrets-Token', SSRF_TOKEN)
368 try:
369 with urllib.request.urlopen(req, timeout=5) as resp:
370 data = json.loads(resp.read())
371 value = data.get('SecretString')
372 except (urllib.error.URLError, OSError, json.JSONDecodeError):
373 pass
374
375 # 2. Resolve via Streamable HTTP MCP
376 if not value:
377 value = _resolve_via_mcp(secret_name, label, region)
378
379 if not value:
380 print(f'asm-exec: ERROR: Failed to resolve: {ref}', file=sys.stderr)
381 sys.exit(1)
382
383 if json_key:
384 try:
385 obj = json.loads(value)
386 value = obj[json_key]
387 except (json.JSONDecodeError, KeyError, TypeError):
388 print(f"asm-exec: ERROR: JSON key '{json_key}' not found in: {secret_name}", file=sys.stderr)
389 sys.exit(1)
390 if not isinstance(value, str):
391 value = json.dumps(value)
392
393 return value
394
395
396 def resolve_string(s):
397 """Single-pass substitution — resolved values are never re-scanned."""
398 return PATTERN.sub(lambda m: resolve_one(m.group(1)), s)
399
400
401 def main():
402 if len(sys.argv) < 2:
403 print('Usage: asm-exec <command> [args...]', file=sys.stderr)
404 sys.exit(1)
405
406 cmd_args = sys.argv[1:]
407 # Strip optional -- separator (convention: asm-exec -- command)
408 if cmd_args and cmd_args[0] == '--':
409 cmd_args = cmd_args[1:]
410 if not cmd_args:
411 print('Usage: asm-exec <command> [args...]', file=sys.stderr)
412 sys.exit(1)
413
414 # Resolve references in command-line arguments
415 args = [resolve_string(a) if PATTERN.search(a) else a for a in cmd_args]
416
417 # Resolve references in exported environment variables (documented behavior).
418 # Uses the same single-pass re.sub as argv — resolved values are never re-scanned.
419 # Secret values flow only to the child process and never return to the calling agent.
420 child_env = {
421 k: (resolve_string(v) if PATTERN.search(v) else v)
422 for k, v in os.environ.items()
423 }
424
425 result = subprocess.run(args, env=child_env)
426 sys.exit(result.returncode)
427
428
429 if __name__ == '__main__':
430 main()