kosa8 docs

Guides / JavaScript & TypeScript / Approvals and policy

Approvals and policy

An organisation can require that named people sign off before certain actions run: deleting a sandbox, opening the network, sharing a host folder. When a call needs that, the SDK throws Kosa8ApprovalRequiredError with everything a person needs to approve it. Your code waits for the approval id, then repeats the call with it.

The policy is signed and installed by an administrator; see Company rules and the Policy reference. Without one installed, nothing on this page applies, and every call runs without asking.

The policy used on this page

One rule: any destructive action needs one signed approval from someone other than the person asking. Two people are listed. The requester must be listed too; see below for why.

{
  "name": "sdk-docs-test",
  "approvers": [
    {"name": "Asha Rao", "email": "[email protected]",
     "public_key": "8yU/pivJ5lS5yO4Qt4xJ7nL9SIiarRoHGy4/v+z6UyY=", "roles": ["security"]},
    {"name": "Dev Requester", "email": "[email protected]",
     "public_key": "J1mlnrtJI2Z/tfplWtx2dvBx50nwyKa1S1eyHnUQEsA="}
  ],
  "approval_rules": [
    {"match": "destructive", "required": 1}
  ]
}
$ kosa8 policy sign org.key profile.json > signed.json
$ kosa8 policy trust $(cat org.key.pub)
trusted policy signer set
$ kosa8 policy install signed.json
installed and activated policy "sdk-docs-test"

It took effect at once, without a daemon restart.

1. Your code meets the refusal

import { Kosa8Client, Kosa8ApprovalRequiredError } from '@kosa8/sdk';

const kosa8 = new Kosa8Client();
try {
  await kosa8.deleteSandbox('runner');
} catch (err) {
  if (!(err instanceof Kosa8ApprovalRequiredError)) throw err;
  const a = err.approval;
  console.log('status', err.status, '|', err.message);
  console.log('why:', a.why);
  console.log('class:', a.class, '| action:', a.action);
  console.log('signers:', a.signers);
  console.log('ask for it with:', a.request_command);
  console.log('requirement:', JSON.stringify(a.requirement));
}
$ node approve1.mjs
status 403 | policy "sdk-docs-test" requires 1 signed approval(s) for destructive, from people other than the requester: `sandbox-rm runner` needs signed approval before it can run — no approval was presented with the call (ask for it with `kosa8 approve request sandbox-rm runner --reason "..."`; have one of Asha Rao <[email protected]> sign it with `kosa8 approve sign REQUEST --reason "..."` (as many distinct people as the policy asks, never the requester); file what they send back with `kosa8 approve add FILE...` and record it with `kosa8 approve status ID --record`; then repeat this call with the approval id — `--approval ID` on the CLI, the `approval` argument of an MCP tool, or the X-Kosa8-Approval header on the API)
why: no approval was presented with the call
class: destructive | action: { kind: 'sandbox-rm', target: 'runner' }
signers: [ 'Asha Rao <[email protected]>' ]
ask for it with: kosa8 approve request sandbox-rm runner
requirement: {"policy":"sdk-docs-test","rules":[{"match":"destructive","required":1}]}

Nothing happened to the sandbox. err.approval is the daemon's refusal, unchanged:

FieldMeaning
whyWhat was wrong: nothing presented, not recorded, already used, expired.
action{ kind, target?, command?, sandbox? }: exactly what is being approved.
classread-only, contained, destructive or escalating.
signersThe people the policy lets approve this.
request_commandThe CLI command that opens a request for exactly this action.
requirement{ policy, rules }: which rules apply.
policy, policy_digest, route, description, request_idWhich policy, its hash, the API route, a description, and the approval id that was presented, if any.

In an app, show why, signers and request_command to the person using it, or post them to wherever approvals happen in your team.

2. A person asks, and an approver signs

This part is done by people, with the CLI. The requester opens a request with their signing identity (kosa8 identity init makes one):

$ kosa8 approve request --reason "tear down the CI sandbox" sandbox-rm runner
request 5a259f1f403447c43278d2e35bf9741c opened by Dev Requester <[email protected]> [cdf5aa01c24ec6c5]
  action   sandbox-rm runner
  on       runner
  needs    policy "sdk-docs-test" requires 1 signed approval(s) for destructive, from people other than the requester
  expires  Thu, 15 Oct 2026 21:41:34 EDT
  file     /tmp/k8d/.kosa8/approvals/5a259f1f403447c43278d2e35bf9741c/request.json

send the file to an approver; they run:
  kosa8 approve sign /tmp/k8d/.kosa8/approvals/5a259f1f403447c43278d2e35bf9741c/request.json --reason "..."
an approver on this machine can pass the request id instead of the file.
add what they send back with `kosa8 approve add FILE`, then check with
  kosa8 approve status 5a259f1f4034
Put --reason before the action

Everything after the action kind is read as part of the action. The refusal's hint, and the first request we opened, put --reason "…" at the end: kosa8 approve request sandbox-rm runner --reason "…". That recorded the action as sandbox-rm runner --reason '…', which is not the call your code will make. Write the flags first, as above.

Fixed in kosa8 0.9.36: the hint reads kosa8 approve request --reason "..." sandbox-rm runner, and approve request takes a --reason written at the very end as the reason. An action that really ends in --reason goes after --.

The approver reviews exactly what they are signing and signs with their own identity. For this page the approver's identity was in a separate folder on the same machine, hence --identity; normally they run this on their own computer with the request file you sent them. -y skips the confirmation prompt and -o writes the approval to a file to send back:

$ kosa8 approve sign request.json --identity ./asha --reason "reviewed: CI sandbox, nothing to keep" -y -o asha-approval.json
sandbox-rm runner

  on          runner
  touches     the kosa8 daemon on this host
  touches     target runner
  risk        destructive
  requested   by Dev Requester <[email protected]> [cdf5aa01c24ec6c5] at Thu, 15 Oct 2026 20:41:34 EDT on …
  their why   tear down the CI sandbox
  policy      sdk-docs-test (sha256:c12afa84ace502265e2c5be09cf610d186c207b567284a7251a72cd8a66e494a)
  signing as  Asha Rao <[email protected]> [4c34c0c35fb8fdd2]

approved request 5a259f1f4034 as Asha Rao <[email protected]> [4c34c0c35fb8fdd2]
  wrote /tmp/k8d/.kosa8/approvals/5a259f1f403447c43278d2e35bf9741c/approval-4c34c0c35fb8fdd2.json
  wrote asha-approval.json
send that file back to the requester; they add it with `kosa8 approve add FILE`

That request was opened under our first version of the policy, which turned out to need two approvers (see Who can ask). After the policy was fixed, a new request, 3ec6fc09ef23, was opened and signed the same way. The requester files the approval and records it:

$ kosa8 approve add asha-approval.json
added approval by Asha Rao <[email protected]> [4c34c0c35fb8fdd2] for request 3ec6fc09ef23
$ kosa8 approve status 3ec6fc09ef23 --record
approved under policy "sdk-docs-test" by Asha Rao <[email protected]>
recorded in the audit log
run the action once with `--approval 3ec6fc09ef23b948f14f942658490a63` (MCP: approval=3ec6fc09ef23b948f14f942658490a63)

3. Your code repeats the call with the id

import { Kosa8Client, Kosa8ApprovalRequiredError } from '@kosa8/sdk';

const kosa8 = new Kosa8Client();
const approval = process.argv[2];            // the id `kosa8 approve status ID --record` printed

console.log(await kosa8.deleteSandbox('runner', { approval }));

// An approval covers one call, once. Presenting it again is refused.
await kosa8.createSandbox({ name: 'runner', egress: 'deny' });
try {
  await kosa8.deleteSandbox('runner', { approval });
} catch (err) {
  if (err instanceof Kosa8ApprovalRequiredError) console.log('again:', err.approval.why);
  else throw err;
}
$ node approve2.mjs 3ec6fc09ef23b948f14f942658490a63
{ ok: true, checkpoints_dropped: 0 }
again: approval 3ec6fc09ef23 was already used; one approval authorizes one action once — open a new request

Every method takes approval as an option; the SDK sends it as the X-Kosa8-Approval header. The approval was spent by that one call. The same id on the next call, for the same action on a new sandbox of the same name, was refused.

Who can ask

Our first attempt listed only the approver and set allow_unlisted_requesters: true. The approval was then not enough:

kosa8: not yet approved under policy "sdk-docs-test" (requested by Dev Requester <[email protected]> [cdf5aa01c24ec6c5])
  destructive needs 2 distinct approver(s): have 1 (Asha Rao <[email protected]>), still need 1 more — and nobody left in the policy can supply it; an administrator must add approvers

That is deliberate. A request from a key the policy does not list could be a listed approver under a throwaway key, so kosa8 asks for at least two distinct listed approvers on every rule. List every requester in approvers, as the policy above does, and one approval from someone else is enough. The requester never counts towards their own request.

Calls that make several requests

An approval covers one request. Some SDK methods make several guarded requests, all with the approval you pass:

MethodRequests it makes
deleteSandboxThe delete, then one snapshot delete per checkpoint of that sandbox.
rewindA sandbox delete, a snapshot restore, then a snapshot delete per dropped checkpoint.
checkpointA snapshot, then a snapshot delete per evicted checkpoint.
exploreA pull, a fork, one run per branch, one delete per losing branch.
AI SDK adapter stop(), destroy()Snapshot and stop; delete sandbox and snapshot.

If the policy covers more than one of those requests, the first spends the approval and the next is refused. The SDK swallows refusals on the clean-up steps (old snapshots stay behind) but not on the main ones. For those, call the underlying methods yourself, one approval each, or ask your administrator to scope the rule to what actually needs a person.

Other refusals by policy

A policy can also refuse outright, with no approval possible: an image from a registry not on allowed_registries, a mount outside a label's mount_paths, the shared engine under require_sandbox. Those arrive as Kosa8PolicyError (HTTP 403) with the daemon's reason in message and what to do in remediation.

When we were done, we uninstalled the test policy by moving ~/.kosa8/policy.json and ~/.kosa8/policy-trust.pub out of the test state directory. On a real machine, policy is managed by your administrator.

Next

Errors reference: every error class, when it is thrown, and what to do.