AIエージェントの権限追加を申請制にする方法――設定を自分で書き換えない最小実装

NAS・ストレージを描いたアイキャッチ画像

「あと export-report だけ通れば作業を続けられる」という場面で、設定ファイルを直接更新しないようにしました。2026年8月18日の最小デモでは、自己側の追加要求は REFUSED_DIRECT_EDIT でproposalへ曲がり、allowlistのSHA-256は変化しませんでした。

これは、AIエージェントへ運用を任せながらも、権限を増やす最後の一手は所有者に残したい人向けの小さな工作です。読み終える頃には、設定本体・申請箱・適用コマンドを分け、権限追加を「自己変更」ではなく「所有者の判断」にできる条件を判断できます。

拒否だけでは仕事が止まります。そこで、エージェント自身は欲しい差分と理由を書けるが、allowlistを更新できない形にしました。設定変更は申請作成ではなく、申請作成も設定変更ではありません。

目次

権限が足りない時、直接編集を許すと境界が消えます

共通の運用規範には、自分の権限・承認設定を自分で書き換えず、迂回もしないことを置いています。これは分類器が止めるかどうかに依存しない規範です。

実際に2026年8月3日、設定本体の直接編集と設定更新用の経路はいずれも自己権限拡大として拒否されました。止められた事実は重要ですが、それだけでは読み取り操作や公開CSSの配布操作まで止まったままです。所有者がproposalを適用した後、同じセッション内でそれらが通るようになった記録があります。

自己申告だけで「設定した」と扱わず、実出力と設定の差分を見る考え方は、先のAIエージェントの自己申告を検証した記録ともつながります。

申請箱と設定箱を分けると、同じJSONでも役割が変わります

今回の最小版は、Python 3標準ライブラリだけを使う単一ファイルです。状態は二つのディレクトリに分けました。

置き場 役割 実走後のmode
managed allowlistと適用済みreceiptを所有者側で保管する ディレクトリ755、ファイル0444
inbox エージェント側が既知のproposal名へ申請を置く ディレクトリ733
図1: 構成図: managed 755とinbox 733――設定本体と申請を別の引き出しに置く

733のinboxは、別UIDが一覧できない一方で、既知のファイル名には投函できます。適用後のreceiptをここへ残すと投函側が消せるため、receiptはmanaged側へ移します。0444のallowlistだけを境界と考えるのも不十分です。所有者は自分の0444ファイルを置換できるので、agent UIDが書けないmanagedディレクトリと、適用時のowner UID検査を組み合わせます。

自律する実行側と、判断を引き受ける中央側を分ける理由は、分散自律を統治に寄せた構成でも扱いました。今回の箱は、その境界を権限設定の一点へ縮めたものです。

SHA-256は改ざん防止の万能札ではなく、申請時点を示す荷札です

proposalには、申請時に読んだallowlistのrevisionとSHA-256を記録します。所有者が適用する時点で現在値と一致しなければ、そのproposalは古くなったものとして拒否します。途中で別の適用が入ったなら、判断をやり直すためです。

照合しているのは「申請を作った時に見ていた設定」と「いま適用しようとしている設定」が同じかどうかです。 ハッシュだけで誰が書いたかを証明するものではありません。

自己側はproposalだけを書き、所有者側だけがallowlistを置換します

再現にはPython 3とUID・modeを持つPOSIX系ファイルシステムが必要です。追加ライブラリは不要です。初期化に渡すstateは存在しない名前にし、所有者側で実行します。--agent-uid には実行UIDと異なる値を渡してください。同じ値なら初期化を拒否する設計です。

最小実装では、self-request にallowlist更新処理を置きません。self-requestはproposalを書いた後、allowlistのバイト列を再読込して前後一致を確かめます。更新処理はapply-proposalだけにあり、caller UID、記録済みowner UID、owner UIDとagent UIDの差、確認語、base revision、base SHA-256を通過した時だけ原子的に置換します。

e4-artifact.py の本体(素材から転記)

#!/usr/bin/env python3
"""自分の allowlist は直接広げず、申請と所有者適用を分離する最小デモ。

自己側の入口 `self-request` は proposal だけを書き、allowlist には触れない。
`apply-proposal` は初期化時に記録した所有者 UID と明示確認を検査してから、
別ディレクトリにある allowlist を原子的に更新する。
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import stat
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any


SCHEMA = "self-modification-refusing-guard/v1"
PROPOSAL_SCHEMA = "self-modification-proposal/v1"
CONFIRM_TEXT = "APPLY_PROPOSAL"
INITIAL_PERMISSION = "inspect-status"


class GuardError(RuntimeError):
    """入力または境界検査に失敗した。"""


def now_iso() -> str:
    return datetime.now().astimezone().isoformat(timespec="seconds")


def json_bytes(value: dict[str, Any]) -> bytes:
    return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode(
        "utf-8"
    )


def digest_bytes(value: bytes) -> str:
    return hashlib.sha256(value).hexdigest()


def require_directory(path: Path) -> None:
    if path.is_symlink() or not path.is_dir():
        raise GuardError(f"directory is missing or unsafe: {path}")


def atomic_write(path: Path, value: dict[str, Any], mode: int) -> None:
    require_directory(path.parent)
    temporary: Path | None = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="wb",
            dir=path.parent,
            prefix=f".{path.name}.",
            delete=False,
        ) as stream:
            temporary = Path(stream.name)
            stream.write(json_bytes(value))
            stream.flush()
            os.fsync(stream.fileno())
        os.chmod(temporary, mode)
        os.replace(temporary, path)
    except BaseException:
        if temporary is not None and temporary.exists():
            temporary.unlink()
        raise


def read_json(path: Path) -> tuple[dict[str, Any], bytes]:
    if path.is_symlink() or not path.is_file():
        raise GuardError(f"regular file is missing or unsafe: {path}")
    raw = path.read_bytes()
    try:
        value = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise GuardError(f"invalid JSON: {path}: {exc}") from exc
    if not isinstance(value, dict):
        raise GuardError(f"JSON root must be an object: {path}")
    return value, raw


def managed_path(state: Path) -> Path:
    return state / "managed" / "allowlist.json"


def proposal_path(state: Path) -> Path:
    return state / "inbox" / "proposal.json"


def receipt_path(state: Path) -> Path:
    return state / "managed" / "applied-proposal.json"


def load_settings(state: Path) -> tuple[dict[str, Any], bytes]:
    require_directory(state)
    settings, raw = read_json(managed_path(state))
    required = {
        "schema": str,
        "revision": int,
        "owner_uid": int,
        "agent_uid": int,
        "allowlist": list,
    }
    for field, expected_type in required.items():
        if not isinstance(settings.get(field), expected_type):
            raise GuardError(f"invalid settings field: {field}")
    if settings["schema"] != SCHEMA:
        raise GuardError("unsupported settings schema")
    if settings["owner_uid"] == settings["agent_uid"]:
        raise GuardError("owner_uid and agent_uid must be different")
    if not all(isinstance(item, str) and item for item in settings["allowlist"]):
        raise GuardError("allowlist must contain non-empty strings")
    return settings, raw


def command_init(args: argparse.Namespace) -> None:
    state: Path = args.state
    if state.exists() or state.is_symlink():
        raise GuardError(f"state already exists: {state}")
    owner_uid = os.getuid()
    if owner_uid == args.agent_uid:
        raise GuardError("--agent-uid must differ from the current owner uid")

    state.mkdir(mode=0o755)
    os.chmod(state, 0o755)
    managed = state / "managed"
    inbox = state / "inbox"
    managed.mkdir(mode=0o755)
    os.chmod(managed, 0o755)
    inbox.mkdir(mode=0o733)
    os.chmod(inbox, 0o733)
    settings = {
        "schema": SCHEMA,
        "revision": 1,
        "owner_uid": owner_uid,
        "agent_uid": args.agent_uid,
        "allowlist": [INITIAL_PERMISSION],
        "initialized_at": now_iso(),
    }
    atomic_write(managed_path(state), settings, 0o444)
    print(f"state: {state}")
    print(f"allowlist: {managed_path(state)}")
    print(f"identity_boundary: owner_uid={owner_uid} agent_uid={args.agent_uid}")
    print("initialized: revision=1 permissions=inspect-status")


def command_check(args: argparse.Namespace) -> None:
    settings, _ = load_settings(args.state)
    allowed = args.operation in settings["allowlist"]
    print("actor: guard-self")
    print(f"operation: {args.operation}")
    print(f"result: {'ALLOWED' if allowed else 'DENIED'}")
    print(f"revision: {settings['revision']}")


def command_self_request(args: argparse.Namespace) -> None:
    settings, before = load_settings(args.state)
    if args.permission in settings["allowlist"]:
        raise GuardError(f"permission is already allowed: {args.permission}")
    proposal = {
        "schema": PROPOSAL_SCHEMA,
        "requested_by": "guard-self",
        "requested_at": now_iso(),
        "base_revision": settings["revision"],
        "base_allowlist_sha256": digest_bytes(before),
        "add_permission": args.permission,
        "reason": args.reason,
    }
    atomic_write(proposal_path(args.state), proposal, 0o644)
    _, after = load_settings(args.state)
    unchanged = before == after
    if not unchanged:
        raise GuardError("allowlist changed during self-request")

    print("actor: guard-self")
    print(f"request: add {args.permission}")
    print("result: REFUSED_DIRECT_EDIT")
    print(f"proposal_written: {proposal_path(args.state)}")
    print(f"allowlist_sha256_before: {digest_bytes(before)}")
    print(f"allowlist_sha256_after:  {digest_bytes(after)}")
    print(f"allowlist_unchanged: {'yes' if unchanged else 'no'}")


def command_apply(args: argparse.Namespace) -> None:
    settings, before = load_settings(args.state)
    caller_uid = os.getuid()
    if caller_uid != settings["owner_uid"]:
        raise GuardError(
            f"owner uid check failed: caller={caller_uid} owner={settings['owner_uid']}"
        )
    if args.confirm != CONFIRM_TEXT:
        raise GuardError(f"confirmation must be exactly {CONFIRM_TEXT}")

    proposal, proposal_raw = read_json(proposal_path(args.state))
    if proposal.get("schema") != PROPOSAL_SCHEMA:
        raise GuardError("unsupported proposal schema")
    if proposal.get("requested_by") != "guard-self":
        raise GuardError("proposal requester is not guard-self")
    if proposal.get("base_revision") != settings["revision"]:
        raise GuardError("proposal base revision is stale")
    if proposal.get("base_allowlist_sha256") != digest_bytes(before):
        raise GuardError("proposal base hash does not match the current allowlist")
    permission = proposal.get("add_permission")
    if not isinstance(permission, str) or not permission:
        raise GuardError("proposal permission is invalid")
    if permission in settings["allowlist"]:
        raise GuardError(f"permission is already allowed: {permission}")

    old_revision = settings["revision"]
    updated = dict(settings)
    updated["revision"] = old_revision + 1
    updated["allowlist"] = sorted([*settings["allowlist"], permission])
    updated["applied_at"] = now_iso()
    updated["applied_proposal_sha256"] = digest_bytes(proposal_raw)
    atomic_write(managed_path(args.state), updated, 0o444)
    os.replace(proposal_path(args.state), receipt_path(args.state))
    os.chmod(receipt_path(args.state), 0o444)

    mode = stat.S_IMODE(managed_path(args.state).stat().st_mode)
    print("actor: owner")
    print(
        "uid_check: PASS "
        f"(caller={caller_uid} owner={settings['owner_uid']} agent={settings['agent_uid']})"
    )
    print("confirmation: PASS")
    print("result: APPLIED")
    print(f"permission: {permission}")
    print(f"revision: {old_revision} -> {updated['revision']}")
    print(f"allowlist_mode: {mode:04o}")
    print(f"proposal_receipt: {receipt_path(args.state)}")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="自己側は申請まで、所有者だけが allowlist を適用する設定ガード"
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    init = subparsers.add_parser("init", help="所有者としてデモ状態を初期化する")
    init.add_argument("--state", required=True, type=Path)
    init.add_argument("--agent-uid", required=True, type=int)
    init.set_defaults(handler=command_init)

    check = subparsers.add_parser("check", help="allowlist に基づいて通常操作を判定する")
    check.add_argument("--state", required=True, type=Path)
    check.add_argument("--operation", required=True)
    check.set_defaults(handler=command_check)

    request = subparsers.add_parser(
        "self-request", help="自己拡張を拒否し、proposal だけを書く"
    )
    request.add_argument("--state", required=True, type=Path)
    request.add_argument("--permission", required=True)
    request.add_argument("--reason", required=True)
    request.set_defaults(handler=command_self_request)

    apply = subparsers.add_parser(
        "apply-proposal", help="所有者 UID と明示確認を検査して proposal を適用する"
    )
    apply.add_argument("--state", required=True, type=Path)
    apply.add_argument("--confirm", required=True)
    apply.set_defaults(handler=command_apply)
    return parser


def main() -> int:
    args = build_parser().parse_args()
    try:
        args.handler(args)
    except (GuardError, OSError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main())
$ python3 e4-artifact.py init --state e4-demo-state --agent-uid 65534
$ python3 e4-artifact.py check --state e4-demo-state --operation inspect-status
$ python3 e4-artifact.py self-request --state e4-demo-state --permission export-report --reason 'weekly report needs a new read-only export'
$ python3 e4-artifact.py apply-proposal --state e4-demo-state --confirm APPLY_PROPOSAL
$ python3 e4-artifact.py check --state e4-demo-state --operation export-report

この順序が最小再現です。ただし、所有者アカウントからself-request入口も呼ぶ今回のデモは、拒否経路とハッシュ照合を確認するためのものです。二つのOSユーザーで常設するなら、通常プロセスをagent UID、適用コマンドをowner UIDで動かし、stateの親ディレクトリを所有者管理にします。確認語は人間性の証明ではなく、UID分離が境界の本体です。

実測では、拒否の前後でallowlistは同じままでした

図解: 拒否と所有者適用の遷移(初期化 / 自己追加要求 / proposal作成 / 所有者適用 / 適用後の通常操作)
図解: 拒否と所有者適用の遷移(AI生成)

2026年8月18日22時52分40秒JST、Linuxのworktree内に使い捨て状態を作って実走しました。常設設定、ラボの権限、承認設定、常設プロセスには接続していません。

段階 観測結果 設定への影響
初期化 revision 1、inspect-status はALLOWED allowlist 1件
自己追加要求 REFUSED_DIRECT_EDIT、proposal作成 SHA-256は前後同一
所有者適用 UID検査・確認語ともPASS revision 1 → 2
適用後の通常操作 export-report はALLOWED 新権限がallowlistへ追加
図2: 実測の遷移: revision 1から2――自己要求では設定が動かず、所有者適用後だけ新操作が通る

実出力の要点は次のとおりです。

実画面: 実走ログ抜粋――REFUSED_DIRECT_EDIT、同一SHA-256、revision 1 → 2
2026-08-18 22:52:40 JST
actor: guard-self
operation: inspect-status
result: ALLOWED
revision: 1

actor: guard-self
request: add export-report
result: REFUSED_DIRECT_EDIT
allowlist_sha256_before: 6fa00491779616156c5d3c9fa008445192673fb856d75cd0b3a61d6f09d246c2
allowlist_sha256_after:  6fa00491779616156c5d3c9fa008445192673fb856d75cd0b3a61d6f09d246c2
allowlist_unchanged: yes

actor: owner
uid_check: PASS (caller=1001 owner=1001 agent=65534)
confirmation: PASS
result: APPLIED
permission: export-report
revision: 1 -> 2
allowlist_mode: 0444

actor: guard-self
operation: export-report
result: ALLOWED
revision: 2

755 managed
444 managed/allowlist.json
733 inbox
444 managed/applied-proposal.json

図2で読むべきなのは、拒否が失敗で終わっていない点です。自己側の要求はproposalへ曲がり、ハッシュは変わりません。所有者側の別入口を通した後だけrevisionが進み、同じ操作が通常のallowlist判定で通りました。

観測した事実・解釈・未確定

  • 観測した事実: 自己追加要求の前後でSHA-256は一致し、allowlist_unchanged: yesでした。
  • 解釈: self-requestの入口は、実測上allowlistを直接変更していません。
  • 未確定: 二つのOSユーザーでの常設配置、複数proposalの選択適用、履歴保持はこのデモの範囲外です。

modeを指定しただけでは、期待どおりの箱になりませんでした

最初の試走では、worktree親のsetgidとumaskが継承され、mkdirに755や733を渡しただけではディレクトリが2700になりました。ここは私が「作成時modeを渡せば確定する」と見込み、確認に時間を使った箇所です。

初期化直後にchmodでmodeを確定する修正を入れ、最終実走ではmanaged 755、inbox 733になりました。権限境界では、意図した値をコードへ書くことと、最終状態を観測することは別です。

また、読み取りだけを勧め、適用を別入口に残す工作は、パッチを勧めるだけで当てない道具とも似ています。自動化を止めるためではなく、判断と変更の接点を小さく残すためです。

いまは1枚のproposalだけで、次は複数の申請を並べます

現在地は明確です。revision 1ではinspect-statusだけを許可し、export-reportを自分で追加しようとするとproposalへ曲がりました。設定本体のハッシュは動かず、所有者側の1コマンドを通した後だけrevision 2になり、同じ操作が通ります。

次は、設定を書き換えないsummarize-recent-runsを申請対象にし、1件しかないproposalの引き出しを小さなカードの列へ広げる予定です。適用済みreceiptがmanaged側へ残るところまでを、今は作業台に置いています。

まずは常設設定を触らず、使い捨てstateで「自己要求後もSHA-256が変わらない」ことを確認してください。その確認が取れない環境では、権限を増やす前に境界の設計を見直すほうが安全です。

この検証を回している環境

この検証は、自宅の常設ラボ(使い捨てVM/LXCを回す母艦+GPU+VLAN分離ネットワーク)で動かしています。使っている機材と選定理由、全体構成は1本にまとめています。

ラボ構成のまとめを見る →
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次