OSIIX Library

AI支援Git運用のSafety Harness

Write Setを実行前に検査する

Type
技術ガイド・実装ノート
Author
mars70

Implementation note

1. 導入

AIにGit作業を補助させるとき、最初に決めた作業境界を、そのまま実行前の検査へ移せると扱いやすくなります。この記事で扱うのは、リポジトリを変更する前に「どこで」「何を」「どの状態で」操作しようとしているかを小さく確認するSafety Harnessです。

対象は、Repository Root、Write Set、Stage Set、Branch、push先、Secret Scannerです。いずれかが想定と違えば処理を続けず、停止して人間が確認できる状態を残します。

ここでの目的は、AI支援Git運用を一つのラッパーだけで安全に完結させることではありません。定義済みの境界を、実行直前の機械的なチェックへ変換することです。

2. 境界を決めることと強制することは別

「このファイルだけを変更する」「このブランチだけを扱う」と指示書に書くことは、境界の定義です。しかし、指示を読んだプロセスが実際にその境界を守っているかを自動的に確認する仕組みとは別です。

そこで、危険な操作の前に現在状態を読み取り、期待値と比較します。Repository Rootが違う、Write Set外の変更がある、Stage Setが広い、push URLが違う、Secret Scanが実行できない——このような差異は、成功に見せかけず停止させます。

本稿の焦点: 境界を新しく発明することではなく、すでに定義したGit作業境界を実行前の検査へ移すこと。

3. Instruction / Safety Harness / 外部強制境界

役割この層だけでは足りない点
Instruction許可されたRepository Root、Write Set、Branchなどを人間とAIに伝える実行時の状態が指示どおりかは検査しない
Safety Harness実行直前に状態を読み、差異があれば停止する呼び出されなければ働かず、プロセス外の操作も拘束しない
外部強制境界CI、保護ブランチ、権限、サーバー側ポリシーなどで別の境界を設ける導入・運用・例外処理の設計が別途必要

この3層は代替関係ではありません。ローカルのHarnessは、外部強制境界の存在を前提にせず、まず自分が実行しようとしている状態を検査するための部品です。

4. 小さなHarnessから始める

最初からGit操作全体を自動化する必要はありません。検査対象を少数の観測値に絞ると、失敗理由を説明しやすくなります。

  1. Repository Rootを確認する。
  2. Write Setを確認する。
  3. Branchとpush URLを確認する。
  4. 許可パスをexplicit stagingする。
  5. Stage Setを確認する。
  6. Secret Scanを実行する。
  7. staged diffを確認し、Human reviewへ進む。
  8. commit等の次操作は、別の確認後に行う。

検査自体が失敗した場合も、検査を省略して続ける理由にはなりません。小ささは機能不足ではなく、停止点を明確に保つための設計条件です。

5. Fail-Closed

Fail-Closedとは、必要な確認ができないときに「安全そうだから続ける」のではなく、処理を停止することです。これは成功の証明ではなく、確認不能を成功として扱わないための制御です。

期待値を定義
      ↓
実際の状態を取得
      ↓
比較できない / 差分がある
      ↓
STOP
      ↓
人間が確認してから再実行

特に、gitleaksがPATHにない、見つかるが起動できない、終了コードが非ゼロという場合は、Secret Scanを通過したとは扱いません。

6. Repository Root

相対パスは、実行時のカレントディレクトリに依存します。サブディレクトリから起動したときも同じリポジトリを扱うため、まずGitが返すrootと期待するrootを解決して比較します。

$ActualRootRaw = & git rev-parse --show-toplevel 2>$null

if ($LASTEXITCODE -ne 0 -or -not $ActualRootRaw) {
    Write-Error 'STOP: Repository Rootを確認できません。'
    exit 1
}

try {
    $ExpectedRoot = (
        Resolve-Path -LiteralPath $ExpectedRootPath -ErrorAction Stop
    ).Path

    $ActualRoot = (
        Resolve-Path -LiteralPath $ActualRootRaw -ErrorAction Stop
    ).Path
}
catch {
    Write-Error 'STOP: Repository Rootのpath resolutionに失敗しました。'
    exit 1
}

if ($ActualRoot -ne $ExpectedRoot) {
    Write-Error "STOP: Repository Rootが違います: $ActualRoot"
    exit 1
}

$RepositoryRoot = $ActualRoot

最後に確定した $RepositoryRoot を後続のGit操作で共有します。解決に失敗した場合、比較を省略して処理を続けません。

7. GitをRepository Root基準で実行

Root確認後のGit呼び出しは、カレントディレクトリの暗黙性を減らすため git -C $RepositoryRoot に固定します。引数は文字列を連結せず、配列として渡します。

function Invoke-GitChecked {
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$GitArgs
    )

    $Output = & git -C $RepositoryRoot @GitArgs
    $ExitCode = $LASTEXITCODE

    if ($ExitCode -ne 0) {
        throw "STOP: git $($GitArgs -join ' ') failed."
    }

    return $Output
}

Gitが非ゼロ終了したときは、空の出力を正常結果と解釈しません。呼び出し側へ例外を返し、次の変更操作へ進まないようにします。

8. Write Set

Write Setは、この作業で変更を許可する相対パスの集合です。本稿では説明用の用語であり、Gitの公式用語ではありません。「変更されたファイル」だけでなく、indexに入ったパスと、標準ignoreを除いた未追跡ファイルも同じ検査へ入れます。

$ChangedFiles = @(
    Invoke-GitChecked -GitArgs @(
        'diff',
        '--no-renames',
        '--name-only'
    )

    Invoke-GitChecked -GitArgs @(
        'diff',
        '--cached',
        '--no-renames',
        '--name-only'
    )

    Invoke-GitChecked -GitArgs @(
        'ls-files',
        '--others',
        '--exclude-standard',
        '--full-name'
    )
) |
    Where-Object { $_ } |
    Sort-Object -Unique

$Unexpected = $ChangedFiles |
    Where-Object { $_ -cnotin $AllowedFiles }

if ($Unexpected) {
    Write-Error 'STOP: Write Set外の変更があります。'
    $Unexpected | ForEach-Object { Write-Host "  $_" }
    exit 1
}

--no-renamesで変更元と変更先をまとめずに扱います。Write Set外から内側へのrenameでも、元のパスを検出対象に残すためです。比較は大文字小文字を区別する -cnotin を使い、パス表記の曖昧な一致を避けます。

AllowedFilesはGitが返すrepository root相対のpath表記に合わせ、/区切りで記述します。非ASCII pathでは、既定のcore.quotePathにより表記差が生じる場合があります。

9. Explicit staging

ステージングは許可されたパスを明示します。作業tree全体を一括でstageする操作に依存せず、Write Setと同じ境界をコマンドにも残します。

# 例: 許可したパスを明示してstageする
git add -- <allowed-file-1> <allowed-file-2>

ここではstage対象を明示するだけで、PASS判定は行わない。indexの内容は次節のStage Set検査で確認する。

10. Stage Set

Stage Setは、その時点でindexに入っているパスの集合です。Write Setと一致していても、stageの段階で別のファイルが混ざる可能性があるため、独立した観測値として扱います。

$StageSet = @(
    Invoke-GitChecked -GitArgs @(
        'diff',
        '--cached',
        '--no-renames',
        '--name-only'
    )
) |
    Where-Object { $_ } |
    Sort-Object -Unique

$UnexpectedStaged = $StageSet |
    Where-Object { $_ -cnotin $AllowedFiles }

if ($UnexpectedStaged) {
    Write-Error 'STOP: Stage Set外のパスがstageされています。'
    exit 1
}

Write-Host 'PASS: Allowed files staged.'

Stage Setの検査には、必ず git diff --cached --no-renames --name-only を使います。未stageの作業treeと、次の操作対象であるindexを混同しないためです。

11. Branch

Branchは、同じファイル集合でも意味を変える状態です。作業対象として許可したBranch名を明示し、現在のBranchと比較します。

$CurrentBranch = Invoke-GitChecked -GitArgs @(
    'symbolic-ref',
    '--short',
    'HEAD'
)

if ($CurrentBranch -ne $ExpectedBranch) {
    Write-Error "STOP: Branchが違います: $CurrentBranch"
    exit 1
}

detached HEADを通常のBranch名として推測したり、違うBranchから自動的に移動したりしません。Branchの差異は、停止して確認する入力です。

12. Push URL

push先はfetch先と同じとは限りません。書き込み先を確認するときは、remoteのpush URLを直接取得します。

$PushUrls = @(
    @(
        Invoke-GitChecked -GitArgs @(
            'remote',
            'get-url',
            '--push',
            '--all',
            $RemoteName
        )
    ) | Where-Object { $_ }
)

if (
    $PushUrls.Count -ne 1 -or
    $PushUrls[0] -cne $ExpectedPushUrl
) {
    Write-Error "STOP: Push URLが想定と一致しません: $($PushUrls -join ', ')"
    exit 1
}

ここで確認するのは git remote get-url --push --all <remote> です。URL件数が1件で、その1件がExpectedPushUrlと完全一致する場合だけPASSとし、0件・複数件・不一致はSTOPします。この簡易Harnessでは複数push URLを許可するpolicy engineへ拡張しません。

13. Secret Scanner

Secret Scannerは、検査を実行できたことと、検査結果がPASSだったことを分けて扱います。gitleaksが存在しない、起動不能、非ゼロ終了のいずれもPASSへ進めません。

$GitleaksCommand = Get-Command gitleaks `
    -CommandType Application `
    -ErrorAction SilentlyContinue

if (-not $GitleaksCommand) {
    Write-Error 'STOP: gitleaksを実行できません。'
    exit 1
}

$GitleaksExitCode = $null

try {
    & $GitleaksCommand.Source `
        git `
        --staged `
        --redact `
        $RepositoryRoot

    $GitleaksExitCode = $LASTEXITCODE
}
catch {
    Write-Error "STOP: gitleaks execution failed: $($_.Exception.Message)"
    exit 1
}

if ($null -eq $GitleaksExitCode -or $GitleaksExitCode -ne 0) {
    Write-Error 'STOP: Secret scan did not pass.'
    exit 1
}

Write-Host 'PASS: Secret Scan OK'

--redactは、検出された秘密情報の値をログや出力上で伏せます。検出そのものは報告されます。Secret Scannerがすべての秘密情報を検出することを意味せず、スキャナーの対象範囲と設定は別途確認が必要です。

この例の gitleaks git ... 形式は、Gitleaks v8.19.0以降のCLI形式を前提とします。

14. Pushを通常Harnessから外す

ローカル変更の検査と、外部repositoryへpushする操作は、同じ自動経路へ詰め込まない方が境界を説明しやすくなります。通常Harnessの終了点を「想定したStage SetとSecret ScanがPASSした」までにし、pushは別の明示的な承認・確認の境界に置きます。

こうすると、ローカルの検査がPASSしたことを、外部repositoryへの書き込み許可やpush成功の証拠へ拡大解釈せずに済みます。

15. Soft Harnessの限界

Soft Harnessも本稿の説明用の用語であり、Git標準用語ではありません。ここでは、PowerShell wrapperやローカルスクリプトのように、呼び出されたときに状態を検査して停止する仕組みを指します。

Soft Harnessは、検査を通らない通常経路を作りにくくします。しかし、別のコマンドを直接呼ぶ、別の作業treeを使う、スクリプトを編集する、外部の強制境界を変更する、といった行為まで拘束するものではありません。

したがって、Soft Harnessをsecurity boundaryそのものとは扱いません。必要な強制力は、CI、保護ブランチ、repository権限、承認フローなど、別の層で設計します。

16. Harnessで防ぎやすくなるもの

検査検出しやすい差異検出できないこと
Repository Root別リポジトリ、存在しない期待root、サブディレクトリ依存root内のすべての意味上の誤り
Write Set許可外のtracked / staged / untracked path、rename元の混入ignored filesなど、列挙条件の外側にあるもの
Stage Setindexに混ざった許可外pathレビュー内容の妥当性
Branch / Push URL対象Branchや書き込み先の取り違えremote側の承認・保護設定の全状態
Secret Scannerスキャナーが起動しない、失敗した、検出対象に該当した場合すべての秘密情報、すべての漏えい経路

「防ぎやすくなる」は「すべてを防止する」ではありません。Harnessの価値は、特定の取り違えを、変更やpushの前に停止可能な差異として見せることにあります。

17. コード例の位置づけ

掲載したPowerShell例は、Write Set検査、Root解決、Git呼び出し、Stage Set、Branch、push URL、gitleaksのfail-closed経路を説明するための最小構成です。実際のrepositoryでは、許可パス、Branch、remote名、push URL、scannerの設定を、その作業の承認内容に合わせて固定してください。

Write Set、Stage Set、Safety Harness、Soft Harnessはいずれも本稿の説明用の用語です。write setharnessが他分野で使われることはありますが、ここでの定義をGit公式の標準用語として扱いません。

18. 実機確認

掲載例のFail-Closed経路について、隔離した一時Git repositoryで5ケースを確認しました。これはコード例の停止動作を確認した記録であり、あらゆる環境の安全性を証明するものではありません。

以下は各条件でHarnessが期待どおりSTOPしたことを示します。

CasePowerShell 7Windows PowerShell 5.1
1. outside untracked期待どおりSTOP期待どおりSTOP
2. outside→inside rename期待どおりSTOP期待どおりSTOP
3. gitleaks unavailable期待どおりSTOP期待どおりSTOP
4. invalid root期待どおりSTOP期待どおりSTOP
5. gitleaks found but cannot start期待どおりSTOP期待どおりSTOP

Versions: Git 2.54.0.windows.1 / PowerShell 7.6.5 / Windows PowerShell 5.1.26100.9444

  • この検証は上記5ケースに限定しています。
  • あらゆるGit構成を網羅していません。
  • 完成済みsecurity productの検証ではありません。
  • ignored files等の既知の範囲外は本文記載どおり残ります。

19. 結論

AI支援Git運用のSafety Harnessは、巨大な自動化基盤から始める必要はありません。Repository Rootを確定し、Gitをroot基準で呼び、Write SetとStage Setを明示的に比較し、Branch、push URL、Secret Scannerを確認するだけでも、実行前に停止できる取り違えを増やせます。

重要なのは、確認できない状態をPASSへ変換しないことです。Write Set、Stage Set、Safety Harness、Soft Harnessは本稿の説明用の用語であり、これらをGit公式用語や万能なsecurity boundaryと誤認しないことも含めて、適用範囲を保ったまま使います。

最後に、ローカルHarnessのPASSは、commit、push、deploy、runtime検証の完了を意味しません。次の境界へ進むときは、その操作を別の承認・検査として扱います。

References

21. 参考資料

各資料は、Gitのパス・差分・remoteの確認方法、PowerShellの終了コードと例外処理、Secret Scannerの公式実装を確認するために参照しています。