Skip to content

Latest commit

 

History

History
208 lines (173 loc) · 9.17 KB

File metadata and controls

208 lines (173 loc) · 9.17 KB

Independent project: Cageforge is not affiliated with, sponsored by, or endorsed by OpenAI.

This crate is a supporting component of the cageforge crate, a cross-platform Rust sandbox for AI agents and untrusted code.

cageforge-permissions

cageforge-permissions provides the typed, platform-neutral preflight contract used by Cageforge hosts and language bindings. It turns a concrete launch request into an auditable capability description, lets a trusted approver create an opaque grant, and persists approved grants in a versioned host-owned store.

Add the crate

[dependencies]
cageforge-permissions = "x.y.z"

The crate is intentionally independent from the native Linux, macOS, and Windows enforcement crates. This makes the same request, grant, digest, and error model available to Rust, Python, Java, and interactive CLI hosts.

Preflight flow

Create a request from the immutable launch identity and requested capabilities. The request includes the tool and version, executable and manifest digests, target platform and architecture, and the exact filesystem, network, and child-process capabilities.

use cageforge_permissions::{GrantAuthority, PermissionRequest, PermissionSet};

let request = PermissionRequest::new(
    "example-tool",
    "1.2.3",
    manifest_digest,
    config_digest,
    platform,
    architecture,
    PermissionSet::default(),
)?;

// Only trusted host code should hold the authority used to approve requests.
let grant = GrantAuthority::new().approve(&request);

Filesystem capabilities use the stable operation labels read, write, deny, and map-executable. The last one is deliberately separate from read: a native runtime may be readable without being eligible for executable memory mapping. A request that contains map-executable must also contain the corresponding read capability when the native policy is restricted.

PermissionRequest is descriptive input. PermissionGrant has private fields and can only be created by GrantAuthority; callers cannot forge a grant by deserializing or reconstructing its representation. A grant is bound to the complete request identity and can be narrowed to a subset of the requested capabilities before launch.

The request is normally produced from the application's TOML profile by the facade or a binding. Its JSON representation is an optional transport format for a trusted host; it is not a second policy file. A persistent PermissionGrant is stored in permissions.json and can be reused only for the same request identity and capabilities. A changed profile is resolved again and produces a new request rather than widening an existing grant.

PermissionRequest is intentionally different from a launch request. A CommandRequest describes the executable and its argv, while a backend request carries the composed policy into one OS implementation. This crate's request is the host-facing capability declaration that connects those two steps before native launch.

The host performs this sequence before starting a process:

  1. construct the request from the exact launch inputs;
  2. load a matching grant, or ask the trusted approver;
  3. validate expiry, platform, architecture, digests, and capability scope;
  4. compose the approved scope with the immutable policy ceiling; and
  5. pass the resulting authorization to the native Cageforge crate.

There is no permission escalation inside a running process. For a profile that uses on-demand approval, PermissionEscalationRequest combines the immutable base request with explicit additional capabilities. A trusted host approves all or a subset with GrantAuthority::approve_escalation; the caller then stops the old child and creates a new sandbox launch. The base capabilities remain in the new grant, while unrestricted sentinels, deny-only additions, and unsupported child-process changes are rejected by the preflight layer.

For hosts that do not need dynamic approval, select a profile with the additional rules and create a new request and grant for that launch. The existing sandbox keeps its original policy; PermissionStore may reuse a matching persistent grant later, but it never widens a running process.

PermissionScope::Launch and PermissionScope::Session remain in memory. PermissionScope::Persistent may be written to the store only after the trusted host approves it. The store path is always selected by the host: the CLI accepts --permission-store PATH, which takes priority over its OS-native per-user default. Without an explicit path, Linux uses $XDG_STATE_HOME/cageforge/permissions.json (falling back to ~/.local/state/cageforge/permissions.json), macOS uses ~/Library/Application Support/Cageforge/permissions.json, and Windows uses %LOCALAPPDATA%\\Cageforge\\permissions.json. A trusted host may deliberately share one path across projects or choose separate stores. Rust callers pass an absolute path to PermissionStore::open, and the Python and Java bindings enforce the same rule with a language-native typed store-path error.

Persistence is revocable host state, not an undeletable system database. The owner may remove the file to revoke all saved approvals, and the next launch will require approval again. A host should keep the store outside any workspace or directory that it grants to the sandbox with write access.

Persistent grant store

PermissionStore stores grants in one versioned permissions.json document. It validates the document when the store is opened, then loads and indexes the current file for each operation. The stored audit metadata is checked against each request. An absent file is always treated as an empty store. Updates use a temporary file, sync, and atomic rename. A writer refreshes the latest document while holding the OS lock before replacing it. Unix stores are restricted to the owner; the Windows implementation applies and verifies an owner-only ACL. A persistent OS advisory lock serializes concurrent writers without a fixed retry budget.

The document contains host audit state, not executable policy:

{
  "schema_version": 2,
  "grants": {
    "<request-digest>": {
      "request_digest": "<request-digest>",
      "tool_id": "example-tool",
      "tool_version": "1.2.3",
      "manifest_digest": "<sha256>",
      "config_digest": "<sha256>",
      "platform": "linux",
      "architecture": "x86_64",
      "approved": {
        "filesystem": [],
        "network": [],
        "child_processes": []
      },
      "scope": "persistent",
      "expires_at": null,
      "issued_at": 1780000000
    }
  }
}

The digest key and the repeated identity fields bind the persisted approval to the exact tool, executable/manifest identity, configuration, platform, architecture, and requested capabilities. Editing a record does not grant new authority: the next lookup revalidates its digest, subset, expiry, schema, and owner-only file protection. A changed TOML profile or executable therefore requires a new approval.

Every get for an existing store and every put takes the kernel lock and refreshes the latest document before evaluating or replacing it. A not-yet- persisted store is treated as empty without creating a sidecar file. put then performs one durable atomic replacement; there is no userspace polling delay or fixed retry loop. The store does not use a userspace mutex for cross-process coordination; the OS lock is the authority. The durable JSON path is intentionally appropriate for relatively infrequent persistent approvals.

Inspecting and revoking grants

The store exposes bounded, metadata-only pages. A request gets the same stable ID in every language binding:

let page = store.list_page(GrantPageRequest::new(50, None)?)?;
for grant in page.entries() {
    println!("{} {}", grant.id, grant.tool_id);
}
if let Some(cursor) = page.next_cursor() {
    let _next = store.list_page(GrantPageRequest::new(50, Some(cursor.clone()))?)?;
}
store.revoke(request.grant_id())?;

GrantSummary contains only tool identity, target, scope, and timestamps. It does not expose the approved capability set or secret material. A cursor is opaque and becomes invalid when the JSON document changes. revoke affects future launches only; revoke_all retains the owner-protected file and is intended for an explicit host-management action. High-frequency hosts should keep session grants in memory or replace this host-owned storage behind the same API with a transactional backend such as SQLite.

The store is host state, not application policy. Keep it in a host-controlled location and pass its path explicitly when the launch environment requires a non-default location.

Integration

The cageforge crate builds requests from resolved profiles and combines an approved grant with the effective policy before invoking the selected native backend. The Python and Java bindings expose the same request identity, approval scope, expiry, and structured permission-error semantics. The CLI uses the same flow when a profile selects preflight approval.

See the cageforge crate and the configuration guide for the complete profile-to-launch flow.