Skip to content
Merged
41 changes: 32 additions & 9 deletions agent-sidecar/src/spec-helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,40 @@ function grantedOf(grantedTools) {
return Array.isArray(grantedTools) ? grantedTools.filter((t) => typeof t === "string") : [];
}

/**
* The tool a permission RULE names — `"Bash"` for `"Bash(nxc reply:*)"`, the string itself for a
* plain tool name (nxf 6j6v.ewbj).
*
* The SDK has two lists that look alike and take different things: `tools` (the base toolset) takes
* tool NAMES, `allowedTools` (the auto-approval list) takes RULES, and a rule may scope a tool to
* some of its uses. A scoped rule put into `tools` names no tool at all, so the base set would lack
* the very tool the rule approves.
*
* @param {string} rule - a tool name or a permission rule.
* @returns {string} the tool it names.
*/
export function toolOfRule(rule) {
const open = rule.indexOf("(");
return open > 0 && rule.endsWith(")") ? rule.slice(0, open) : rule;
}

/**
* Decide what main.mjs should assign to the SDK's `options.tools` (the base toolset), given
* `spec.tools` and `spec.grantedTools` as parsed from the role spec JSON.
*
* Mirrors the exact guard main.mjs used inline: `if (Array.isArray(spec.tools)) options.tools =
* spec.tools;`. Only an actual array — including `[]` — is a genuine declaration of intent:
* Only an actual array — including `[]` — is a genuine declaration of intent:
* - `tools` absent (`undefined`) or `null` -> returns `undefined`, meaning "leave
* `options.tools` unset", so the SDK's default full toolset applies. The grant is NOT folded
* in here, and that is the point: the default set already contains everything the grant names,
* and turning "unset" into a list would SHRINK an undeclared role's toolset to exactly the
* grant — which is the regression this whole split exists to avoid.
* - `tools: []` -> the grant, and nothing else. An explicit zero-tools role that is put under an
* obligation gets exactly the means for that obligation and no more — the same narrow scoping
* nxf 6j6v.04es gave the `summarize` synthesizer by hand, now derived.
* - `tools: [...]` -> that array plus anything granted that is not already in it, order
* preserved (declaration first).
* - `tools: []` -> the TOOL the grant names, and nothing else. Since nxf 6j6v.ewbj the engine
* grants such a role the rule `Bash(nxc reply:*)`, so this is `["Bash"]`, and what may run
* inside it without approval is decided by {@link resolveAllowedTools} — the rule, not the
* whole shell.
* - `tools: [...]` -> the tools that array names plus any granted tool not already in it, order
* preserved (declaration first). A declared rule (`Bash(nxc reply:*)`) contributes its tool, so
* a narrowly declared role has a base set the rule can act in.
*
* @param {unknown} tools - `spec.tools` as parsed from the role spec (may be undefined, null,
* an array, or any other JSON value).
Expand All @@ -46,8 +64,10 @@ function grantedOf(grantedTools) {
*/
export function resolveToolsOption(tools, grantedTools) {
if (!Array.isArray(tools)) return undefined;
const granted = grantedOf(grantedTools);
return [...tools, ...granted.filter((t) => !tools.includes(t))];
const named = [...tools, ...grantedOf(grantedTools)].map((t) =>
typeof t === "string" ? toolOfRule(t) : t,
);
return named.filter((t, i) => named.indexOf(t) === i);
}

/**
Expand All @@ -60,6 +80,9 @@ export function resolveToolsOption(tools, grantedTools) {
* requires approval". Union of the declaration and the grant, so the obligation is runnable
* whatever the declaration says, and a role that was granted nothing reads exactly as before.
*
* Rules pass through as RULES (nxf 6j6v.ewbj): a granted `Bash(nxc reply:*)` approves that command
* and no other, which is what keeps a `tools: []` role from holding an auto-approved shell.
*
* @param {unknown} tools - `spec.tools` as parsed from the role spec.
* @param {unknown} [grantedTools] - `spec.grantedTools` as parsed from the role spec.
* @returns {string[]} the value for `allowedTools`.
Expand Down
41 changes: 41 additions & 0 deletions agent-sidecar/test/spec-helpers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
resolveToolsOption,
resolveAllowedTools,
toolOfRule,
resolveModelOption,
isSubcommandNotImplementedYet,
isFlagNotImplementedYet,
Expand Down Expand Up @@ -96,6 +97,39 @@ test("resolveToolsOption", async (t) => {
await t.test("an older engine sends no grant at all", () => {
assert.deepEqual(resolveToolsOption(["Read"], undefined), ["Read"]);
});

// ---- a scoped grant (nxf 6j6v.ewbj) ----------------------------------------------------------

await t.test("a zero-tools role granted the reply rule gets the shell TOOL in its base set", () => {
// The base set takes tool names; the rule goes to the approval list (below). Without the tool
// in the base set the rule approves a tool the session does not have.
assert.deepEqual(resolveToolsOption([], ["Bash(nxc reply:*)"]), ["Bash"]);
});

await t.test("a declared rule contributes its tool, once", () => {
assert.deepEqual(resolveToolsOption(["Bash(nxc reply:*)"], undefined), ["Bash"]);
assert.deepEqual(resolveToolsOption(["Read", "Bash(nxc reply:*)"], ["Bash(nxc reply:*)"]), [
"Read",
"Bash",
]);
assert.deepEqual(resolveToolsOption(["Bash", "Read"], ["Bash(nxc reply:*)"]), ["Bash", "Read"]);
});
});

test("toolOfRule", async (t) => {
await t.test("a plain tool name is itself", () => {
assert.equal(toolOfRule("Bash"), "Bash");
assert.equal(toolOfRule("Read"), "Read");
});

await t.test("a scoped rule names the tool before the parenthesis", () => {
assert.equal(toolOfRule("Bash(nxc reply:*)"), "Bash");
});

await t.test("something that only looks scoped is left alone", () => {
assert.equal(toolOfRule("(nxc)"), "(nxc)");
assert.equal(toolOfRule("Bash(unclosed"), "Bash(unclosed");
});
});

test("resolveAllowedTools", async (t) => {
Expand Down Expand Up @@ -124,6 +158,13 @@ test("resolveAllowedTools", async (t) => {
assert.deepEqual(resolveAllowedTools(["Bash"], ["Bash"]), ["Bash"]);
});

await t.test("a scoped grant is approved as the RULE, never widened to the tool", () => {
// THE hole nxf 6j6v.ewbj closes: the grant used to be the bare `Bash`, and a `tools: []` role
// that owed a reply held an auto-approved shell.
assert.deepEqual(resolveAllowedTools([], ["Bash(nxc reply:*)"]), ["Bash(nxc reply:*)"]);
assert.ok(!resolveAllowedTools([], ["Bash(nxc reply:*)"]).includes("Bash"));
});

await t.test("a malformed grant cannot inject a non-string into the list", () => {
assert.deepEqual(resolveAllowedTools(["Read"], "Bash"), ["Read"]);
assert.deepEqual(resolveAllowedTools(["Read"], [42, "Bash"]), ["Read", "Bash"]);
Expand Down
25 changes: 25 additions & 0 deletions changes/4gp2-held-answers-reach-their-caller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
type: fixed
facade: changed
---
[en]
An answer that reaches a caller while it is still finishing its turn is now delivered. On the
command line the background service was handed the delivery as a channel deadline on a session id
("no such thread"), so a held answer was never handed over; and the caller's sidecar, seeing its
sub-round already answered, reminded it and then posted a substitute reply in its name. A sub-round
whose answer is held for its commissioner now counts as still in flight (`waiting_on_sub_round` in
`nxc status --json` and on the facade). Also fixed: a persona that declares no `tools:` and is woken
with an answer it still has to pass on is granted its `nxc reply` (the wake carries the obligation it
still has), and a session spawned by a development build resolves that build's service home, not the
installed one.
[de]
Eine Antwort, die einen Aufrufer erreicht, während er seinen Zug noch beendet, wird jetzt
zugestellt. Auf der Kommandozeile bekam der Hintergrunddienst die Zustellung als Kanal-Frist auf
eine Sitzungs-ID („no such thread“), eine zurückgehaltene Antwort wurde also nie übergeben; und die
Sidecar des Aufrufers sah seine Unterrunde schon beantwortet, erinnerte ihn und postete dann eine
Ersatzantwort in seinem Namen. Eine Unterrunde, deren Antwort für ihren Auftraggeber zurückgehalten
wird, zählt jetzt weiter als unterwegs (`waiting_on_sub_round` in `nxc status --json` und in der
Fassade). Außerdem behoben: Eine Persona ohne `tools:`, die mit einer Antwort geweckt wird, die sie
noch weitergeben muss, darf ihr `nxc reply` ausführen (die Weckung trägt die Pflicht mit, die sie
noch hat), und eine Sitzung, die ein Entwicklungs-Build startet, nutzt dessen Dienst-Verzeichnis,
nicht das installierte.
41 changes: 41 additions & 0 deletions changes/70dy-pm-talks-to-pm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
type: added
facade: breaking
---
[en]
A persona can commission a persona of another workspace on the same machine, and the answer comes
back. `nxc send --to <owner>/<repo>/<persona>` records the commission in the caller's own workspace
and hands over that one thread; the other workspace checks access, starts its persona in its own
working copy, and the answer — or a question, which always goes to whoever commissioned — wakes the
caller. Only the border thread crosses. Access is the receiver's: `addressable.external` admits a
role from trusted workspaces (`"*/pm"`, `nxsflow/manufakt-io/pm`), and without it nothing comes in.
New: `nxs name` (a workspace's stored `<owner>/<repo>` name), `nxs sync trust add --workspace
<owner>/<repo>` (trust a neighbouring workspace by name; needed on both sides), and `nxc status`
showing a border thread in both workspaces with the other side and its state (submitted, working,
input-required, completed, rejected, canceled). Two hours without a sign of life cancel it;
`nxc withdraw` takes it back on both sides. An engine older than this release cannot load a persona
file that carries `external:` — add it only once every reader of the repository is on this version.
For embedding apps: `EngineConfig::peers` and `Ctx::peers` (the host's way to reach other
workspaces; `None` keeps everything as before), `Engine::handover`, `Addressable::OnlyWithExternal`,
`Coordinator::Border`, and new `border` fields on `Refs`, `SendToReceipt`, `ReplyReceipt` and
`StatusThread` — a struct literal of `EngineConfig`, `Ctx`, `Adapter` or `Refs` without
`..Default::default()` needs the new field.
[de]
Eine Persona kann eine Persona eines anderen Arbeitsbereichs auf derselben Maschine beauftragen,
und die Antwort kommt zurück. `nxc send --to <besitzer>/<repo>/<persona>` legt den Auftrag im
eigenen Arbeitsbereich ab und übergibt genau diesen einen Faden; der andere Arbeitsbereich prüft den
Zugang, startet seine Persona in seiner eigenen Arbeitskopie, und die Antwort — oder eine
Rückfrage, die immer an den Auftraggeber geht — weckt den Aufrufer. Nur der Grenzfaden geht hinüber.
Den Zugang bestimmt der Empfänger: `addressable.external` lässt eine Rolle aus vertrauten
Arbeitsbereichen zu (`"*/pm"`, `nxsflow/manufakt-io/pm`), ohne das Feld kommt nichts herein. Neu:
`nxs name` (der gespeicherte Name `<besitzer>/<repo>` eines Arbeitsbereichs), `nxs sync trust add
--workspace <besitzer>/<repo>` (einem benachbarten Arbeitsbereich beim Namen vertrauen; auf beiden
Seiten nötig) und `nxc status`, das einen Grenzfaden in beiden Arbeitsbereichen mit der Gegenseite
und seinem Zustand zeigt (submitted, working, input-required, completed, rejected, canceled). Zwei
Stunden ohne Lebenszeichen brechen ihn ab; `nxc withdraw` zieht ihn auf beiden Seiten zurück. Eine
Engine, die älter ist als dieses Release, kann eine Persona-Datei mit `external:` nicht laden —
setzen Sie das Feld erst, wenn alle Leser des Repositorys diesen Stand haben. Für einbettende Apps:
`EngineConfig::peers` und `Ctx::peers` (der Weg des Hosts zu anderen Arbeitsbereichen; `None` lässt
alles wie bisher), `Engine::handover`, `Addressable::OnlyWithExternal`, `Coordinator::Border` und neue
Felder `border` an `Refs`, `SendToReceipt`, `ReplyReceipt` und `StatusThread` — ein Struct-Literal
von `EngineConfig`, `Ctx`, `Adapter` oder `Refs` ohne `..Default::default()` braucht das neue Feld.
26 changes: 26 additions & 0 deletions changes/ewbj-tools-empty-is-a-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
type: fixed
facade: changed
---
[en]
Safety fix: a persona declared with `tools: []` can no longer run arbitrary shell commands when it
owes a reply. Until now the engine granted every such session the bare, auto-approved `Bash` so that
it could answer, which gave a persona declared to have no tools a full shell (`gh pr merge`,
`git push --force`, a release). It now grants `Bash(nxc reply:*)`: the reply runs, every other
command needs an approval nobody is there to give and is refused. A persona whose `tools:` is absent,
or lists `Bash`, behaves as before; one that declares `Bash(nxc reply:*)` itself now stays that
narrow. For an embedding app's own worker: `RoleSpec::granted_tools` (`grantedTools` in the sidecar
spec) can now carry a scoped permission rule rather than a tool name — put the tool it names into the
SDK's `tools` and the rule itself into `allowedTools`, as the bundled sidecar does.
[de]
Sicherheitskorrektur: Eine Persona mit `tools: []` kann keine beliebigen Shell-Befehle mehr
ausführen, wenn sie eine Antwort schuldet. Bisher gewährte die Engine jeder solchen Sitzung das
nackte, automatisch freigegebene `Bash`, damit sie antworten konnte — eine Persona ohne Werkzeuge
hatte damit eine volle Shell (`gh pr merge`, `git push --force`, ein Release). Jetzt gewährt sie
`Bash(nxc reply:*)`: Die Antwort läuft, jeder andere Befehl braucht eine Freigabe, die niemand
erteilen kann, und wird abgewiesen. Eine Persona ohne `tools:` oder mit `Bash` in der Liste verhält
sich wie bisher; eine, die selbst `Bash(nxc reply:*)` deklariert, bleibt jetzt so eng. Für den
eigenen Worker einer einbettenden App: `RoleSpec::granted_tools` (`grantedTools` in der
Sidecar-Spezifikation) kann jetzt eine eingeschränkte Berechtigungsregel statt eines Werkzeugnamens
tragen — das genannte Werkzeug gehört in `tools` des SDK, die Regel selbst in `allowedTools`, wie es
die mitgelieferte Sidecar tut.
16 changes: 16 additions & 0 deletions crates/chat/docs/guide/de/limits-and-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ ist eine Persona, deren `system_prompt` das sagt und deren Deklaration ihr keine
Handeln gibt — `tools: []` ist ein echter, eigener Zustand und nicht dasselbe wie den Schlüssel
wegzulassen.

Was `tools: []` genau verhindert und was nicht. Eine so deklarierte Persona schuldet ihre Antwort
trotzdem; wird sie beauftragt, gewährt ihr die Engine deshalb den einen Befehl, der diese Antwort
ist: `Bash(nxc reply:*)`. Sie kann `nxc reply` ausführen — die Heredoc-Form und die einzeilige Form
— und ohne Freigabe nichts anderes: kein `nxc send`, kein `git push`, kein `gh`, kein Schreiben einer
Datei, keine Kette, Pipe, Umleitung und kein `$(…)` um ihre Antwort herum. Weil niemand da ist, der
etwas freigibt, wird ein Befehl, der eine Freigabe braucht, abgewiesen. Was die Agenten-Laufzeit
selbst im Arbeitsverzeichnis der Persona als nur lesend zählt (`ls`, `cat` einer Datei dort,
`git status`), läuft weiterhin; diese Erlaubnis gehört der Laufzeit, nicht der Deklaration. Eine
Persona ohne `tools:` ist ein anderer Zustand: Sie läuft mit dem vollen Standardwerkzeugsatz der
Laufzeit und einer freigegebenen Shell, und nichts davon schränkt sie ein. Eine Persona mit
`permissions: bypassPermissions` gibt alles selbst frei, gleich, was in `tools:` steht.

Eine Persona, die Aufrufer aus anderen Arbeitsbereichen zulässt (`addressable.external`, `nxc guide
personas`), bekommt Text, den ein anderer Arbeitsbereich geschrieben hat. Was dieser Text sie tun
lassen kann, begrenzt ihre Deklaration — genau deshalb muss `tools: []` bedeuten, was es sagt.

## Eskalation: das deklarierte „ich brauche Hilfe oder eine Entscheidung"

Ein Agent darf mit `reply` genau zwei Dinge sagen: *ich bin fertig* und *allein komme ich nicht ans
Expand Down
Loading
Loading