Watch
0
0
Fork
You've already forked hyperhive
0

review classifier: run on ACP when the swarm settings choose it

The swarm settings gain `runtime` (`api-key`, the default, or `acp`) and
an `acp` provider record: an https base URL, the provider's model, and
its prices per million tokens. A PUT now changes only the fields its
body names, so the settings form that sends model, effort and limit
leaves the runtime alone; `null` clears `effort` or `acp`. The body stays
strict, and the stored document keeps #5192's lenient read.

The controller sends the runtime in each run request. On `acp` the
classifier runs one prompt on a new `opencode acp` session per
classification through hive-runtime's AcpRuntime, in a scratch
directory, with a config rendered from the settings. opencode runs
`read` and other tools without asking, so OPENCODE_PERMISSION
{"*":"deny"} takes every tool away from the model, and a deny-all policy
refuses any request on top. The title call, which resends the prompt,
is turned off. The deadline cancels the turn, and a turn that does not
end within 15s of that is dropped, which also covers an agent stuck
before its prompt. The answer is the final assistant text, as ACP has
no result event.

The provider key is read per run from
swarm/services/review-classifier/acp-provider, which the bao UI's
admins login may create and update, and reaches opencode through
AcpRuntime::with_provider_key and the trampoline's allowlist.

Each ACP classification's cost is computed from the configured prices
and the reported tokens, and added to
hyperhive.review_classifier.cost.usage labelled repo, pr, model and
currency.

Part of #5096
This commit is contained in:
atlas 2026-10-13 10:15:27 +02:00 • committed by mara
commit 1b4e764e5c
24 changed files with 1622 additions and 262 deletions

View file

@ -1455,7 +1455,8 @@ let
in
lib.hasInfix "path \"secret/data/swarm/services/review-classifier/forge/token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s
&& !(lib.hasInfix "secret/data/swarm/services/review-classifier/*" s)
&& !(lib.hasInfix "secret/data/swarm/services/review-classifier/anthropic-api-key" s);
&& !(lib.hasInfix "secret/data/swarm/services/review-classifier/anthropic-api-key" s)
&& !(lib.hasInfix "secret/data/swarm/services/review-classifier/acp-provider" s);
}
{
# The runner's role is the token's only reader. Its policy is the one
@ -1553,17 +1554,18 @@ let
lib.hasInfix "/ci-runner.pem ]" s && lib.hasInfix "swarm-ci-runner \"\" clientAuth" s;
}
{
# The classifier reads the key the operator writes and its own queue
# The classifier reads the keys the operator writes and its own queue
# client secret, each read-only and spelled to the leaf: `services/*`
# would reach every swarm service's secret.
name = "the review classifier's grant is its API key and its queue client secret, and nothing else";
name = "the review classifier's grant is its API key, its ACP provider key and its queue client secret, and nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-review-classifier-policy.script;
in
lib.hasInfix "path \"secret/data/swarm/services/review-classifier/anthropic-api-key\" {\n capabilities = [\"read\"]\n}" s
&& lib.hasInfix "path \"secret/data/swarm/services/review-classifier/acp-provider\" {\n capabilities = [\"read\"]\n}" s
&& lib.hasInfix "path \"secret/data/swarm/services/swarm-review-classifier/oidc/client\" {\n capabilities = [\"read\"]\n}" s
&& lib.length (lib.filter lib.isList (builtins.split "path \"" s)) == 2
&& lib.length (lib.filter lib.isList (builtins.split "path \"" s)) == 3
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
&& !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives" s)
@ -1956,10 +1958,10 @@ let
# ── the UI's OIDC login ─────────────────────────────────────────────────
{
# What an `admins` login through the UI holds: the key tree, KV
# metadata, and a write on the one leaf the operator fills by hand.
# metadata, and a write on the leaves the operator fills by hand.
# A `read` under `data/` would hand a browser session a secret's
# value, and any `sys/` path more than the UI needs.
name = "the operator viewer policy is list and read on KV metadata, a write on the classifier's API key, and no data/ read or sys/";
name = "the operator viewer policy is list and read on KV metadata, a write on the classifier's two keys, and no data/ read or sys/";
ok =
let
viewer = grantsIn baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script;
@ -1979,6 +1981,13 @@ let
"update"
];
}
{
path = "secret/data/swarm/services/review-classifier/acp-provider";
caps = [
"create"
"update"
];
}
]
&& grantFor viewer "secret/data/swarm/agents/x/queue" == null
&& grantFor viewer "secret/metadata/swarm/agents/x/queue" != null

View file

@ -359,10 +359,10 @@ let
}
{
# The daemon holds its store leaf, handed in by nspawn without a copy on
# disk, and nothing else from the host; the key is read per run and
# reaches only `claude`'s environment. Reading the claude path also
# evaluates the container's unfree allowance.
name = "the review classifier loads only its store leaf and carries no API key in its unit";
# disk, and nothing else from the host; a key is read per run and
# reaches only the model child's environment. Reading the claude path
# also evaluates the container's unfree allowance.
name = "the review classifier loads only its store leaf and carries no API or provider key in its unit";
ok =
let
svc =
@ -374,7 +374,9 @@ let
env.BAO_CLIENT_KEY == "/run/credentials/swarm-review-classifier.service/review-classifier-bao-key"
&& env.SWARM_REVIEW_CLASSIFIER_OIDC_CLIENT_ID == "swarm-review-classifier"
&& lib.hasSuffix "/bin/claude" env.SWARM_REVIEW_CLASSIFIER_CLAUDE
&& lib.hasSuffix "/bin/opencode" env.SWARM_REVIEW_CLASSIFIER_OPENCODE
&& !(env ? ANTHROPIC_API_KEY)
&& !(env ? ACP_PROVIDER_API_KEY)
&& lib.elem "--load-credential=review-classifier-bao-key:/var/lib/swarm-bao-pki/review-classifier-key.pem" (
withClassifier.containers.swarm-review-classifier.extraFlags
)