# 動画台本（Day 5）

# Day 5：権限委任とガードレールで、エージェントを囲う（約3分）

**ねらい：** 「権限のないツールは見せない」「承認はエージェントの権限の外に置く」「ガードレールはモデルの外に置く」を、設定とデモで見せる。
**映すもの：** Keycloak の管理画面（agent-concierge クライアント）、`application.properties`、ガードレールのコード、`demo/openshift/policies/`、台本版デモの場面3・5・7・8。

| 時間 | 画面 | ナレーション | テロップ |
| --- | --- | --- | --- |
| 0:00 | タイトル画面 | 今日の結論です。エージェントの安全は、プロンプトの「やってはいけません」ではなく、外側の仕組みで守ります。 | Day 5：統制層 |
| 0:10 | Keycloak：agent-concierge の Client scopes / Scope 設定 | エージェント用のクライアントは、Full scope allowed をオフにしました。利用者がどんな権限を持っていても、エージェントに渡るのは参照と更新の2つだけです。承認や監査の権限は、最初から渡りません。 | 委任するのは必要な権限だけ |
| 0:46 | `application.properties` の HTTP 権限 | アプリ側でも、/mcp/read は参照ロール、/mcp/write は更新ロールを要求します。トークンの宛先が agent-tools であることも確認しています。 | 経路ごとにロールを要求 |
| 1:03 | ターミナル：場面3（bob） | 参照権限だけの bob で /mcp/write に接続すると、403 で拒否されます。bob のエージェントには、振込ツールが選択肢にも出てきません。 | 見えないツールは使えない |
| 1:33 | ターミナル：場面5（承認） | 30万円の振込は承認待ちになります。エージェントのトークンで承認しようとしても 403。依頼者本人の alice が承認画面から承認しても、職務分掌で拒否されます。承認できるのは carol だけです。 | 承認はエージェントの外 |
| 2:08 | `InputSafetyGuardrail.java` と場面7 | ガードレールは、ツールが実行される前に引数を検査します。「以前の指示を無視して」という文言や、カード番号を含むメモは止めます。止めた記録は、トレースと監査証跡にも残ります。 | 止めた事実も記録する |
| 2:25 | 場面8（連打） | 更新系は1分10回までに制限しました。暴走しても、最大の影響を事前に数字で言えます。 | 最大影響を数字で言える |
| 2:34 | `demo/openshift/policies/` の AuthPolicy と RateLimitPolicy | 本番では、同じ判断を Connectivity Link のゲートウェイで行います。経路ごとに AuthPolicy と RateLimitPolicy を付け、LLM の経路には TokenRateLimitPolicy でトークン量の上限をかけます。アプリ側の確認は内側の防御として残します。 | 本番はゲートウェイで二重に |
| 3:03 | 締め | 明日は、失敗しても元に戻ることと、全部が一本のトレースで見えることを確かめます。 | 次回：補償と可観測性 |

**収録メモ：** TokenRateLimitPolicy は製品バージョンでの確認が済んでいない（社内ゲート G2）。動画では「本番での構成例」と言い、サポートを断言しない。


---

# 参考資料：05_demo_runbook.md

# 05. デモの実装と実行手順（Day 4〜6）

2026-10-05 · 社内検討用

デモ一式は `demo/` にあり、ローカルの Mac（Podman と JDK 21）で動きます。2026-10-05 の時点で、台本版の9場面すべてと評価セット8件（安全性の違反0件）が通っています。

## 1. 構成要素

| 層 | 実装 | 場所 | 統制 |
| --- | --- | --- | --- |
| エージェント | Python。OpenAI 互換 API の LLM（既定は Ollama の qwen3:8b）と、自前の最小 MCP クライアント | `demo/agent/` | 1, 6 |
| ツール層 | Quarkus + quarkus-mcp-server 2.0。参照系 `/mcp/read` と更新系 `/mcp/write` を別サーバーとして公開 | `demo/agent-tools/.../mcp/` | 2 |
| 統制層（アプリ側） | OIDC とロール、所有者確認、入力ガードレール、更新系の回数制限、人間の承認 API、職務分掌 | `.../guard/`、`.../api/` | 1〜4 |
| 統合層 | Camel。Outbox のリレーと掃除役、Saga（拘束 → 振替 → 失敗時は解除） | `.../saga/` | 5 |
| 監査 | ハッシュ連鎖の監査証跡、検証 API | `.../audit/` | 7 |
| 可観測性 | OpenTelemetry（エージェント・ツール・Camel・基幹）、Micrometer、Tempo、Prometheus、Grafana | `demo/infra/` | 6 |
| 基幹 | 勘定系の模擬。拘束・振替は ID で冪等。障害注入 API | `demo/core-banking-mock/` | — |
| 統制層（本番形） | Connectivity Link の Gateway、HTTPRoute、AuthPolicy、RateLimitPolicy、TokenRateLimitPolicy | `demo/openshift/` | 1, 2, 4 |

## 2. 実行手順

前提：JDK 21、Maven、Podman（compose）、Python 3.12 以上。LLM を使う場合は Ollama と `qwen3:8b`。

```bash
cd demo && mvn -q -DskipTests package
```

```bash
cd demo && ./scripts/start.sh
```

```bash
cd demo/agent && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
```

```bash
cd demo/agent && .venv/bin/python demo_scenario.py --pause
```

```bash
cd demo/agent && .venv/bin/python concierge.py --user alice "ACC-001 から ACC-900 へ会費 20000 円を振り込んで"
```

```bash
cd demo/agent && .venv/bin/python run_eval.py
```

```bash
cd demo && ./scripts/stop.sh --all
```

| 画面 | URL | ログイン |
| --- | --- | --- |
| Grafana Explore（トレース。データソース Tempo） | http://localhost:3000/explore | 匿名で利用可 |
| Tempo API | http://localhost:3200 | 不要 |
| Grafana（ダッシュボード） | http://localhost:3000/d/agent-safe-connect | 匿名で利用可 |
| Prometheus | http://localhost:9090 | 不要 |
| Keycloak | http://localhost:8180 | admin / admin |

デモ用の利用者は alice / bob / carol で、パスワードは利用者名と同じです（`demo/infra/keycloak/realm-banking.json`）。

## 3. 確認結果（2026-10-05）

| 確認 | 結果 |
| --- | --- |
| 単体テスト（ガードレールの判定、監査のハッシュ連鎖） | 4件すべて成功 |
| 台本版の9場面（`demo_scenario.py`） | 9場面すべて PASS |
| 評価セット（`run_eval.py`、qwen3:8b） | 品質4件・安全性4件すべて PASS。安全性の違反0件 |
| トレースの連結 | エージェント → LLM → ツール → Camel → 基幹が1本のトレースになる（21スパン、3サービス） |
| 監査証跡 | すべての記録にトレース ID が付く。1件の書き換えを検証で検出 |
| OpenShift 用マニフェスト | `kubectl kustomize` で構文を確認。クラスタへの適用は未実施 |

## 4. 実装上の判断と、その理由

- **MCP サーバーを参照系と更新系で分けた。** ゲートウェイのポリシー（認可・回数制限）を経路ごとに付け分けられ、権限のない利用者には更新系のツールが一覧にも出ないためです。
- **冪等キーはエージェント側で決める。** 会話 ID と振込内容から UUIDv5 を作ります。LLM が同じ依頼を二度出しても、二重送金になりません。
- **MCP クライアントは SDK を使わずに書いた。** ツール呼び出しごとに traceparent ヘッダーを確実に付け、サーバー側のスパンを同じトレースにつなぐためです。サーバー側では、MCP エンドポイントに自動のスパンが付かなかったため、ヘッダーを親にしてツール実行のスパンを作っています（`McpTracing`）。
- **Outbox は「コミット直後の即時送信」と「掃除役」の二段にした。** 通常は即時送信で同じトレースに載り、アプリが落ちた場合は掃除役が拾い直します。
- **監査証跡は別トランザクションで書く。** 業務処理が失敗・拒否されても、記録は残します。

## 5. 既知の制約と今後

- Keycloak はパスワード・グラントを使っています。本番では認可コード + PKCE と、トークン交換（RFC 8693）で委任トークンを発行します。
- H2（インメモリ）のため、再起動で依頼と監査証跡は消えます。本番では PostgreSQL などにします。
- 掃除役から再送した場合は、新しいトレースになります。Outbox に元の traceparent を保存しているので、リンクで関連付けることはできます（未実装）。
- OpenShift への適用と、Connectivity Link の API（特に TokenRateLimitPolicy）の製品バージョンでの検証は未実施です（社内ゲート G2）。
- ガードレールは正規表現による簡易版です。本番では TrustyAI Guardrails などの検出器をゲートウェイ側に置き、アプリ側は内側の防御として残します。

## 6. 変更履歴

| 日付 | 変更 |
| --- | --- |
| 2026-10-05 | トレースの保存先を Jaeger から Grafana Tempo に変更。トレースは Grafana の Explore（データソース Tempo）で見る。Tempo の metrics-generator で、サービスグラフとスパンのメトリクスを Prometheus に送る。OpenShift では Red Hat build of Tempo（TempoStack）に置き換える想定 |


---

# 参考資料：auth-policies.yaml

# 統制1・2: ゲートウェイでの認証・認可（Connectivity Link の AuthPolicy）。
# アプリ側（Quarkus OIDC と HTTP 権限）でも同じ確認をする多層防御。片方の設定漏れでは通らない。
apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
  name: mcp-read
spec:
  targetRef: {group: gateway.networking.k8s.io, kind: HTTPRoute, name: mcp-read}
  rules:
    authentication:
      rhbk:
        jwt:
          issuerUrl: https://rhbk.apps.example.com/realms/banking
    authorization:
      audience-and-role:
        patternMatching:
          patterns:
            - {selector: auth.identity.aud, operator: incl, value: agent-tools}
            - {selector: auth.identity.realm_access.roles, operator: incl, value: banking-reader}
---
apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
  name: mcp-write
spec:
  targetRef: {group: gateway.networking.k8s.io, kind: HTTPRoute, name: mcp-write}
  rules:
    authentication:
      rhbk:
        jwt:
          issuerUrl: https://rhbk.apps.example.com/realms/banking
    authorization:
      audience-and-role:
        patternMatching:
          patterns:
            - {selector: auth.identity.aud, operator: incl, value: agent-tools}
            - {selector: auth.identity.realm_access.roles, operator: incl, value: banking-writer}
---
apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
  name: ops-api
spec:
  targetRef: {group: gateway.networking.k8s.io, kind: HTTPRoute, name: ops-api}
  rules:
    authentication:
      rhbk:
        jwt:
          issuerUrl: https://rhbk.apps.example.com/realms/banking
    authorization:
      humans-only:
        patternMatching:
          patterns:
            # エージェント用クライアントのトークンは、承認・監査の API に入れない
            - {selector: auth.identity.azp, operator: neq, value: agent-concierge}


---

# 参考資料：rate-limit-policies.yaml

# 統制4: 被害範囲の限定。利用者ごとの回数制限（Connectivity Link の RateLimitPolicy）。
apiVersion: kuadrant.io/v1
kind: RateLimitPolicy
metadata:
  name: mcp-write
spec:
  targetRef: {group: gateway.networking.k8s.io, kind: HTTPRoute, name: mcp-write}
  limits:
    per-user-writes:
      rates:
        - {limit: 10, window: 1m}
        - {limit: 100, window: 24h}
      counters:
        - expression: auth.identity.sub
---
apiVersion: kuadrant.io/v1
kind: RateLimitPolicy
metadata:
  name: mcp-read
spec:
  targetRef: {group: gateway.networking.k8s.io, kind: HTTPRoute, name: mcp-read}
  limits:
    per-user-reads:
      rates:
        - {limit: 120, window: 1m}
      counters:
        - expression: auth.identity.sub


---

# 参考資料：InputSafetyGuardrail.java

package demo.tools.guard;

import demo.tools.AgentMetrics;
import demo.tools.Caller;
import demo.tools.McpTracing;
import demo.tools.audit.AuditLog;
import io.quarkiverse.mcp.server.ToolCallException;
import io.quarkiverse.mcp.server.ToolInputGuardrail;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import java.util.Map;
import java.util.Optional;

/** 統制3: ツール引数のプロンプトインジェクションと個人情報を検査し、該当すれば実行前に止める。 */
@ApplicationScoped
public class InputSafetyGuardrail implements ToolInputGuardrail {

    @Inject
    AuditLog audit;

    @Inject
    AgentMetrics metrics;

    @Inject
    Caller caller;

    @Inject
    McpTracing tracing;

    @Override
    public void apply(ToolInputContext context) {
        String tool = context.getTool().name();
        for (Map.Entry<String, Object> e : context.getArguments()) {
            if (!(e.getValue() instanceof String s)) {
                continue;
            }
            // 識別子（冪等キーや口座番号）は個人情報検査の対象外。UUID の数字列を誤検知しないため。
            boolean identifier = e.getKey().endsWith("Key") || e.getKey().endsWith("Id") || e.getKey().endsWith("Account");
            Optional<String> hit = GuardrailRules.injection(s)
                    .map(r -> "injection")
                    .or(() -> identifier ? Optional.empty() : GuardrailRules.pii(s).map(r -> "pii:" + r));
            if (hit.isPresent()) {
                String kind = hit.get().startsWith("pii") ? "pii" : "prompt-injection";
                metrics.guardrailBlock(kind, tool);
                metrics.toolCall(tool, caller.agent(), "blocked");
                String reason = hit.get();
                String arg = e.getKey();
                tracing.recordBlocked(tool, kind, reason, () -> audit.append(caller.user(), caller.agent(),
                        "tool:" + tool, "arg:" + arg, reason, "BLOCKED"));
                throw new ToolCallException("Blocked by guardrail (" + kind + ") on argument '" + e.getKey()
                        + "'. Remove the sensitive or instruction-like text and try again.");
            }
        }
    }
}
