aic-exec — README (EN)EN

aic-exec

Authorization is a cryptographic fact. aic-exec is where it decides whether a command may run.

License Go Version Go Reference Status

AIC-gated command executor. One tool that serves most agents: an agent submits a command vector, aic-exec projects it into a CLC-v1 operation, decides it against the caller's AIC grants (all policy is CLC), runs the command without a shell, and writes an evidence record for every invocation — allowed or denied.

It exists so that no per-agent integration has to be written: any agent that can run a local command or register an MCP server can route all command execution through the same admission + CLC decision + evidence core.

            ┌──────────────────────────────────────────────────┐
 agent ──▶  │ aic-exec: one decision core (package gate)        │
 (CLI or    │  aic-verifier admission   (mTLS AIC / Bearer)     │
  MCP)      │  execclc                  (argv → CLC operation)  │
            │  CLC (aic-verifier)       (grant set → verdict)   │
            │  execer                   (no shell, timeout)     │
            │  evidence                 (audit + evidence)      │
            └──────────────────────────────────────────────────┘

Two entry points, one core

Form Invocation Use
CLI aic-exec git status replace an agent's shell/exec tool; hook-friendly
MCP aic-exec serve register once as an MCP server in any agent

Both call the same gate.Run, so the CLC decision and the evidence format are identical. Where a command is executed is configuration (see Configuration below): locally, or on a remote aic-exec authority (with an optional chained front node between an agent and that authority).

The two gates

  1. Admission (who). The caller presents an AIC credential — an AIC X.509 client certificate (mTLS) or a Bearer AIC-JWT. aic-verifier verifies it against the trust root and yields the identity + capability set. For the MCP form the tool additionally requires the varwof/exec-v1:* capability as a coarse front door before the request reaches the core.
  2. Command (what, CLC). The requested argv is projected by the projection table into a CLC-v1 operation id and parameter set, then decided against the caller's AIC grants by the CLC core. The projection table is not policy: it only says how to name and parameterize a command. Whether the command may run is decided entirely by CLC (allow / deny with a normative reason / allow_unresolved for residual obligations the executor discharges).

argv is passed verbatim to the executable (exec.CommandContext); no shell is invoked, so pipes, redirects, globs and command substitution are never interpreted.

Projection table

The table maps each executable basename to how its action and parameters are derived. An operation is varwof/exec-v1:<tool>:<action> (see docs/varwof-exec-v1.md); a subcommand invoked with no non-flag token uses the reserved action invoke.

{
  "version": 1,
  "tools": {
    "echo": {"action": {"kind": "fixed", "value": "invoke"},
             "params": {"flags": {"kind": "flags"}, "positionals": {"kind": "positionals"}},
             "timeout": "10s"},
    "git":  {"action": {"kind": "subcommand"},
             "params": {"flags": {"kind": "flags"}, "positionals": {"kind": "positionals"}},
             "timeout": "30s"}
  }
}
  • action.kindsubcommand (first non-flag token) or fixed (constant value).
  • params — the fixed parameter schema, always emitted in full (key closure): flags (all - tokens, sorted and deduped), positionals (remaining non-flag tokens), string (exact, from --flag=) or number (upper bound, from --flag=).
  • timeout — per-invocation runtime bound (default 30s); not a policy knob.

CLI form

# First run creates a private local trust root under ./localca and mints a
# short-lived claim scoped to the requested tool, then runs the command through
# the real admission + gate pipeline in-process.
$ aic-exec --tools-table examples/tools-table.json git status
exit=0 dur=3ms resolved=/usr/bin/git

$ aic-exec --tools-table examples/tools-table.json rm -rf /tmp/x
aic-exec: projection: tool "rm" is not in the projection table   # denied + evidence

Evidence lands in ./localca/audit.jsonl. The local trust root is the machine operator's implicit delegation for locally-invoked commands; CLC still decides every projected operation.

Configuration: local or remote

Both forms read one JSON file — ./aic-exec.json when present (see examples/aic-exec.json). Command-line flags always beat the file; the file fills only values you did not set. With no file the previous defaults apply unchanged.

$ aic-exec --config my.json git status   # CLI form reads run.*
$ aic-exec serve --config my.json        # server form reads serve.*

Where the command runs is decided by the config, on both sides:

  • run.target: "local" (default) — the CLI self-bootstraps a trust root and adjudicates + executes in-process.
  • run.target: "remote" — the CLI hands the argv to a remote aic-exec authority over its /exec endpoint (TLS; bearer AIC-JWT or mTLS client certificate). The remote node does the CLC decision, the execution, and the evidence; the CLI has no local gate on this path.
  • serve.upstream — a serve node becomes an AIC admission front: it authenticates the agent, then forwards the command to a remote authority for adjudication + execution. Compose nodes into a policy chain while one central node keeps the grants. serve exposes both /mcp (agents) and /exec (remote clients / the upstream edge).
# remote CLI: run on the authority node
$ aic-exec --target remote --url https://auth:9446/exec \
    --token-file demo/token.txt git status

# chained serve: this node only admits, the authority decides and runs
$ aic-exec serve --config chain-config.json

The remote trust model is fail-closed: verification of the peer TLS certificate is required (ca bundle; insecure: true is dev-only) and a configured-credential requirement is enforced at startup — a broken token, certificate or timeout fails the process before any command is attempted.

Local chain: real issuer, real identity

The CLI form no longer self-invents an identity: give it a persistent issuer directory (e.g. one produced by mint) and the identity the claim is minted under. The issuer root is reused, not recreated:

$ aic-exec mint --dir ops-ca --grant 'varwof/exec-v1:echo:*'
# run.config local.local_ca: "ops-ca", agent_id "ops-admin", issuer/audience "aic-exec"
$ aic-exec --config my.json echo hi          # claim minted by ops-ca, identity = ops-admin

run.local fields: tools_table, local_ca, agent_id (default local-operator), issuer/audience (default aic-exec-local), realm (default example). The value must match what an authority trusts: a serve node with --jwt-ca ops-ca/ca.pem --issuer aic-exec --audience aic-exec accepts these self-minted claims.

User-signer approval

run.local.signer: true attaches a varwof/constraint-v1:signer:operator authorization constraint and evaluates it via the custom ConstraintEvaluator extension (the user-signer approval hook). The command prompts before running; a declined or interrupted approval denies fail-closed:

$ printf 'y\n' | aic-exec --config signer.json echo hi    # approves and runs
$ printf 'n\n' | aic-exec --config signer.json echo hi    # approval denied

Node-to-node mTLS

The same client-certificate trust that admits agents also secures the serve→upstream hop. Mint node credentials under one shared CA, then point the upstream at it:

# one shared issuer CA; node client certs signed by it
$ aic-exec mint --dir ops-ca --grant 'varwof/exec-v1:echo:*'
$ mkdir ops-front ops-client && cp ops-ca/ca.pem ops-ca/ca-key.pem ops-front/
$ cp ops-ca/ca.pem ops-ca/ca-key.pem ops-client/
$ aic-exec mint --dir ops-front --mtls --agent-id front-node --grant 'varwof/exec-v1:echo:*'
$ aic-exec mint --dir ops-client --mtls --agent-id cli-ops   --grant 'varwof/exec-v1:echo:*'

# authority only accepts mTLS nodes under ops-ca; a remote CLI presents a node cert
$ aic-exec serve --addr :19449 --auth mtls --mtls-ca ops-ca/ca.pem ...
$ aic-exec --target remote --url https://front:19450/exec \
    --cert-file ops-client/client-cert.pem --key-file ops-client/client-key.pem echo hi

front.json (serve form) carries the upstream edge:

"serve": { "addr": ":19450", "auth": "mtls", "mtls_ca": "ops-ca/ca.pem",
  "upstream": { "url": "https://auth:19449/exec", "auth": "mtls",
    "cert_file": "ops-front/client-cert.pem", "key_file": "ops-front/client-key.pem" } }

The authority's evidence then attributes the request to the front node (client_cn, agent_id = front-node) — the link uses the node identity, not the downstream agent's.

MCP form

# 1. mint a credential the agent can present (bearer token or --mtls cert pair).
#    A grant is a CLC capability id; a constraint is a residual obligation.
$ aic-exec mint --dir demo --grant 'varwof/exec-v1:git:*' \
    --constraint 'varwof/constraint-v1:time:window:[{"start":"09:00","end":"17:00"}]'

# 2. serve: an AIC-gated MCP server exposing the "exec" tool
$ aic-exec serve --tools-table examples/tools-table.json --tools examples/mcp-tools.json \
    --addr :9446 --auth both --jwt-ca demo/ca.pem --mtls-ca demo/ca.pem

In an MCP-capable agent (claude, codex, opencode, …) register https://HOST:9446/mcp as an MCP server with the credential; the agent then calls the exec tool with {"args": ["git", "status"]}. Every call is admitted by aic-verifier, projected and decided by CLC, and recorded.

--auth selects the front end: bearer (AIC-JWT), mtls (AIC X.509 client certificate) or both (either).

Verdicts

gate.Response (and the MCP tool result) carries the CLC three-valued verdict:

  • allow — the operation was authorized outright.
  • deny + deny_reason — normative CLC reason (capability_not_authorized, params_exceed_grant, params_missing, undeclared_param, not_in_enum, …) or a projection: error when no operation could be formed.
  • allow_unresolved — a recognized residual obligation (a time:window or network:cidr constraint). The executor discharges each obligation itself; on success the command runs and the evidence marks it released:<obligations>; otherwise it fails closed.

Evidence

Every invocation writes one line through the aic-verifier audit sink:

{"entry":{"action":"exec","target":"git status",
  "target_id":"varwof/exec-v1:git:status sha256:… unresolved","duration":"3ms",
  "decision":"allow","deny_reason":"released:varwof/constraint-v1:time:window:…",
  "agent_id":"agent-001","capabilities":["git:*"],
  "aic_fingerprint":"…","level":"INFO"}}

The line carries the admitted identity (agent id, capabilities, AIC fingerprint, delegation hash), the projected CLC operation id and the exact argv digest, plus the execution outcome (exit code, output size, duration). Denied commands are recorded too (decision":"deny", level":"WARN"), so the audit shows intent as well as action.

Signed DSSE decision records

Setting --evidence-dir (or evidence in the config) makes every decided invocation also leave a DSSE-wrapped CLC decision record through aic-verifier's evidence layer — the same replayable record the admission pipeline produces, so it can be re-computed offline and linked to its outcome:

aic-exec --evidence-dir ./evidence --evidence-key ./evidence-key.pem \
         --evidence-key-id exec-1 git status
// The record is verifiable against the pinned key, and recomputes offline.
signer, _ := aicverifier.LoadRecordSignerFile("./evidence-key.pem", "exec-1")
rep, _ := aicverifier.VerifyEvidenceDir("./evidence", signer.VerifyFn())

Supplying a key signs every record (RSA / ECDSA / Ed25519); without one the records are still content-recomputable, just unsigned. --strict-evidence denies the command when a record cannot be written. Evidence is off by default: an audit-only deployment produces no extra bytes.

Layout

Path Purpose
config/ JSON config: run.target local/remote, serve.upstream chain, RemoteConn (TLS + credential)
execclc/ projection table + argv → CLC operation + residual-obligation evaluation
execer/ shell-free execution with timeout/env pruning/output cap
evidence/ audit + evidence records over the aic-verifier sink
gate/ decision core shared by both entry points (exchangeable Backend)
remote/ transport to a remote /exec endpoint (TLS + bearer/mTLS)
proxy/ gate Backend that forwards to an upstream authority
cmd/aic-exec/ CLI: run (default), serve, mint
examples/ projection table, MCP tool manifest and config samples

Design notes

  • docs/varwof-exec-v1.md — the varwof/exec-v1 scheme: identifier grammar, projection rules, CLC params/constraints.
  • docs/aic-clc-meaning.md — what the implementation proves about AIC and CLC: the enforcement surface reaching the action edge, the capability-granularity limit, and why the command gate converged on CLC.

Status

Preview (v0.1.0-rc). Dependencies resolve from the module proxy: go.mod pins the published aic-verifier v0.3.0-rc2 with no local replace, so a plain go get github.com/varwof/aic-exec works. The API may still change within a minor before v1.0.

License

Apache-2.0 (see LICENSE).