Setting the file. One moment.
Asm Exec · AWS Secrets Manager · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 93
Creating Amazon Aurora Db Cluster With Instances
Shieldadvanced
Resilience Program Design
Connecting Lambda To DynamoDB
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()