2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

IBM Bob Premium Package for i でLifecycle hooksを使用してみる

2
Last updated at Posted at 2026-08-21

IBM Bob Premium Package for i を検証する機会があり、AI に実行させたくない操作をどう止めるかを調べていたところ、Lifecycle hooks という機能があったので、実際に設定して動きを確認した内容を備忘録代わりに記載します。

セキュリティー設計全体の話ではなく、hooks が何をできて何をできないのかを実際に動かして確認した記録になります。

はじめに

IBM Bob Premium Package for i は、IBM i に対して CL コマンドの実行や SQL の実行、ソースメンバーの読み書きなどを直接行うことができます。便利な反面、AI が意図しない操作を行う可能性がありますので、何らかの歯止めが必要になります。

その歯止めの1つが hooks です。

用語解説

Lifecycle hooks

IBM Bob の機能で、セッション中の決められたタイミングでシェルコマンドを自動実行できる仕組みです。実行結果によって、AI が行おうとしている操作をブロックすることができます。

今回使用するのは PreToolUse というイベントで、AI がツールを呼び出す直前に発火します。

ツール

Bob が IBM i を操作する際に呼び出す機能の単位です。CL コマンドの実行、SQL の実行などがそれぞれ別のツールになっており、hooks はツール名を指定して仕掛けます。

hooks の基本的な仕組み

設定場所

設定は settings.json に記載します。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^execute_cl_command",
        "hooks": [
          {
            "type": "command",
            "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%USERPROFILE%\\.bob\\hooks\\block-cl-command.ps1\"",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

matcher に対象のツール名を指定し、command に実行したいスクリプトを書きます。

終了コードで許可と拒否を伝える

呼び出されたスクリプトは、終了コードで結果を返します。

終了コード 意味
0 許可(そのまま実行される)
2 拒否(実行されない)

拒否した場合、標準エラー出力に書いた内容が Bob に渡されます。理由を書いておくと、AI がそれを読んで応答してくれます。

渡されるデータ

スクリプトには標準入力から JSON が渡されます。実際に受け取った内容が下記になります。

{
  "session_id": "db6201e9194c1a9f64a9c7b5fa762938",
  "cwd": "c:\\Users\\85282\\.bob",
  "hook_event_name": "PreToolUse",
  "tool_name": "execute_cl_command",
  "tool_input": {
    "command": "WRKSYSVAL SYSVAL(QCCSID)"
  },
  "tool_use_id": "tooluse_fynTlKByz0rIP13bybL7Si"
}

実行されようとしている CL コマンドが tool_input.command にそのまま入っています。パラメーターも含まれていますので、コマンド名だけでなく引数の内容でも判定ができます。

IBM i 向けのツール名

検証の過程で確認できたツール名は下記になります。

ツール名 対象 コマンドが入るフィールド
execute_cl_command CL コマンドの実行 tool_input.command
execute_sql_statement SQL の実行 tool_input.statement
write_member ソースメンバーへの書き込み tool_input.member.library など
write_stream_file IFS への書き込み tool_input.path

ツールごとにフィールド名が違いますので注意が必要です。特に write_member はライブラリー名が構造化されたフィールドで渡ってきますので、文字列を解析する必要がなく確実に判定できます。

実際に作ってみる

まずは単純に拒否してみる

システム値を参照するコマンドを拒否してみます。

#Requires -Version 5.1
# block-cl-command.ps1
# 指定した CL コマンドの実行を拒否します。
# 終了コード 0=許可 / 2=拒否

$LogPath = Join-Path $env:USERPROFILE '.bob\hooks\block-cl-command.log'

$blockedPatterns = @(
    '\bWRKSYSVAL\b'
    '\bDSPSYSVAL\b'
)

function Write-Log([string]$msg) {
    try {
        $dir = Split-Path $LogPath -Parent
        if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        "$(Get-Date -Format s)`t$msg" | Out-File -FilePath $LogPath -Append -Encoding utf8
    } catch { }
}

$raw = [Console]::In.ReadToEnd()
try {
    $payload = $raw | ConvertFrom-Json -ErrorAction Stop
    $cmdText = [string]$payload.tool_input.command
} catch {
    Write-Log "UNKNOWN: 解析できません"
    [Console]::Error.WriteLine("BLOCKED: 入力を解析できませんでした")
    exit 2
}

$normalized = $cmdText.ToUpperInvariant()

foreach ($p in $blockedPatterns) {
    if ($normalized -match $p) {
        Write-Log "BLOCK [$p] $cmdText"
        [Console]::Error.WriteLine("このコマンドは実行が制限されています。")
        [Console]::Error.WriteLine("Command: $cmdText")
        exit 2
    }
}

Write-Log "ALLOW $cmdText"
exit 0

Bob に DSPSYSVAL を実行させると、下記のように返ってきました。

DSPSYSVAL コマンドはブロックポリシーにより実行が制限されています。

ログにも記録されています。

2026-08-21T12:45:11	BLOCK execute_cl_command: {"session_id":"...","tool_input":{"command":"WRKSYSVAL SYSVAL(QCCSID)"},...}
2026-08-21T12:49:52	BLOCK [\bWRKSYSVAL\b] WRKSYSVAL SYSVAL(QCCSID)
2026-08-21T12:51:20	BLOCK [\bDSPSYSVAL\b] DSPSYSVAL SYSVAL(QCCSID)

想定どおり止まりました。

判定できないときは止める

上のスクリプトでは、JSON を解析できなかった場合に exit 2 で止めています。

ここを exit 0 にして通す作りにすると、何か想定外のことが起きたときに素通りしてしまいます。ブロックしているつもりで実は動いていた、という状態が一番危険ですので、判定できない場合は止める作りにしておくことをおすすめします。

拒否リスト方式の問題点

AIは別の手段を探す

WRKSYSVALDSPSYSVAL を拒否したので、システム値は参照できなくなったはずです。

そこで続けて、同じ情報を別の方法で取得できないか試したところ、下記の SQL で取得できてしまいました。

SELECT SYSTEM_VALUE_NAME, CURRENT_NUMERIC_VALUE
  FROM QSYS2.SYSTEM_VALUE_INFO
 WHERE SYSTEM_VALUE_NAME = 'QCCSID'

SQL は execute_sql_statement という別のツールで実行されますので、execute_cl_command に仕掛けた hooks には引っかかりません。

これは特殊な回避方法を使ったわけではなく、AI が同じ目的を達成する別の手段を選んだだけです。hooks はコマンドの文字列を見ているだけで、何をしようとしているかは判定していない、ということがよくわかる結果になりました。

同じコマンドが複数のライブラリーに存在する

もう1つ、興味深い挙動がありました。

ライブラリー修飾なしで CL コマンドを実行させようとしたところ、hooks ではなくツール自体がエラーを返しました。

Error from tool execute_cl_command: The provided CL command was not library qualified.
Consider calling this tool again with the appropriate command library found using
QSYS2.COMMAND_INFO: QSYS/DSPLIB, QSYSV7R4M0/DSPLIB, QSYSV7R5M0/DSPLIB, QSYS38/DSPLIB

execute_cl_command はライブラリー修飾を必須としているようです。そしてこのエラーメッセージは、解決方法まで提示しています。Bob はこれを読んで QSYS/DSPLIB LIB(IMAO) として実行し直しました。

ここで注意したいのが、同じコマンドが QSYS 以外にも QSYSV7R4M0QSYSV7R5M0QSYS38 に存在するという点です。

そのため、拒否パターンをライブラリー込みで書いてしまうと簡単に回避されます。

# 良くない例(QSYS38/DSPSYSVAL で回避されます)
'\bQSYS/DSPSYSVAL\b'

# 良い例
'\bDSPSYSVAL\b'

許可リスト方式にしてみる

拒否リストは、禁止したいものを列挙し続ける必要があり、書き漏らしがそのまま穴になります。そこで、許可したライブラリー以外はすべて拒否する方式に変えてみます。

許可リストをどこから取るか

Code for IBM i の接続設定に、接続時のライブラリーリストが定義されています。

"code-for-ibmi.connectionSettings": [
    {
        "name": "STUDENT99",
        "libraryList": [
            "STUDENT99",
            "QGPL",
            "QTEMP",
            "RPGUNIT",
            "QDEVTOOLS"
        ]
    }
]

これを許可リストとして使えば、設定を二重に管理せずに済みます。

なお、この libraryList 自体には制御の力はありません。接続時にライブラリーリストを設定するだけの指定であり、ここに書いていないライブラリーでも修飾すれば普通に参照できます。あくまで hooks 側で許可リストとして利用するという話になります。

アクティブな接続をどう判定するか

接続設定は複数持てますので、現在どれに接続しているかを知る必要があります。接続名をスクリプトに直接書いてしまうと、接続を切り替えたときに動かなくなります。

調べたところ、下記のファイルに最後に接続した接続名が記録されていました。

%APPDATA%\IBM Bob\User\globalStorage\state.vscdb

このファイルは SQLite で、ItemTablehalcyontechltd.code-for-ibmi というキーに JSON が入っており、その中の lastConnections から取得できます。

バイナリを直接読む方式は使えない

最初、PowerShell だけで完結させたかったので、SQLite のファイルを文字列として読み込んで正規表現で接続名を拾おうとしました。しかし、これは正しく動作しません。

実際にファイル内の出現箇所を確認したところ、下記のようになっていました。

[0] idx=144852  name='はじめてのIBM Bob'   ← 最初の出現(古い世代)
[1] idx=148960  name='HAJIBOB'
[2] idx=149059  name='(なし)'
[3] idx=149880  name='STUDENT99'
[4] idx=151742  name='STUDENT99'
[5] idx=151992  name='STUDENT99'
[6] idx=152183  name='STUDENT99'          ← 最新の行

SQLite は更新時に古いページをその場で消さず、新しいページを別の場所に書いてポインターを付け替えます。そのためバイナリとして読むと古い世代のデータが残っており、先頭から検索すると古い接続名を拾ってしまいます。

※注意:後ろから検索すれば最新が取れるように見えますが、フリーページが再利用されると順序は保証されません。必ず SQL で取得してください。

Python 経由で取得する

SQLite を正しく読むには、SQL を発行する必要があります。Python の sqlite3 は標準ライブラリーですので、Python さえ入っていれば追加のインストールは不要です。

import sqlite3, json, sys
con = sqlite3.connect(sys.argv[1])
row = con.execute(
    "SELECT value FROM ItemTable WHERE key='halcyontechltd.code-for-ibmi'"
).fetchone()
con.close()
if row:
    last = json.loads(row[0]).get('lastConnections', [])
    if last:
        print(last[0]['name'])

共通処理をまとめる

hooks を複数のツールに仕掛けると、接続名の取得と許可リストの構築が毎回必要になります。共通ファイルに切り出しておきます。

#Requires -Version 5.1
# _common.ps1
# 各スクリプトの冒頭でドットソースして読み込みます。

$script:SettingsPath = Join-Path $env:APPDATA  'IBM Bob\User\settings.json'
$script:StatePath    = Join-Path $env:APPDATA  'IBM Bob\User\globalStorage\state.vscdb'
$script:HooksDir     = Join-Path $env:USERPROFILE '.bob\hooks'
$script:CachePath    = Join-Path $script:HooksDir '.conn-cache'
$script:CacheSeconds = 60

function Write-HookLog {
    param([string]$LogName, [string]$Message)
    try {
        if (-not (Test-Path $script:HooksDir)) {
            New-Item -ItemType Directory -Path $script:HooksDir -Force | Out-Null
        }
        $path = Join-Path $script:HooksDir "$LogName.log"
        "$(Get-Date -Format s)`t$Message" | Out-File -FilePath $path -Append -Encoding utf8
    } catch { }
}

function Get-PythonPath {
    $cmd = Get-Command python, python3, py -ErrorAction SilentlyContinue | Select-Object -First 1
    if ($cmd) { return $cmd.Source }
    throw 'Python が見つかりません。hooks の利用には Python が必要です。'
}

function Get-ActiveConnectionName {
    # ツール呼び出しのたびに実行されるため、短時間キャッシュします
    if ((Test-Path $script:CachePath) -and
        ((Get-Date) - (Get-Item $script:CachePath).LastWriteTime).TotalSeconds -lt $script:CacheSeconds) {
        $cached = (Get-Content $script:CachePath -Raw -Encoding UTF8).Trim()
        if ($cached) { return $cached }
    }

    $py = @'
import sqlite3, json, sys
con = sqlite3.connect(sys.argv[1])
row = con.execute(
    "SELECT value FROM ItemTable WHERE key='halcyontechltd.code-for-ibmi'"
).fetchone()
con.close()
if row:
    last = json.loads(row[0]).get('lastConnections', [])
    if last:
        print(last[0]['name'])
'@
    $tmpPy = Join-Path $script:HooksDir '_active_conn.py'
    [System.IO.File]::WriteAllText($tmpPy, $py, [System.Text.Encoding]::UTF8)

    $out  = & (Get-PythonPath) $tmpPy $script:StatePath 2>&1
    $name = ($out | Select-Object -First 1).ToString().Trim()
    if (-not $name) { throw 'lastConnections から接続名を取得できません' }

    $name | Out-File $script:CachePath -Encoding utf8 -NoNewline
    return $name
}

function Get-HookContext {
    param([string[]]$AlwaysAllow = @('QSYS','QSYS2','QTEMP','QGPL','SYSTOOLS'))

    $connName = (Get-ActiveConnectionName).ToUpperInvariant()

    $json = [System.Text.Encoding]::UTF8.GetString(
                [System.IO.File]::ReadAllBytes($script:SettingsPath))
    $settings = $json | ConvertFrom-Json

    $conn = $settings.'code-for-ibmi.connectionSettings' |
            Where-Object { $_.name.ToUpperInvariant() -eq $connName }
    if (-not $conn) { throw "接続 $connName の設定が見つかりません" }

    $libs = @()
    if ($conn.libraryList) { $libs += $conn.libraryList }
    if ($conn.currentLibrary -and $conn.currentLibrary -ne '*CRTDFT') { $libs += $conn.currentLibrary }
    if ($conn.tempLibrary)  { $libs += $conn.tempLibrary }

    return [pscustomobject]@{
        ConnectionName = $connName
        AllowList      = ($libs + $AlwaysAllow |
                          Where-Object { $_ } |
                          ForEach-Object { $_.ToUpperInvariant() } |
                          Select-Object -Unique)
    }
}

function Read-HookPayload {
    $stdin = [Console]::OpenStandardInput()
    $ms    = New-Object System.IO.MemoryStream
    $buf   = New-Object byte[] 4096
    do {
        $n = $stdin.Read($buf, 0, $buf.Length)
        if ($n -gt 0) { $ms.Write($buf, 0, $n) }
    } while ($n -gt 0)

    $raw = [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
    if (-not $raw.Trim()) { throw '入力が空です' }
    return $raw | ConvertFrom-Json
}

hooks はツール呼び出しのたびに起動しますので、毎回 Python を立ち上げると待ち時間が発生します。接続名は頻繁に変わるものではありませんので、60秒間キャッシュするようにしています。

CL コマンド用のスクリプト

#Requires -Version 5.1
# block-execute-cl-command.ps1
# CL コマンドが許可ライブラリー外を参照していないか検査します。

. (Join-Path $env:USERPROFILE '.bob\hooks\_common.ps1')
$LogName = 'block-execute-cl-command'

# ライブラリーリストを変更するコマンド
# これを許可すると、以降のコマンドからライブラリー名が消えてしまいます
$liblChangePatterns = @(
    '\bADDLIBLE\b', '\bRMVLIBLE\b', '\bCHGLIBL\b',
    '\bCHGCURLIB\b', '\bCHGSYSLIBL\b', '\bEDTLIBL\b'
)

$qualifiedParams = 'FILE|OBJ|SRCFILE|TOFILE|FROMFILE|PGM|SRVPGM|MODULE|BNDDIR|CMD|DTAARA|DTAQ|OUTFILE|JOBD|MSGQ|OUTQ'
$libraryParams   = 'LIB|SRCLIB|TOLIB|FROMLIB|OBJLIB|CURLIB|SAVLIB|RSTLIB'

function Deny([string]$reason, [string]$cmd) {
    Write-HookLog $LogName "BLOCK $reason : $cmd"
    [Console]::Error.WriteLine("BLOCKED: $reason")
    [Console]::Error.WriteLine("Command: $cmd")
    [Console]::Error.WriteLine("Allowed: $($script:allowList -join ', ')")
    exit 2
}

try {
    $ctx = Get-HookContext
    $script:allowList = $ctx.AllowList
    $payload = Read-HookPayload
    $cmdText = [string]$payload.tool_input.command
} catch {
    Write-HookLog $LogName "UNKNOWN: $($_.Exception.Message)"
    [Console]::Error.WriteLine("BLOCKED: 判定できませんでした ($($_.Exception.Message))")
    exit 2
}

if (-not $cmdText.Trim()) {
    [Console]::Error.WriteLine("BLOCKED: コマンドが空です")
    exit 2
}

$n = $cmdText.ToUpperInvariant()

foreach ($p in $liblChangePatterns) {
    if ($n -match $p) { Deny 'ライブラリーリストの変更は許可されていません' $cmdText }
}

# LIB(xxx) 形式
foreach ($m in [regex]::Matches($n, "\b(?:$libraryParams)\(([A-Z0-9_#`$@*]{1,10})\)")) {
    $lib = $m.Groups[1].Value
    if ($lib -eq '*LIBL' -or $lib -eq '*CURLIB') { continue }
    if ($lib -eq '*ALL') { Deny '*ALL の指定は許可されていません' $cmdText }
    if ($allowList -notcontains $lib) { Deny "ライブラリー $lib は許可リストにありません" $cmdText }
}

# PARM(lib/obj) 形式
foreach ($m in [regex]::Matches($n, "\b(?:$qualifiedParams)\(([A-Z0-9_#`$@]{1,10})/[A-Z0-9_#`$@*]{1,10}\)")) {
    $lib = $m.Groups[1].Value
    if ($allowList -notcontains $lib) { Deny "ライブラリー $lib は許可リストにありません" $cmdText }
}

# OBJTYPE(*LIB) のときは、オブジェクト名そのものがライブラリー
if ($n -match '\bOBJTYPE\(\*LIB\)') {
    foreach ($m in [regex]::Matches($n, "\bOBJ\([A-Z0-9_#`$@]{1,10}/([A-Z0-9_#`$@]{1,10})\)")) {
        $obj = $m.Groups[1].Value
        if ($allowList -notcontains $obj) { Deny "OBJTYPE(*LIB): $obj は許可リストにありません" $cmdText }
    }
}

Write-HookLog $LogName "ALLOW $cmdText"
exit 0

ADDLIBLE などを拒否しているのが地味に重要です。これを許可してしまうと、一度ライブラリーリストに追加された後はコマンドにライブラリー名が現れなくなり、許可リストによる判定が意味を失います。

OUTFILE を対象に含めているのは、許可されたライブラリーを参照しつつ、結果を許可外のライブラリーに書き出すという操作を防ぐためです。

ソースメンバー書き込み用のスクリプト

#Requires -Version 5.1
# block-write-member.ps1
# ソースメンバー / IFS への書き込みを検査します。

. (Join-Path $env:USERPROFILE '.bob\hooks\_common.ps1')
$LogName = 'block-write-member'

function Deny([string]$reason, [string]$detail) {
    Write-HookLog $LogName "BLOCK $reason : $detail"
    [Console]::Error.WriteLine("BLOCKED: $reason")
    [Console]::Error.WriteLine("Detail: $detail")
    exit 2
}

try {
    $ctx = Get-HookContext
    $allowList = $ctx.AllowList
    $connName  = $ctx.ConnectionName
    $payload   = Read-HookPayload
} catch {
    Write-HookLog $LogName "UNKNOWN: $($_.Exception.Message)"
    [Console]::Error.WriteLine("BLOCKED: 判定できませんでした")
    exit 2
}

switch ([string]$payload.tool_name) {
    'write_member' {
        # ライブラリー名が構造化フィールドで渡されるため、文字列解析が不要です
        $lib = ([string]$payload.tool_input.member.library).ToUpperInvariant()
        if (-not $lib) { Deny 'library フィールドが空です' '(empty)' }
        if ($allowList -notcontains $lib) {
            Deny "ライブラリー $lib は許可リストにありません" "LIB=$lib"
        }
        Write-HookLog $LogName "ALLOW write_member LIB=$lib"
        exit 0
    }
    'write_stream_file' {
        $path = [string]$payload.tool_input.path
        $allowedPrefix = "/HOME/$connName"
        if ($path.ToUpperInvariant().TrimEnd('/').StartsWith($allowedPrefix)) {
            Write-HookLog $LogName "ALLOW write_stream_file PATH=$path"
            exit 0
        }
        Deny "/home/$connName の外への書き込みは許可されていません" "PATH=$path"
    }
}

exit 0

write_member はライブラリー名が tool_input.member.library という独立したフィールドで渡ってきますので、文字列を解析する必要がありません。CL や SQL と違って確実に判定できますので、hooks が最も効果を発揮するのはこのタイプのツールになります。

IFS への書き込みは write_stream_file という別のツールになります。
こちらも tool_input.path にパスがそのまま入ってきますので、
同じ要領で判定できます。

ただしライブラリーと違って、IFS は書き込める場所が広いという点に注意が必要です。
ライブラリーの場合は許可リストを作れば範囲が限定されますが、IFS は
ルートから下がすべて対象になりますので、許可する場所を決めて
それ以外は拒否する、という作りにしておくほうが安全です。

今回は /home/<ユーザープロファイル> の配下だけを許可し、
それ以外への書き込みは拒否するようにしました。

$allowedPrefix = "/HOME/$($ctx.UserProfile)"
if ($path.ToUpperInvariant().TrimEnd('/').StartsWith($allowedPrefix)) {
    exit 0
}

※注意:/home 配下のディレクトリー名は接続設定の名前ではなく、
接続に使用しているユーザープロファイル名になります。
接続設定に任意の名前をつけている場合は一致しませんので、
ユーザープロファイル名を使うようにしてください。

SQL 用のスクリプト

#Requires -Version 5.1
# block-execute-sql-statement.ps1
# SQL が許可スキーマ外を参照していないか検査します。

. (Join-Path $env:USERPROFILE '.bob\hooks\_common.ps1')
$LogName = 'block-execute-sql-statement'

$sqlAlwaysAllow = @('QSYS','QSYS2','QTEMP','QGPL','SYSTOOLS',
                    'SYSIBM','SYSIBMADM','SYSCAT','INFORMATION_SCHEMA')

$pathChangePatterns = @(
    '\bSET\s+(?:CURRENT\s+)?PATH\b',
    '\bSET\s+(?:CURRENT\s+)?SCHEMA\b',
    '\bSET\s+CURRENT_PATH\b',
    '\bSET\s+CURRENT_SCHEMA\b'
)

function Deny([string]$reason, [string]$sql) {
    Write-HookLog $LogName "BLOCK $reason : $sql"
    [Console]::Error.WriteLine("BLOCKED: $reason")
    [Console]::Error.WriteLine("Statement: $sql")
    [Console]::Error.WriteLine("Allowed: $($script:allowList -join ', ')")
    exit 2
}

try {
    $ctx = Get-HookContext -AlwaysAllow $sqlAlwaysAllow
    $script:allowList = $ctx.AllowList
    $payload = Read-HookPayload
    $sqlText = [string]$payload.tool_input.statement
} catch {
    Write-HookLog $LogName "UNKNOWN: $($_.Exception.Message)"
    [Console]::Error.WriteLine("BLOCKED: 判定できませんでした")
    exit 2
}

if (-not $sqlText.Trim()) {
    [Console]::Error.WriteLine("BLOCKED: SQL が空です")
    exit 2
}

$n = $sqlText.ToUpperInvariant()

foreach ($p in $pathChangePatterns) {
    if ($n -match $p) { Deny 'SET PATH / SET SCHEMA は許可されていません' $sqlText }
}

# FROM / JOIN / INTO / UPDATE の直後の修飾名だけをスキーマとして扱います
$pattern = '\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\s+([A-Z][A-Z0-9_#$@]{0,9})\.[A-Z0-9_#$@]{1,128}'
foreach ($m in [regex]::Matches($n, $pattern)) {
    $schema = $m.Groups[1].Value
    if ($allowList -notcontains $schema) {
        Deny "スキーマ $schema は許可リストにありません" $sqlText
    }
}

Write-HookLog $LogName "ALLOW $sqlText"
exit 0

スキーマ名の抽出を FROMJOIN の直後に限定しているのには理由があります。

修飾名をすべてスキーマとして扱うと、下記のような SQL でテーブル別名を誤検知してしまいます。

SELECT A.CUSNAM FROM STUDENT99.CUSTMST A WHERE A.CUSNBR > 0

A.CUSNAMA がスキーマ名と判定され、許可リストにないためブロックされます。正常な SQL が止まってしまいますので、判定範囲を絞る必要があります。

動作確認

実際に Bob で試したログが下記になります。

2026-08-21T17:00:48	BLOCK ライブラリー IMAO は許可リストにありません : DSPLIB LIB(IMAO)
2026-08-21T17:00:49	ALLOW DSPLIB LIB(STUDENT99)
2026-08-21T17:00:49	BLOCK ライブラリーリストの変更は許可されていません : ADDLIBLE LIB(IMAO)
2026-08-21T17:03:48	BLOCK Library IMAO is not in the allow list : QSYS/DSPLIB LIB(IMAO)
2026-08-21T17:03:50	ALLOW QSYS/DSPLIB LIB(STUDENT99)
2026-08-21T17:03:51	BLOCK Library IMAO is not in the allow list : CRTPF FILE(IMAO/TESTPF)
2026-08-21T17:05:36	BLOCK Library IMAO is not in the allow list : QSYS/DSPLIB LIB(IMAO) OUTPUT(*PRINT)

許可外のライブラリーはブロックされ、許可リスト内のものは通っています。コマンド自体をライブラリー修飾した QSYS/DSPLIB でも、パラメーターを追加した場合でも回避できていません。

hooks の限界

ここまでで、それなりに実用的なものができました。ただし hooks には構造的な限界がありますので、整理しておきます。

クライアント側で動作する

hooks の設定もスクリプトも、開発者の PC 上にあります。編集も削除もできますので、これを守りの本体にすることはできません。

また、5250 画面や ACS から人間が直接操作する場合には、当然ながら hooks は関与しません。止められるのは Bob 経由の操作だけです。

文字列で判定している

CL も SQL も、渡ってくるのは文字列です。書き方を変えられると判定をすり抜けます。

  • CALL QCMDEXC を経由して CL コマンドを実行する
  • 許可ライブラリー内にプログラムを作成して呼び出す(中身までは見えません)
  • サブクエリーや動的 SQL で参照先を組み立てる

塞ぐたびに別の経路が残りますので、この方式で守り切るのは現実的ではありません。

接続設定を変えると許可リストも変わる

今回、接続設定の libraryList を許可リストとして使いました。裏を返せば、接続設定を追加すれば許可リストを広げられるということになります。settings.json は開発者が編集できますので、ここも回避の経路になります。

厳しくしすぎると業務が止まる

逆方向の問題もあります。SQL の別名の例のように、判定を厳しくすると正常な操作までブロックされます。SBMJOB を無条件で拒否すると、コンパイルのバッチ投入なども止まります。

hooks は「ゆるすぎるとすり抜ける、厳しすぎると仕事にならない」という両側から挟まれた状態にあり、ちょうどよい点を探し続ける必要があります。

最後に

今回の作業を整理すると、下記の流れになりました。

hooks の仕組みを確認する(PreToolUse、終了コード 0/2)
↓
渡されるペイロードの構造を確認する
↓
拒否リスト方式で作ってみる
↓
別のツールを使われてすり抜ける
↓
許可リスト方式に変更する
↓
接続設定からライブラリーリストを取得して自動追従させる
↓
限界を確認する

はじめは hooks で一通り制御できるものだと考えていたのですが、実際に作ってみると、AI は拒否されると別の手段を探しますし、こちらが書いた判定ロジックの隙間を素直に通り抜けていきます。悪意があるわけではなく、目的を達成しようとした結果そうなるので、塞ぐ側がすべての経路を想像しきる必要があり、これは無理だと感じました。

一方で、うっかり本番ライブラリーを指定してしまった、といったミスは確実に止まります。ログも残りますので、AI が何をしようとしたかを後から確認できるのも便利でした。

hooks は第一の関門として置いておき、本当に触らせたくないオブジェクトについては IBM i 側のオブジェクト権限や権限リストで塞いでおく、という組み合わせが現実的だと思います。権限で塞いでおけば、CL 経由だろうと SQL 経由だろうと、どの経路から来ても止まりますので。

※注意:本記事の内容は検証時点のもので、ツール名やペイロードの構造は今後変更される可能性があります。実際に設定する際は、まず受け取ったペイロードをログに出力して構造を確認することをおすすめします。

2
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?