Agent Commons 플러그인 권한을 Manifest로 선언하기

Agent Commons 플러그인 권한을 Manifest로 선언하기

한눈에 보기

플러그인 권한 manifest는 보안 기능 그 자체가 아니라 설치 전 검토와 런타임 강제를 연결하는 계약이다. 플러그인이 요구한 권한, 사용자가 허용한 권한, 워크스페이스 정책, 에이전트 권한의 교집합만 실행 시점에 부여해야 한다. 미선언 권한은 기본 거부하고 파일 경로·네트워크 목적지·secret 용도·외부 쓰기 동작까지 가능한 한 구체적으로 제한한다.

에이전트 플랫폼의 플러그인은 일반 라이브러리보다 훨씬 강한 위치에 있다. 모델이 결정한 인자를 받아 파일을 읽거나, 셸을 실행하거나, 외부 API에 글을 쓸 수 있다. 사용자는 “이슈 정리 플러그인”을 설치했지만 실제 코드가 홈 디렉터리 전체를 읽고 임의 서버로 전송할 수 있다면 이름과 설명만으로 위험을 판단할 수 없다.

코드를 열어 보면 알 수 있다고 말하기도 어렵다. 설치하는 모든 사용자가 코드를 감사할 수 없고, 업데이트 뒤 권한 범위가 조용히 넓어질 수도 있다.

Agent Commons의 플러그인 모델을 생각하며 권한을 실행 코드 바깥의 manifest에 선언해야 한다고 판단했다. 다만 선언 파일 하나를 추가한다고 보안이 완성되는 것은 아니다. manifest를 설치 화면, 실행 경계, 승인 기록, 감사 로그까지 연결해야 의미가 생긴다.

아래 YAML과 코드는 실제 저장소 구현을 복사한 것이 아니라 권한 모델을 설명하기 위해 만든 예시다.

목차

플러그인은 이름보다 행동으로 평가해야 한다

다음 두 플러그인을 생각해 보자.

issue-reader
  공개 저장소의 이슈를 읽어 요약한다.

issue-helper
  이슈를 읽고 라벨을 바꾸며 댓글을 작성한다.

이름은 비슷하지만 필요한 권한은 다르다.

행동 필요한 능력 실패했을 때 영향
공개 이슈 읽기 특정 API 도메인으로 GET 정보 조회 실패
비공개 이슈 읽기 제한된 접근 토큰 비공개 정보 노출
라벨 변경 외부 상태 변경 잘못된 분류
댓글 작성 사용자 이름으로 외부 쓰기 평판·협업 영향
로컬 저장소 수정 파일 쓰기 소스 손상
테스트 실행 프로세스 실행 자원 소모·임의 명령 위험

network: truegithub: allowed 같은 한 줄은 이 차이를 충분히 표현하지 못한다. 읽기와 쓰기, 목적지, 리소스 범위, 승인 필요 여부가 함께 있어야 한다.

권한 선언의 목적은 세 가지다.

  1. 설치 전 설명: 사용자가 플러그인이 무엇을 할 수 있는지 확인한다.
  2. 정책 검증: 조직이나 워크스페이스가 허용하지 않는 요구를 설치 전에 거절한다.
  3. 런타임 강제: 실제 도구 호출이 선언된 범위를 넘지 못하게 한다.
manifest는 행동 계약이다

“코드가 현재 무엇을 하는가”를 추론한 문서가 아니라 “런타임이 이 코드에 무엇을 허용할 것인가”를 선언해야 한다. 런타임이 강제하지 않는 manifest는 권한표가 아니라 설명문에 그친다.

manifest 선언과 런타임 강제를 분리한다

권한 시스템에는 서로 다른 주체가 있다.

flowchart LR
    M[Plugin Manifest
요구 권한] --> R[Policy Resolver] U[사용자 승인
허용 권한] --> R W[Workspace Policy
최대 권한] --> R A[Agent Policy
업무별 권한] --> R R --> C[Effective Capabilities] C --> G[Runtime Guard] G --> T[Tool Handler]

manifest는 플러그인이 요구하는 범위다. 사용자가 설치했다고 모두 허용되는 것이 아니다. 조직 정책은 일부 네트워크 목적지를 금지할 수 있고, 특정 에이전트는 읽기 전용으로 실행될 수 있다.

선언과 강제를 분리하면 다음 질문을 구체적으로 다룰 수 있다.

manifest permissions 객체를 핸들러에 그대로 넘기지 않고 검증된 capability 객체로 변환한다.

@dataclass(frozen=True)
class EffectiveCapabilities:
    readable_roots: tuple[Path, ...]
    writable_roots: tuple[Path, ...]
    allowed_http_origins: frozenset[str]
    allowed_http_methods: frozenset[str]
    secret_handles: Mapping[str, SecretHandle]
    external_write_mode: Literal["deny", "approval", "allow"]

플러그인 코드가 문자열 manifest를 스스로 해석하면 자신에게 유리하게 처리할 수 있다. 신뢰 경계 밖의 플랫폼 런타임이 파싱하고 최소 권한 capability만 전달한다.

권한은 문자열보다 구조가 필요하다

초기에는 다음처럼 작성하기 쉽다.

permissions:
  filesystem: write
  network: allowed
  secrets:
    - github_token

검토하기는 쉽지만 범위가 너무 넓다. 다음처럼 작업과 범위를 구조화할 수 있다.

schema_version: 2
id: com.example.issue-helper
version: 1.4.0
entrypoint: runtime/main.py

tools:
  - name: issues.summarize
    risk: read
  - name: issues.comment
    risk: external_write

permissions:
  filesystem:
    read:
      - workspace://docs/**
    write:
      - workspace://.agent-output/**
    deny:
      - workspace://{any-directory}/.env*
      - workspace://{any-directory}/.git/**

  network:
    requests:
      - origin: https://api.example.invalid
        methods: [GET]
        paths:
          - /repos/{owner}/issues
          - /repos/{owner}/issues/{number}
      - origin: https://api.example.invalid
        methods: [POST]
        paths:
          - /repos/{owner}/issues/{number}/comments
        approval: per_action

  secrets:
    - name: issue_api_token
      purpose: api.example.invalid
      inject_as: brokered_header

  process:
    spawn: deny

  external_writes:
    default: approval_required

예시 도메인은 의도적으로 실제 서비스가 아닌 예약 도메인을 사용했다.

구조가 상세할수록 정책을 정적으로 검사하고 설치 화면에서 의미 있는 차이를 보여 줄 수 있다. 반면 지나치게 세밀하면 manifest 작성이 어렵고 사소한 변경마다 권한 갱신이 필요하다. 플랫폼이 실제로 강제할 수 있는 수준까지만 표현해야 한다.

권한 schema는 확장에 대비해 버전을 둔다.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["schema_version", "id", "version", "permissions"],
  "properties": {
    "schema_version": { "const": 2 },
    "id": {
      "type": "string",
      "pattern": "^[a-z0-9]+(?:[.-][a-z0-9]+)+$"
    },
    "permissions": {
      "$ref": "#/$defs/permissions"
    }
  },
  "additionalProperties": false
}

additionalProperties: falseexternal_write 오타를 런타임이 조용히 무시하는 일을 막는다. 알 수 없는 권한을 무시하고 설치하면 플러그인은 허용됐다고 생각하고 플랫폼은 거부하는 불명확한 상태가 된다.

여러 정책의 교집합으로 유효 권한을 만든다

실행 권한은 어느 한 선언만으로 결정하지 않는다.

effective
= plugin 요구 권한
∩ 사용자 허용 권한
∩ workspace 최대 권한
∩ agent/run 권한
∩ 현재 sandbox가 실제 제공하는 능력

예를 들어 플러그인은 workspace/** 쓰기를 요구하지만 사용자가 .agent-output/**만 허용하면 유효 쓰기 범위는 후자다. 워크스페이스가 외부 쓰기를 전부 금지하면 manifest의 approval_required보다 더 엄격한 deny가 이긴다.

권한 모드는 넓은 순서가 명확해야 한다.

RANK = {
    "deny": 0,
    "approval_required": 1,
    "allow": 2,
}


def most_restrictive(*modes: str) -> str:
    return min(modes, key=lambda mode: RANK[mode])

경로와 도메인은 단순 문자열 교집합이 어렵다. 이를 위해 내부 표현을 정규화된 정책 객체로 바꾸고, 결정 시 각 정책을 모두 통과하는지 평가할 수 있다.

def is_allowed(request: CapabilityRequest, policies: list[Policy]) -> bool:
    return all(policy.allows(request) for policy in policies)

이 방식은 미리 완벽한 교집합 목록을 생성하지 않아도 호출마다 같은 정책 체인을 적용할 수 있다. 단, 성능을 위해 컴파일한 정책을 캐시하더라도 플러그인 버전과 사용자 승인 세대를 cache key에 포함해야 한다.

deny가 우선한다

상위 정책의 명시적 deny를 하위 manifest가 다시 allow할 수 없게 한다. 권한 합성 규칙이 불명확하면 설정 파일 순서에 따라 보안 결과가 달라진다.

파일 시스템 권한을 경로와 작업으로 제한한다

filesystem: write는 홈 디렉터리, SSH 키, 다른 프로젝트까지 포함할 수 있다. 파일 권한에는 최소한 작업 종류와 root가 필요하다.

from pathlib import Path


class FileGuard:
    def __init__(
        self,
        workspace_root: Path,
        readable_roots: tuple[Path, ...],
        writable_roots: tuple[Path, ...],
    ):
        self.workspace_root = workspace_root.resolve()
        self.readable_roots = tuple(path.resolve() for path in readable_roots)
        self.writable_roots = tuple(path.resolve() for path in writable_roots)

    def authorize_write(self, requested: str) -> Path:
        candidate = (self.workspace_root / requested).resolve(strict=False)
        if not any(candidate.is_relative_to(root) for root in self.writable_roots):
            raise PermissionError("FILESYSTEM_WRITE_DENIED")
        return candidate

문자열이 workspace/로 시작하는지만 보면 workspace/output/../../.ssh 같은 경로 순회에 취약하다. 정규화된 절대 경로로 바꾼 뒤 허용 root 하위인지 확인한다.

하지만 resolve만으로 충분하지 않을 수 있다. 검사 뒤 심볼릭 링크가 바뀌는 TOCTOU 경쟁이 있고, 대상 파일이 아직 없으면 부모 경로의 링크를 따라갈 수 있다.

더 강한 실행 환경에서는 다음을 조합한다.

host workspace
├── source/       → sandbox /workspace/source (read-only)
└── .agent-output → sandbox /workspace/output (read-write)

host의 다른 경로는 sandbox에서 보이지 않음

애플리케이션 레벨 검사는 좋은 오류 메시지와 감사 로그를 제공하고, OS 수준 격리는 우회 시 피해 범위를 줄인다. 관련 원리는 에이전트 작업을 샌드박스에서 실행하기와 연결된다.

네트워크 권한의 목적지를 제한한다

network: allowed는 데이터를 어디든 전송할 수 있다는 뜻이다. origin, 메서드, 가능하면 경로와 용도를 제한한다.

type NetworkRule = {
  origin: string;
  methods: Set<"GET" | "POST" | "PATCH" | "DELETE">;
  pathPatterns: RegExp[];
};

function authorizeRequest(
  input: URL,
  method: string,
  rules: NetworkRule[],
): NetworkRule {
  const normalizedMethod = method.toUpperCase();
  const matched = rules.find(
    (rule) =>
      input.origin === rule.origin &&
      rule.methods.has(normalizedMethod as never) &&
      rule.pathPatterns.some((pattern) => pattern.test(input.pathname)),
  );

  if (!matched) throw new Error("NETWORK_DESTINATION_DENIED");
  return matched;
}

URL 문자열의 prefix만 비교하면 https://allowed.invalid.attacker.example 같은 우회가 가능하다. URL parser로 scheme, host, port를 정규화한다. 사용자 정보가 포함된 URL, 비표준 port, redirect도 정책에 포함한다.

특히 redirect를 HTTP 라이브러리에 자동으로 맡기면 허용된 호스트가 임의 호스트로 리디렉션해 정책을 우회할 수 있다. 각 redirect hop을 다시 검사하거나 redirect를 끈다.

DNS 이름 허용만으로 SSRF가 완전히 해결되지는 않는다. DNS rebinding이나 내부 IP로 해석되는 도메인을 막기 위해 연결 시점의 실제 IP 범위도 검사해야 한다. 프록시 기반 egress gateway에서 정책을 강제하는 편이 더 견고하다.

계층 막는 범위
URL origin 검사 명시되지 않은 외부 목적지
메서드·경로 검사 허용 API 안의 과도한 행동
redirect 재검사 허용 origin을 통한 우회
DNS/IP 검사 내부 메타데이터·사설망 접근
egress proxy 런타임 라이브러리 우회

플러그인이 자체 socket 라이브러리를 사용할 수 있다면 플랫폼 HTTP wrapper만 검사해서는 부족하다. 샌드박스 네트워크를 차단하고 허용된 broker를 통해서만 요청하게 하는 구조가 필요하다.

secret을 값이 아니라 참조로 전달한다

manifest에 secret 값이 들어가면 안 된다. 이름과 목적만 선언한다.

permissions:
  secrets:
    - name: issue_api_token
      purpose: api.example.invalid
      scopes:
        - issues:read
        - comments:write
      inject_as: brokered_header

가장 단순한 환경 변수 주입은 플러그인 프로세스가 값을 읽어 다른 곳으로 전송할 수 있다.

ISSUE_API_TOKEN=plain-secret-value

가능하면 secret broker가 실제 값을 소유하고, 플러그인은 opaque handle로 허용 API 요청을 위임한다.

@dataclass(frozen=True)
class SecretHandle:
    id: str
    allowed_origin: str
    allowed_scopes: frozenset[str]


class CredentialBroker:
    async def send(
        self,
        handle: SecretHandle,
        request: AuthorizedHttpRequest,
    ) -> HttpResponse:
        self._assert_origin(handle, request.url)
        token = await self._vault.resolve(handle.id)
        return await self._http.send(
            request.with_header("Authorization", f"Bearer {token}")
        )

플러그인에는 토큰 원문이 가지 않고, 허용된 목적지로 보내는 승인된 요청에만 broker가 헤더를 붙인다.

secret 사용 로그에는 값은 물론 전체 Authorization 헤더, query token, 응답 원문도 남기지 않는다. 권한 결정에는 secret의 논리 이름과 scope만 기록한다.

secret 권한은 network 권한과 함께 본다

비밀을 읽을 수 있지만 네트워크가 없으면 위험이 제한되고, 네트워크와 임의 파일 읽기를 모두 허용하면 .env를 빼낼 수 있다. 권한을 개별 목록으로만 보여 주지 말고 위험한 조합을 설치 화면에서 강조해야 한다.

외부 쓰기와 승인을 별도 축으로 둔다

HTTP POST가 항상 위험하고 GET이 항상 안전한 것은 아니다. 일부 API는 GET으로 작업을 시작하고, POST는 검색일 수 있다. 기술적 메서드 외에 사용자나 외부 시스템의 상태를 바꾸는가를 별도 메타데이터로 둔다.

tools:
  - name: issues.read
    effects:
      data: read
      external_state: none
    approval: never

  - name: issues.comment
    effects:
      data: send
      external_state: write
    approval: per_action

승인도 단순 boolean보다 범위가 필요하다.

승인 모드 의미 예시
never 허용 범위 안에서 자동 실행 공개 이슈 읽기
per_run 한 작업 실행 동안 특정 범위를 승인 저장소 A의 라벨 변경
per_action 각 외부 변경 전에 승인 댓글 게시, PR 생성
always_deny 실행 불가 결제, 사용자 삭제

승인 화면에는 도구 이름만 보여 주지 않고 실제 효과를 요약한다.

{
  "tool": "issues.comment",
  "plugin": "com.example.issue-helper@1.4.0",
  "effect": "external_write",
  "target": "example-org/demo-repo#42",
  "preview": "문제 재현 절차를 확인했습니다...",
  "credential": "issue_api_token",
  "expiresAt": "2026-08-21T10:05:00Z"
}

승인 후 인자를 바꿀 수 없도록 호출의 canonical hash를 승인 레코드에 저장한다. “댓글 작성 승인”을 받은 뒤 대상 이슈나 본문을 바꿔 실행하면 안 된다.

도구 등록과 호출 시점에 모두 검사한다

manifest에 tools: [issues.read]라고 적어 놓고 플러그인 진입점이 admin.delete_user를 추가 등록할 수 있다면 선언이 무의미하다.

등록 시점에 제공 도구와 manifest를 비교한다.

def register_plugin_tools(
    manifest: PluginManifest,
    declared_tools: list[RuntimeTool],
    registry: ToolRegistry,
) -> None:
    allowed = {tool.name: tool for tool in manifest.tools}

    for runtime_tool in declared_tools:
        declaration = allowed.get(runtime_tool.name)
        if declaration is None:
            raise PluginLoadError(
                f"undeclared tool: {runtime_tool.name}"
            )
        registry.register(
            wrap_with_policy(runtime_tool, manifest, declaration)
        )

그래도 호출 시점 검사가 반드시 필요하다. 인자에 따라 대상 경로와 네트워크 목적지가 달라지기 때문이다.

async def invoke_tool(call: ToolCall, context: RunContext) -> ToolResult:
    tool = context.registry.require(call.tool_name)
    decision = context.policy_engine.evaluate(
        plugin=tool.plugin_identity,
        tool=tool.declaration,
        arguments=call.arguments,
        run=context.run_policy,
    )

    if decision.kind == "deny":
        raise PermissionDenied(decision.reason_code)
    if decision.kind == "approval_required":
        approval = await context.approvals.require(decision)
        decision.assert_same_action(approval)

    return await tool.handler(
        context.with_capabilities(decision.capabilities),
        call.arguments,
    )

핸들러가 raw workspace root, 전체 환경 변수, 자유로운 HTTP client를 받지 않게 한다. 승인된 capability만 가진 context를 전달한다.

검사를 한 곳에서 우회 없이 통과시키는 구조가 중요하다. UI 실행은 guard를 지나지만 background job이나 CLI는 핸들러를 직접 호출한다면 취약한 별도 경로가 된다.

업데이트에서 권한 상승을 감지한다

플러그인 1.4.0에서 1.5.0으로 업데이트될 때 버전 숫자보다 권한 차이가 중요하다.

 permissions:
   filesystem:
     read:
       - workspace://docs/**
+      - workspace://src/**
   network:
     requests:
       - origin: https://api.example.invalid
         methods: [GET]
+      - origin: https://hooks.example.invalid
+        methods: [POST]

새 권한이 추가되거나 범위가 넓어지면 자동 업데이트를 멈추고 다시 승인받는다.

반대로 권한 축소는 자동 적용할 수 있지만, manifest schema 의미가 바뀌는 업데이트는 별도 검토한다.

type PermissionDelta = {
  added: Capability[];
  removed: Capability[];
  widened: CapabilityChange[];
  narrowed: CapabilityChange[];
};

function requiresConsent(delta: PermissionDelta): boolean {
  return delta.added.length > 0 || delta.widened.length > 0;
}

사용자가 예전에 승인한 것은 plugin ID + version range + permission digest에 묶는다. manifest 파일이 바뀌었는데 승인 레코드를 그대로 재사용하지 않는다.

설치 화면은 의미 중심으로

YAML diff만 보여 주기보다 “새 버전은 src 디렉터리를 읽고 외부 webhook으로 데이터를 보낼 수 있습니다”처럼 위험 조합을 사람이 이해할 수 있게 요약한다.

manifest 자체의 신뢰성을 확인한다

공격자가 플러그인 코드와 manifest를 함께 바꾸면 schema 검증만으로 출처를 확인할 수 없다. 배포 패키지의 콘텐츠 해시와 서명을 확인한다.

publisher key
  → manifest + entrypoint + package files의 Merkle root 서명

installer
  → publisher identity 확인
  → 서명 검증
  → package digest 고정
  → 권한 diff 표시
  → 사용자 승인

중요한 것은 manifest만 서명하고 실행 파일은 제외하지 않는 것이다. 선언은 그대로인데 entrypoint가 교체되면 서명이 의미가 없다.

로컬 개발 플러그인은 서명 없이 허용할 수 있지만 신뢰 수준을 명확히 표시하고 더 제한된 sandbox 정책을 적용한다.

설치 출처 신뢰 표시 기본 정책
검증된 marketplace 서명 publisher 확인됨 선언 권한 내 허용
직접 지정한 로컬 경로 개발자 모드 제한 sandbox와 매 실행 경고
원격 URL의 미서명 패키지 출처 불명 기본 설치 거부

서명은 코드가 안전하다는 보증이 아니라 누가 어떤 바이트를 배포했는지 확인하는 장치다. 취약한 코드와 과도한 권한은 여전히 별도 검토가 필요하다.

감사 로그에는 결정 근거를 남긴다

권한 거부 로그가 permission denied 한 줄이면 운영자가 어떤 정책을 고쳐야 할지 알기 어렵다. 허용과 거부 모두 최소한의 결정 근거를 남긴다.

{
  "event": "capability_decision",
  "runId": "run_demo_42",
  "plugin": "com.example.issue-helper@1.4.0",
  "tool": "issues.comment",
  "capability": "external.write",
  "targetHash": "demo:repo-issue-42",
  "decision": "approval_required",
  "reason": "plugin_declared_and_workspace_requires_approval",
  "manifestDigest": "sha256:example",
  "policyGeneration": 17,
  "approvalId": "approval_demo_7"
}

다음 값은 피한다.

정책 세대와 manifest digest가 있으면 나중에 “그 시점에 어떤 규칙으로 허용됐는가”를 재구성할 수 있다.

관찰할 지표는 다음과 같다.

장기간 사용되지 않는 권한은 manifest에서 제거할 후보다. 실제 사용량을 자동으로 manifest 축소에 적용하지는 말고 개발자에게 제안한다.

테스트해야 하는 우회 경로

권한 테스트는 정상 거부 사례 하나로 끝나지 않는다.

파일 경로

@pytest.mark.parametrize(
    "path",
    [
        "../secret.txt",
        "output/../../secret.txt",
        "/absolute/path.txt",
        "output/link-to-outside/token.txt",
        "output/file.txt<NUL>.png",
    ],
)
def test_file_guard_denies_escape(path: str):
    with pytest.raises(PermissionError):
        guard.authorize_write(path)

실제 심볼릭 링크와 경로 교체 경쟁도 통합 테스트한다.

네트워크

test.each([
  "https://api.example.invalid.attacker.test/data",
  "http://api.example.invalid/data",
  "https://api.example.invalid:8443/data",
  "https://user@api.example.invalid/data",
  "https://api.example.invalid/forbidden/path",
])("denies undeclared destination %s", (rawUrl) => {
  expect(() => authorizeRequest(new URL(rawUrl), "POST", rules)).toThrow();
});

redirect가 다른 origin으로 향할 때와 DNS가 사설 IP로 해석될 때도 확인한다.

승인

실행 경로

import도 실행이다

동적 언어 플러그인은 module import만으로 코드가 실행될 수 있다. entrypoint를 호스트 프로세스에 직접 import한 뒤 권한을 검사하면 이미 늦다. 격리된 프로세스에서 로드하고 등록 프로토콜만 노출하는 편이 안전하다.

manifest로 해결되지 않는 것

manifest 권한은 중요한 기반이지만 다음 문제를 자동으로 해결하지 않는다.

허용된 권한 안의 악의적 행동

플러그인이 특정 API에 댓글을 쓸 권한이 있으면 악성 댓글을 쓸 수도 있다. 입력 검증, 승인 preview, rate limit, 운영 취소 경로가 필요하다.

프롬프트 인젝션

읽어 온 이슈 본문이 에이전트에게 “secret을 출력하라”고 지시할 수 있다. 권한 분리는 피해를 제한하지만 데이터와 명령을 구분하는 모델·프롬프트 설계도 필요하다.

샌드박스 탈출

애플리케이션 guard에 취약점이 있거나 런타임 자체가 손상되면 manifest를 우회할 수 있다. 프로세스 격리, OS 보안 업데이트, 의존성 관리가 별도로 필요하다.

confused deputy

권한이 큰 에이전트가 권한이 작은 플러그인의 요청을 대신 실행하면 간접 상승이 생긴다. capability는 호출 체인을 따라 전달하고, 대리 호출에서도 원래 principal과 목적을 검사해야 한다.

공급망 취약점

서명된 정상 플러그인도 의존성 취약점을 포함할 수 있다. 잠금 파일, 재현 가능한 빌드, SBOM, 취약점 점검은 별도 계층이다.

정리

Agent Commons 같은 에이전트 플랫폼에서 플러그인 manifest는 설치 메타데이터 이상의 역할을 해야 한다. 사람이 위험을 이해하고, 플랫폼이 정책을 계산하며, 런타임이 실제 호출을 제한하는 공통 계약이어야 한다.

설계 원칙을 정리하면 다음과 같다.

  1. 권한을 플러그인 이름이 아니라 실제 행동과 부수 효과로 표현한다.
  2. manifest의 요구 권한과 사용자가 허용한 권한을 구분한다.
  3. 유효 권한은 plugin·user·workspace·agent·sandbox 정책의 교집합으로 만든다.
  4. 파일 권한은 read/write와 정규화된 root로 제한하고 OS 격리를 함께 사용한다.
  5. 네트워크는 origin·메서드·경로·redirect·실제 목적지까지 검사한다.
  6. secret 원문 대신 목적이 제한된 broker handle을 전달한다.
  7. 외부 쓰기는 기술적 HTTP 메서드와 별개로 표시하고 필요한 경우 인자에 묶인 승인을 받는다.
  8. 도구 등록 시 선언 일치를 확인하고 모든 호출 경로에서 다시 강제한다.
  9. 업데이트의 추가·확대 권한을 감지해 다시 동의받는다.
  10. manifest와 실행 패키지 전체의 출처와 digest를 검증한다.
  11. 결정 근거와 정책 버전을 감사 로그에 남긴다.
  12. manifest만 믿지 않고 sandbox, 공급망 보안, 프롬프트 인젝션 방어를 함께 둔다.

권한 manifest의 가치는 플러그인이 무엇을 원한다고 적는 데 있지 않다. 플랫폼이 그 선언보다 더 많은 능력을 절대로 건네지 않는 데 있다.

관련 노트