Skip to content

imbue-ai/detent

Repository files navigation

Detent

npm CI license downloads

Fine-grained HTTP permissions for AI agents.

Quick example

# Store rules.
echo '{
  "rules": [
    {"github-rest-api": ["github-read-all", "github-write-issues"]},
    {"slack-api": ["slack-read-all"]}
  ]
}' > ~/.config/detent/config.json

# Check a request before sending.
# (Exit code: 0 = approved, 1 = rejected.)
detent curl -s https://api.github.com/repos/octocat/Hello-World/issues/1

Installation

npm install -g @imbue-ai/detent

(This is not needed if you only intend to use Detent as a JavaScript library.)

Motivation

Users of AI agents sometimes give them access to services like Slack, Google Drive, GitHub and others. Giving agents full access is unnecessarily risky - the best practice in most cases is giving only the necessary level of access. In practice, that can be challenging, especially for services that do not offer native granular permissions.

Detent is meant to address this. Users or developers can easily specify permissions, from broad ones ("only read access to my Slack") to more specific ones ("only read access to GitHub issues for this one repository").

Integrations

Checking permissions is only useful if the results are respected. To be effective, Detent needs to be integrated into whatever tool the agent uses to access third-party services.

Latchkey

Latchkey lets users point to a Detent config in order to control what agents can and can't access.

Details and architecture

Detent is a command line tool and a Typescript library. It allows users and developers to:

  1. Define named permissions for outgoing HTTP requests.
  2. Check that a given request is allowed given the defined permissions.

Usage

Store your permission setup in a config file as documented below. Then assemble a curl invocation for an HTTP request and prepend it with the detent command (detent curl ...) to check whether it's allowed. Detent returns 0 if the request is allowed based on the config, 1 if it's not allowed and 2 or higher in case of errors. No requests are actually sent.

Alternatively, use detent as a library:

import { check } from "detent";

const request = new Request("https://api.example.com/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "alice" }),
});

const result = await check(request);

Configuration

All the configuration goes to ~/.config/detent/config.json (or $XDG_CONFIG_HOME/detent/config.json if XDG_CONFIG_HOME is set). Use the DETENT_CONFIG environment variable to specify a different path.

Matching requests

An HTTP(s) request can be represented as an object that has several well-defined properties: protocol, domain, port, path, method, headers, queryParams, body and parsedBody. Using this representation, the detent tool uses JSON schema to:

  1. Match requests to permission checks.
  2. Define the "acceptable" shape of a request that is subject to a permission check.

Some of the fields are normalized to canonical form before matching: method is always uppercase (e.g. "GET"), protocol, domain and headers keys are always lowercase (e.g. "content-type").

body holds the raw request body as a string. parsedBody holds a structured representation of the body and is only present when the body can be parsed, which currently happens for JSON bodies (content type application/json or *+json). Matching against parsedBody lets schemas inspect individual fields of a request body without resorting to brittle regular expressions. Support for other body formats (e.g. XML, GraphQL) may be added later.

Request schemas

We use JSON schema for matching and validating request objects. For example:

{
  "properties": {
    "method": { "const": "GET" }
  },
  "required": ["method"]
}

This would match all GET requests, regardless of the domain, path, or anything else. Schemas must use normalized field values (uppercase for methods etc.).

In the Detent config, schemas are identified by names, like this:

{
  "schemas": {
    "github-api": {
      "properties": {
        "domain": { "const": "api.github.com" }
      },
      "required": ["domain"]
    },
    "github-read-issues-detent": {
      "properties": {
        "method": { "const": "GET" },
        "path": {
          "type": "string",
          "pattern": "^/repos/imbue-ai/detent/issues(/[0-9]+)?$"
        }
      },
      "required": ["method", "path"]
    },
    ...
  }
}

For a complete example config that defines custom schemas, see docs/example-cloudflare.json.

Permission rules

Once defined, request schemas can be combined in a two-level rules hierarchy, like this:

{
  "schemas": {...},
  "rules": [
    {"github-api": ["github-read-issues-detent", "github-write-comments-detent", ...] },
    {"slack-api": ["slack-read-all"] }
  ]
}

In each rule, the key defines scope ("should a given request be subject to this rule") and each value represents a list of permissions allowed by this rule.

This is the meaning of the rules in the example above:

  • When accessing the GitHub API, the only allowed actions are reading issues and writing comments in the Detent repository.
  • When accessing the Slack API, only read actions are allowed.
  • No other requests are allowed.

Rule resolution, default outcomes

When a request gets checked, the rules in your config are simply evaluated from top to bottom. The first rule whose scope matches the request determines the outcome: if the request matches any of the permissions in the rule, it's approved. Otherwise, it's rejected. Further rules are not evaluated. By default, requests that don't match any rule get rejected. If you want to allow requests by default, append the {"any": ["any"]} rule to the end of your rule list.

Hooks (custom executable checks)

In addition to the plain list of schemas shown above, a rule's value can be an object with schemas and/or hooks:

{
  "rules": [
    {
      "github-rest-api": {
        "schemas": ["github-read-issues-detent"],
        "hooks": ["./check-actor.sh", "audit-log"]
      }
    }
  ]
}

Semantics:

  • schemas works exactly like the plain list form: the request is approved (by this gate) if any listed schema matches the decomposed request.
  • hooks is a list of executables; the request is approved (by this gate) only if all of them exit 0.
  • When both fields are present, both must succeed (AND).
  • Hooks run before schemas.

Each hook is invoked with a single argument: the path to a temporary JSON file containing the decomposed request (the same shape used by schema validators - see the request schema fields listed above). Hooks must follow detent's exit-code convention:

  • 0: allowed by this hook
  • 1: rejected by this hook
  • 2 or higher: error; the whole detent invocation aborts with that exit code (no further rules are evaluated)

Hook paths are resolved at execution time, in this order:

  1. Absolute path: used as-is.
  2. Path containing a separator: resolved relative to the directory of the config file that defined the rule.
  3. Bare name: looked up via $PATH.

Within a single rule, hooks run sequentially in array order. Evaluation short-circuits on the first non-zero exit: a 1 rejects the rule (no further hooks are run) and a 2+ aborts the whole detent invocation with that exit code. When the library API (check) hits a hook with exit code 2+, it throws a HookExecutionError carrying the offending hook spec and exit code; the CLI re-uses that exit code.

Built-in schemas

Detent comes with a number of preconfigured schemas out of the box that are automatically available and recognized in rule bodies:

  • any (to match and allow any and all requests)
  • aws-s3 (to identify requests going to AWS S3)
  • aws-s3-read (to allow read operations on AWS S3)
  • stripe-read-all (to allow all read operations in Stripe API)
  • google-drive-write-comments (to allow adding comments to Google Drive items)
  • ... and many others, see docs/builtin-schemas.md for the full list

Run detent dump to see your current config together with all the available built-in schemas. If you only want to list the schema names, run detent dump | jq '.schemas | keys'.

If you don't want to use the built-in schemas, set the DETENT_DO_NOT_USE_BUILTIN_SCHEMAS environment variable to a non-empty value.

Including other config files

Use the include key to split your configuration across multiple files. Paths are resolved relative to the directory of the config that contains the include.

{
  "include": ["shared/example.json", "shared/another_example.json"],
  "rules": [
    {"github-rest-api": ["github-read-issues"]}
  ]
}

Included configs are merged recursively: schemas and rules from all included files are collected first (in list order), then the including config's own schemas and rules are applied on top. This means the parent config's schemas override equally-named included schemas, and its rules are evaluated after included rules. Circular includes are detected and rejected.

Contributing

Contributions of all kinds are welcome!

Disclaimer

We're providing the preconfigured schemas for convenience, but it's likely that some of them may not work entirely as intended. We hope that the community will help us refine the built-in permission definitions over time. In the meantime, preferably double-check built-in definitions before using them, and when possible, use API tokens with reduced permission scopes.

We still think the tool is useful in its current form as a protection against accidental agent actions and the first line of defense against malicious or compromised agents.

About

A CLI tool to validate HTTP requests against a set of permission rules.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors