はじめに
Agent Skillsは、2025/12/18に標準化が行われた「AIエージェントの手順書」とも言える機能であり、いまやどんなエージェントハーネスにも実装されている、AIエージェント開発者にとっては必須の技術要素だ。一方で、スクリプトを使って自由に色々なことができるため、野良のスキルによる脅威というリスクを同時に抱えることになる。
OWASPのAgentic Skills Top 10でも、以下のようなリスクが挙げられていて、対策が必要だと言われている。
| # | Risk | Severity | Key Mitigation | Real-World Evidence |
|---|---|---|---|---|
| AST01 | Malicious Skills | Critical | Merkle root signing, registry scanning(Merkleルート署名、レジストリスキャン) | ClawHavoc (1,184 skills), ToxicSkills (76 payloads) |
| AST02 | Supply Chain Compromise | Critical | Registry transparency, provenance tracking(レジストリの透明性、来歴追跡) | ClawHub collapse, Claude Code CVE-2025-59536 |
| AST03 | Over-Privileged Skills | High | Least-privilege manifests, schema validation(最小権限マニフェスト、スキーマ検証) | 280+ credential-leaking skills (Snyk, Feb 2026) |
| AST04 | Insecure Metadata | High | Static analysis, safe parsers, sandboxed loading(静的解析、安全なパーサー、サンドボックス化された読み込み) | Fake “Google” skill impersonation; YAML payload delivery in SKILL.md |
| AST05 | Untrusted External Instructions | High | Source inventory, content pinning, continuous rescanning(参照元の棚卸し、コンテンツの固定、継続的な再スキャン) | Air PoC bypassed all scanners; 26,000 agents at risk |
| AST06 | Weak Isolation | High | Containerization, Docker sandboxing(コンテナ化、Dockerサンドボックス) | OpenClaw host-mode execution, 135K exposed instances |
| AST07 | Update Drift | Medium | Immutable pinning, hash verification(不変バージョン固定、ハッシュ検証) | ClawJacked (CVE-2026-28363), patch-lag exploitation |
| AST08 | Poor Scanning | Medium | Semantic + behavioral multi-tool pipeline(セマンティック分析と動作分析を組み合わせた複数ツールのパイプライン) | Pattern-matcher bypass via natural-language injection |
| AST09 | No Governance | Medium | Skill inventories, agentic identity controls(スキルのインベントリ管理、エージェントID制御) | 53K exposed instances with no SOC visibility |
| AST10 | Cross-Platform Reuse | Medium | Universal YAML format(汎用YAML形式) | Malicious skills ported across ClawHub, skills.sh |
そんな中で、NVIDIAが、脅威に対する救世主とも言える、Agent Skillsの妥当性を検証するためのOSS、SkillSpectorを発表した。
SkillSpectorとは
17カテゴリ、68パターンの脆弱性に対する検査機能を持っていて、ディレクトリやZipファイルを渡すことで結果を出力してくれる。
さらに、パターンマッチの静的解析以外に加えて、LLMによる意味評価も可能だ。
CLI/SDKの呼び出しにも対応していて、CI/CDからの呼び出しなども柔軟に対応できる。
ライセンスはApache 2.0ライセンスであり、自由度も高く利用できるのも魅力だ。
今回の記事では、SkillSpectorをAWS Lambda Functionから呼び出し、検証済みの情報を付与した結果を作成する。
全体構成
今回は、Amazon S3のイベント通知を使って、入ってきたZipファイルを検証し、結果を再度Zipファイルに固めるという構成にする。
AWS Lambda Functionで実行するコードの全体像をまとめておく。
また、この後、Terraformでの構築例をまとめていく。
AWS Lambda Function実行コード
(上に空行が必要)
import hashlib
import io
import json
import os
import tempfile
import urllib.parse
import uuid
import zipfile
from datetime import UTC, datetime
from pathlib import Path
import boto3
RECOMMENDATION_TO_DECISION = {
"SAFE": "APPROVED",
"CAUTION": "WARNING",
"DO_NOT_INSTALL": "REJECTED",
}
def _normalize(value: dict) -> bytes:
"""署名対象を常に同じバイト列へ正規化する。"""
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()
def _manifest_for(skill_dir: Path) -> bytes:
"""Skill配下のファイルから決定的なManifestを作る。"""
files = []
for path in sorted(skill_dir.rglob("*")):
if not path.is_file() or ".verification" in path.relative_to(skill_dir).parts:
continue
content = path.read_bytes()
files.append(
{
"path": path.relative_to(skill_dir).as_posix(),
"sha256": hashlib.sha256(content).hexdigest(),
"size": len(content),
}
)
manifest = {"schemaVersion": 1, "files": files}
return _normalize(manifest)
def _sign_attestation(attestation: dict) -> bytes:
"""AttestationのSHA-256ダイジェストをAWS KMSで署名する。"""
digest = hashlib.sha256(_normalize(attestation)).digest()
response = boto3.client("kms").sign(
KeyId=os.environ["KMS_KEY_ID"],
Message=digest,
MessageType="DIGEST",
SigningAlgorithm="RSASSA_PSS_SHA_256",
)
return response["Signature"]
def _inspect_skill(skill_dir: Path, validation_id: str) -> None:
"""SkillSpectorを実行し、Skill内へ検証情報を書き込む。"""
model = os.environ["SKILLSPECTOR_MODEL"]
from skillspector import graph
result = graph.invoke(
{
"skill_path": str(skill_dir),
"output_format": "json",
"use_llm": True,
}
)
normalized_manifest = _manifest_for(skill_dir)
recommendation = result["risk_recommendation"]
decision = RECOMMENDATION_TO_DECISION[recommendation]
severity = result["risk_severity"]
sarif = result["sarif_report"]
attestation = {
"schemaVersion": 1,
"attestationId": validation_id,
"validationId": validation_id,
"subject": {
"skillName": skill_dir.name,
"manifestSha256": hashlib.sha256(normalized_manifest).hexdigest(),
},
"scanner": {
"name": "SkillSpector",
"version": "unknown",
"mode": "LLM",
"model": model,
},
"result": {
"status": "COMPLETED",
"severity": severity,
"riskScore": result["risk_score"],
"recommendation": recommendation,
"decision": decision,
},
"issuedAt": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
"issuer": os.environ["ATTESTATION_ISSUER"],
}
verification_dir = skill_dir / ".verification"
verification_dir.mkdir(exist_ok=True)
(verification_dir / "skillspector-results.sarif").write_bytes(json.dumps(sarif).encode())
(verification_dir / "skill-manifest.json").write_bytes(normalized_manifest)
(verification_dir / "skill-attestation.json").write_bytes(_normalize(attestation))
(verification_dir / "skill-attestation.sig").write_bytes(_sign_attestation(attestation))
def _build_verified_zip(extracted_dir: Path) -> bytes:
"""検証情報を追加した展開済みSkillディレクトリをZIPに戻す。"""
output = io.BytesIO()
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(extracted_dir.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(extracted_dir).as_posix())
return output.getvalue()
def lambda_handler(event: dict, context: object) -> dict:
record = event["Records"][0]
validation_id = str(uuid.uuid4())
""" Inputバケットの情報取得"""
source = (
boto3.client("s3")
.get_object(
Bucket=record["s3"]["bucket"]["name"], Key=urllib.parse.unquote_plus(record["s3"]["object"]["key"])
)["Body"]
.read()
)
""" Zipファイルの解凍とSkillSpectorの実行 """
with tempfile.TemporaryDirectory() as work_dir:
extracted_dir = Path(work_dir) / "skill"
extracted_dir.mkdir()
with zipfile.ZipFile(io.BytesIO(source)) as archive:
archive.extractall(extracted_dir)
skill_dir = next(path.parent for path in extracted_dir.rglob("SKILL.md"))
_inspect_skill(skill_dir, validation_id)
verified_zip = _build_verified_zip(extracted_dir)
""" SkillSpector実行結果のOutputバケットへの格納"""
output_bucket = os.environ["OUTPUT_BUCKET"]
key = f"output/{validation_id}/verified-skills.zip"
boto3.client("s3").put_object(Bucket=output_bucket, Key=key, Body=verified_zip)
return {"result": "OK"}
AWSのリソース作成
Amazon S3 Bucket
Amazon S3 Bucketを以下のように作っていく。
例によって、aws_s3_bucket_public_access_block、aws_s3_bucket_ownership_controlsはセキュリティのおなじないとして設定していく。
今回は最小限の構成として書いておくので、aws_s3_bucket_server_side_encryption_configuration、aws_s3_bucket_lifecycle_configurationは必要に応じて適宜設定をしておこう。
resource "aws_s3_bucket" "input" {
bucket = local.s3_input_bucket_name
}
resource "aws_s3_bucket_public_access_block" "input" {
bucket = aws_s3_bucket.input.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "input" {
bucket = aws_s3_bucket.input.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_versioning" "input" {
bucket = aws_s3_bucket.input.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_notification" "input" {
bucket = aws_s3_bucket.input.id
lambda_function {
lambda_function_arn = aws_lambda_function.skillspector.arn
filter_suffix = ".zip"
events = [
"s3:ObjectCreated:*",
]
}
depends_on = [
aws_lambda_permission.s3_invoke_skillspector,
]
}
resource "aws_s3_bucket" "output" {
bucket = local.s3_output_bucket_name
force_destroy = true
}
resource "aws_s3_bucket_public_access_block" "output" {
bucket = aws_s3_bucket.output.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "output" {
bucket = aws_s3_bucket.output.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
AWS Lambda Functionの準備
続いて、AWS Lambda関数を用意していく。
今回、LLM評価のモデルはClaude Sonnet 4.6を使う。
また、SkillSpectorはパッケージが大きいため、通常のZip渡しではサイズ超過で設定がエラーになる。
S3バケット経由でアプリケーションのZipを渡すことにする。
inputバケットはAmazon S3イベント通知の設定をしているので、outputバケットに置くことにする。
resource "aws_lambda_function" "skillspector" {
function_name = local.lambda_function_name
role = aws_iam_role.skillspector.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
s3_bucket = aws_s3_bucket.output.id
s3_key = aws_s3_object.skillspector_lambda_artifact.key
source_code_hash = filebase64sha256(local.skillspector_lambda_zip_path)
memory_size = 512
timeout = 900
ephemeral_storage {
size = 1024
}
environment {
variables = {
OUTPUT_BUCKET = aws_s3_bucket.output.id
KMS_KEY_ID = aws_kms_key.attestation.key_id
SKILLSPECTOR_MODEL = "jp.anthropic.claude-sonnet-4-6"
ATTESTATION_ISSUER = "SkillSpector"
}
}
depends_on = [
aws_cloudwatch_log_group.skillspector,
]
}
resource "aws_cloudwatch_log_group" "skillspector" {
name = "/aws/lambda/${local.lambda_function_name}"
}
resource "aws_lambda_permission" "s3_invoke_skillspector" {
statement_id = "AllowS3InvokeSeal"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.skillspector.function_name
principal = "s3.amazonaws.com"
source_arn = aws_s3_bucket.input.arn
source_account = data.aws_caller_identity.current.account_id
}
resource "aws_s3_object" "skillspector_lambda_artifact" {
bucket = aws_s3_bucket.output.id
key = "_lambda_artifacts/skill_spector.zip"
source = local.skillspector_lambda_zip_path
etag = filemd5(local.skillspector_lambda_zip_path)
}
data "aws_caller_identity" "current" {}
IAMは以下のように設定する。
LLM評価のモデルにClaude Sonnet 4.6を使うため、モデルと推論プロファイル両方のARNを設定する。
resource "aws_iam_role" "skillspector" {
name = local.iam_role_lambda_skillspector_name
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = [
"lambda.amazonaws.com",
]
}
actions = [
"sts:AssumeRole",
]
}
}
resource "aws_iam_role_policy" "skillspector" {
name = local.iam_policy_lambda_skillspector_name
role = aws_iam_role.skillspector.id
policy = data.aws_iam_policy_document.lambda_custom.json
}
data "aws_iam_policy_document" "lambda_custom" {
statement {
effect = "Allow"
actions = [
"s3:GetObject",
"s3:GetObjectVersion",
]
resources = [
"${aws_s3_bucket.input.arn}/*",
]
}
statement {
effect = "Allow"
actions = [
"s3:PutObject",
]
resources = [
"${aws_s3_bucket.output.arn}/*",
]
}
statement {
effect = "Allow"
actions = [
"bedrock:InvokeModel",
]
resources = [
"arn:aws:bedrock:ap-northeast-3::foundation-model/anthropic.claude-sonnet-4-6",
"arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-sonnet-4-6",
"arn:aws:bedrock:ap-northeast-1:${data.aws_caller_identity.current.id}:inference-profile/jp.anthropic.claude-sonnet-4-6",
]
}
statement {
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
aws_cloudwatch_log_group.skillspector.arn,
"${aws_cloudwatch_log_group.skillspector.arn}:log-stream:*",
]
}
}
AWS Lambda Functionのコードのポイント
さて、AWSのリソースができたので、ここからAWS Lambda Functionのコードのポイントを説明する。
Inputバケットからの情報取得
今回、Inputバケットの入力書式は問わない(Suffixが.zipであれば通知される)ので、そのまま取得しよう。
source = (
boto3.client("s3")
.get_object(
Bucket=record["s3"]["bucket"]["name"], Key=urllib.parse.unquote_plus(record["s3"]["object"]["key"])
)["Body"]
.read()
)
なお、Amazon S3イベント通知のイベント構造は以下を参照。
Zip解凍で一時ファイルを作成
Pythonの標準モジュールであるtempfileを使って、一時的な領域を作りそこにZipファイルを展開する。
""" Zipファイルの解凍とSkillSpectorの実行 """
with tempfile.TemporaryDirectory() as work_dir:
extracted_dir = Path(work_dir) / "skill"
extracted_dir.mkdir()
with zipfile.ZipFile(io.BytesIO(source)) as archive:
archive.extractall(extracted_dir)
skill_dir = next(path.parent for path in extracted_dir.rglob("SKILL.md"))
_inspect_skill(skill_dir, validation_id)
SkillSpectorの呼び出し
Skillspectorは簡単に呼び出しが可能だ。
"use_llm": Trueにする場合、SKILLSPECTOR_MODELのモデルを実行しにいくようになっている。
これは、先述したAWS Lambda Functionのリソース生成時にClaude Sonnet 4.6を渡しているため、ここで実行が決定する。
model = os.environ["SKILLSPECTOR_MODEL"]
from skillspector import graph
result = graph.invoke(
{
"skill_path": str(skill_dir),
"output_format": "json",
"use_llm": True,
}
)
SkillSpectorのレポート自体は result["sarif_report"] に格納される。
これを、出力ディレクトリに出そう。
sarif = result["sarif_report"]
verification_dir = skill_dir / ".verification"
verification_dir.mkdir(exist_ok=True)
(verification_dir / "skillspector-results.sarif").write_bytes(json.dumps(sarif).encode())
また、以下の情報が取得可能だ。
- 重大度: result["risk_severity"]
- 推奨: result["risk_recommendation"]
- リスクスコア: result["risk_score"]
これらは、レポートとは別に、skill-attestation.jsonとしてレポートを作成しておこう。
※コードは長いので割愛。
出力Zipファイルの作成
以下の関数を作成し、tempfile.TemporaryDirectoryのWithブロックから呼び出そう。
def _build_verified_zip(extracted_dir: Path) -> bytes:
"""検証情報を追加した展開済みSkillディレクトリをZIPに戻す。"""
output = io.BytesIO()
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(extracted_dir.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(extracted_dir).as_posix())
return output.getvalue()
これで、最低限の実装は完了だ。
いざ、動かす!
さて、これでInputのAmazon S3バケットに、Skillsの入ったZipファイルを格納すれば、Outputバケットに、verified-skills.zipというファイルが作成されるはずだ。LLMの実行もあるので、Agent Skillsの大きさにもよるが、数分はかかる。
今回、サンプルとして、Anthropic公式Agent Skillsのxlsxを入力してみた。
結果としては、
- Severity: CRITICAL
- Recommendation: DO_NOT_INSTALL
- RiskScore: 100
となり、非常にハイリスクなものになった。
リスク内容がsarif形式で出力されるので確認すると、やはりスクリプトや外部コマンド実行が、OWASPの脅威に該当するとして高リスク判定されているようだ。
{
"version": "2.1.0",
"$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.4.json",
"runs": [
{
"tool": {
"driver": {
"name": "skillspector",
"version": "2.3.13",
"rules": [
{
"id": "AST4",
"shortDescription": {
"text": "The `run_soffice` function passes user-supplied `args` directly into a subprocess call without any sanitization or allowlisting. An attacker who controls the arguments could inject arbitrary shell-level options or file paths (e.g., `--infiltrate`, macros, or paths outside the expected working directory). While the command is passed as a list (not a shell string), the lack of validation still permits misuse via crafted argument lists."
}
},
{
"id": "E2",
"shortDescription": {
"text": "Env Variable Harvesting"
}
},
(中略)
]
}
},
"results": [
{
"ruleId": "AST4",
"message": {
"text": "The `run_soffice` function passes user-supplied `args` directly into a subprocess call without any sanitization or allowlisting. An attacker who controls the arguments could inject arbitrary shell-level options or file paths (e.g., `--infiltrate`, macros, or paths outside the expected working directory). While the command is passed as a list (not a shell string), the lack of validation still permits misuse via crafted argument lists."
},
"level": "warning",
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "scripts/office/soffice.py"
},
"region": {
"startLine": 37,
"endLine": 37
}
}
}
],
"properties": {
"severity": "MEDIUM",
"category": "Dangerous Code Execution",
"pattern": "subprocess module call",
"confidence": 0.72,
"finding": " return subprocess.run([\"soffice\"] + args, env=env, **kwargs)",
"explanation": "The `run_soffice` function passes user-supplied `args` directly into a subprocess call without any sanitization or allowlisting. An attacker who controls the arguments could inject arbitrary shell-level options or file paths (e.g., `--infiltrate`, macros, or paths outside the expected working directory). While the command is passed as a list (not a shell string), the lack of validation still permits misuse via crafted argument lists.",
"remediation": "Validate and allowlist all arguments passed to `run_soffice`. At minimum, enforce that accepted flags match a known safe set (e.g., `--headless`, `--convert-to`) and that file paths are within expected directories. Consider using `subprocess.run` with `check=True` and explicit timeouts.",
"code_snippet": "\ndef run_soffice(args: list[str], **kwargs) -> subprocess.CompletedProcess:\n env = get_soffice_env()\n return subprocess.run([\"soffice\"] + args, env=env, **kwargs)\n\n\n",
"tags": [
"Dangerous Code Execution"
]
}
},
(中略)
]
}
]
}
利用者は、このレポートの内容を確認し、問題がないかを評価して導入を判断するうえで、さらなるリスク対策として独立・隔離したサンドボックス環境で動作させるなどの対策が必要になる。
AIエージェントに限らずだが、セキュリティは多層的な対策を行っていくのが原則なので、利便性とのトレードオフをみながら最適解を探すことになる。
次の一手をどう考えるか
個人で利用する場合は自己責任でリスク評価をすれば良いが、筆者はマルチテナントのエージェントハーネスプラットフォームの運用をしている。
Strands Agentsの機能を使いAgent Skillsも実装しているが、自己責任とはいえ事故られて自分が運営しているプラットフォームから情報流出すると困ったことになってしまうため、ある程度Skillsにも縛りを入れる必要があるだろう。
今回作ったZipファイルには、skill-attestation.jsonとして評価結果のサマリを格納している。
この内容でリスクが高い場合は、Skillsのロード時にエラーを出して、Skillsを使わせないという変更を行った。(当然、.verificationのないSkill実行は未検証としてエラーにするようにもした)
また、それだと利便性が下がるため、validationIdによるホワイトリスト運用を行うことにした。
なお、ベースラインのYAMLを作成することで、検知した脆弱性を抑止することもできるが、これを許容してしまうと、結局利用者が好き放題に通すことができてしまいガバナンスが効かないので、採用を見送った。
多少の運用負荷が高まるが、先述した、利便性とのトレードオフがある中で解を探した一つの例として考えていただきたい。
でもskill-attestation.jsonを改ざんしたら動かせてしまうのでは?
当然、マルチテナントで運用する以上、そういう抜け道を探す人が出てくることは想定しなければいけない。
そこで、今回の仕組みでは、ファイル構成(skill-manifest.json)、結果のサマリ(skill-attestation.json)に対して署名を行い、プラットフォーム側で署名検証を行うことで、上記のような改ざんを検知してブロックするような実装も行っている。
署名は、KMSの非対称キーを用いた公開鍵によるダイジェストの検証を行う。
AWSリソースの変更
以下のAWS KMSリソースを作成する。
resource "aws_kms_key" "attestation" {
description = "SkillSeal Attestation signing key (${local.prefix})"
key_usage = "SIGN_VERIFY"
customer_master_key_spec = "RSA_3072"
}
resource "aws_kms_alias" "attestation" {
name = local.kms_alias_name
target_key_id = aws_kms_key.attestation.key_id
}
data "aws_kms_public_key" "attestation" {
key_id = aws_kms_key.attestation.key_id
}
AWS Lambda FunctionにアタッチしているIAMロールに以下の権限を追加する。
statement {
effect = "Allow"
actions = [
"kms:Sign",
]
resources = [
aws_kms_key.attestation.arn,
]
}
コードで以下の関数を定義し、
def _sign_attestation(attestation: dict) -> bytes:
"""AttestationのSHA-256ダイジェストをAWS KMSで署名する。"""
digest = hashlib.sha256(_normalize(attestation)).digest()
response = boto3.client("kms").sign(
KeyId=os.environ["KMS_KEY_ID"],
Message=digest,
MessageType="DIGEST",
SigningAlgorithm="RSASSA_PSS_SHA_256",
)
return response["Signature"]
_inspect_skillの最後に呼び出しを追加する。
def _inspect_skill(skill_dir: Path, validation_id: str) -> None:
# (中略)
(verification_dir / "skill-attestation.sig").write_bytes(_sign_attestation(attestation))
これで、より強固なAgent Skillsを実行できる基盤が作れるようになった!
