diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index 09359b2..9bf74a5 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -784,6 +784,15 @@ beidem. braucht sie einmal, nicht sechsmal — und die Buchhaltung darf den Marktplatz-Schlüssel nicht sehen. Gehört an die Einheit (#683), mit demselben Vererbungsgedanken wie die Rechte. + + > **Umgesetzt (#692).** Eine Einheit hält Zugangsdaten je Werkzeug und Einstellung + > (`credentials`), geschützt gespeichert wie alle Geheimnisse und in der Oberfläche nur + > als „gesetzt" sichtbar. Ein Werkzeug eines Mitglieds — auch einer Untereinheit — läuft + > mit seinen eigenen Einstellungen, ergänzt um die der nächsten Einheit nach oben, die den + > Wert hält; ein eigener, nicht leerer Wert des Agenten gewinnt. Ein Zugangsdatum gibt + > kein Werkzeug, und Nachbareinheiten bekommen nichts. Gilt für Läufe, Tool-Jobs und + > Vorprüfungen gleichermaßen. Nur Einstellungen der obersten Ebene — verschachtelte wie + > `providers.x.apiKey` bleiben am Agenten. Vorlagen (#691) tragen sie nie mit. 2. **Das Lesen dessen, was wirklich hereinkommt.** Eine Auftragsbestätigung ist ein PDF-Anhang, eine Preisliste eine Tabelle. Text aus Anhängen zu holen ist die unspektakulärste und am häufigsten gebrauchte fehlende Fähigkeit. Ein PDF vom diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 52642ee..87f0a72 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -796,9 +796,7 @@ public sealed class AppHost : IAsyncDisposable if (agent is null) return null; - var toolConfig = agent.Tools.TryGetValue(toolName, out var cfg) - ? cfg.AsReadOnly() - : new Dictionary().AsReadOnly(); + var toolConfig = Core.Organisation.OrgChart.From(instance).ToolSettingsFor(agent, toolName); return new AgentToolContext( agent.AgentId, diff --git a/src/ClawdDotNet.App/Services/OrgAdministration.cs b/src/ClawdDotNet.App/Services/OrgAdministration.cs index 39b51fb..dae37db 100644 --- a/src/ClawdDotNet.App/Services/OrgAdministration.cs +++ b/src/ClawdDotNet.App/Services/OrgAdministration.cs @@ -271,6 +271,44 @@ public sealed partial class OrgAdministration(AppHost host, ILogger logger) return AdminResult.Success(trimmed.Count == 0 ? message : message + $" Lowered with it: {string.Join(", ", trimmed)}."); } + // ─── credentials per unit (#692) ─── + + /// + /// #692: Sets or removes one credential a unit holds for its members' tool. The value is + /// written protected and never shown again — the audit names the setting, not the value. + /// + public AdminResult SetUnitCredential(string unitId, string tool, string key, string? value, string reason = "") + { + if (FindUnit(unitId) is not { } unit) + return AdminResult.Failure($"There is no unit '{unitId}'."); + tool = (tool ?? "").Trim(); + key = (key ?? "").Trim(); + if (tool.Length == 0 || key.Length == 0) + return AdminResult.Failure("Name the tool and the setting the credential is for."); + + unit.Credentials ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); + var toolKey = unit.Credentials.Keys.FirstOrDefault(k => Same(k, tool)) ?? tool; + if (!unit.Credentials.TryGetValue(toolKey, out var held)) + unit.Credentials[toolKey] = held = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var remove = string.IsNullOrEmpty(value); + if (remove) + { + held.Remove(held.Keys.FirstOrDefault(k => Same(k, key)) ?? key); + if (held.Count == 0) + unit.Credentials.Remove(toolKey); + if (unit.Credentials.Count == 0) + unit.Credentials = null; + } + else + held[held.Keys.FirstOrDefault(k => Same(k, key)) ?? key] = value!; + + Save(null, "unit_credential", new { unitId, tool = toolKey, key, set = !remove }, reason); + return AdminResult.Success(remove + ? $"'{unit.Name}' no longer holds {toolKey}.{key}." + : $"'{unit.Name}' holds {toolKey}.{key} for its members and the units below — where an agent has {toolKey} and no value of its own."); + } + // ─── votes (#702) ─── /// @@ -1004,7 +1042,7 @@ public sealed partial class OrgAdministration(AppHost host, ILogger logger) { Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId, Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals, - Inspection = u.Inspection, Votes = u.Votes, TemplateOrigin = u.TemplateOrigin + Inspection = u.Inspection, Votes = u.Votes, TemplateOrigin = u.TemplateOrigin, Credentials = u.Credentials }; private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); diff --git a/src/ClawdDotNet.Core/Config/UnitConfig.cs b/src/ClawdDotNet.Core/Config/UnitConfig.cs index e21c84e..307783b 100644 --- a/src/ClawdDotNet.Core/Config/UnitConfig.cs +++ b/src/ClawdDotNet.Core/Config/UnitConfig.cs @@ -74,6 +74,15 @@ public sealed class UnitConfig /// #691: The template the unit came from — its texts and rules are someone else's. [JsonPropertyName("templateOrigin")] public string? TemplateOrigin { get; set; } + + /// + /// #692: Credentials the unit holds for its members, by tool and setting — a purchasing + /// department with six agents needs the marketplace key once, not six times, and the + /// accounts department never gets it. Every value is a secret, stored protected; a member's + /// own non-empty value wins, and a credential grants no tool. + /// + [JsonPropertyName("credentials")] + public Dictionary>? Credentials { get; set; } } /// diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 922943b..1bcbe8b 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -1853,9 +1853,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct, DelegationChain? chain = null, string? runId = null) { - var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg) - ? cfg.AsReadOnly() - : new Dictionary().AsReadOnly(); + // #692: completed by the credentials the agent's unit holds for the tool. + var toolConfig = OrganisationProvider?.Invoke() is { } org + ? org.ToolSettingsFor(agentConfig, toolName) + : agentConfig.Tools.TryGetValue(toolName, out var cfg) + ? cfg.AsReadOnly() + : new Dictionary().AsReadOnly(); var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution"); var baseChain = chain ?? DelegationChain.Root(agentConfig.AgentId); diff --git a/src/ClawdDotNet.Core/Organisation/OrgChart.cs b/src/ClawdDotNet.Core/Organisation/OrgChart.cs index e13220d..b29d282 100644 --- a/src/ClawdDotNet.Core/Organisation/OrgChart.cs +++ b/src/ClawdDotNet.Core/Organisation/OrgChart.cs @@ -135,6 +135,43 @@ public sealed class OrgChart : null; } + /// + /// #692: The settings a tool of this agent runs with — its own, completed by the credentials + /// its unit and the units above hold for that tool (the nearest first). The agent's own + /// non-empty value wins; nothing is added for a tool the agent does not have. + /// + public IReadOnlyDictionary ToolSettingsFor(AgentConfig agent, string toolName) + { + if (!agent.Tools.TryGetValue(toolName, out var own)) + return new Dictionary().AsReadOnly(); + if (UnitOf(agent.AgentId) is not { } unit) + return own.AsReadOnly(); + + Dictionary? merged = null; + foreach (var holder in new[] { unit }.Concat(Ancestors(unit.Id))) + { + if (holder.Credentials?.FirstOrDefault(c => string.Equals(c.Key, toolName, StringComparison.OrdinalIgnoreCase)).Value is not { } held) + continue; + foreach (var (key, value) in held) + { + merged ??= new Dictionary(own, StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(value) || (merged.TryGetValue(key, out var current) && !IsEmpty(current))) + continue; + merged[key] = value; + } + } + return (merged ?? own).AsReadOnly(); + } + + private static bool IsEmpty(object? value) => value switch + { + null => true, + string s => s.Length == 0, + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.String } e => string.IsNullOrEmpty(e.GetString()), + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined } => true, + _ => false + }; + /// The unit this agent leads, if any. public UnitConfig? LedBy(string agentId) { diff --git a/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs b/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs index 7fee933..7c6bca3 100644 --- a/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs +++ b/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs @@ -99,6 +99,9 @@ public sealed record CredentialNeed( public static class OrgTemplates { + /// A credential need held by a unit, not an agent: unit:<id>. + public const string UnitPrefix = "unit:"; + /// Longest Identity or Soul text a template may bring — it goes into every prompt. public const int MaxTextLength = 50_000; @@ -121,7 +124,14 @@ public static class OrgTemplates foreach (var unit in instance.Units) { - template.Units.Add(JsonSerializer.Deserialize(JsonSerializer.Serialize(unit))!); + var copy = JsonSerializer.Deserialize(JsonSerializer.Serialize(unit))!; + // #692: what a unit holds for its members stays here — named, never carried. + foreach (var (tool, held) in copy.Credentials ?? []) + foreach (var key in held.Where(h => h.Value.Length > 0).Select(h => h.Key)) + template.Credentials.Add(new CredentialNeed(UnitPrefix + unit.Id, tool, key)); + copy.Credentials = null; + copy.TemplateOrigin = null; + template.Units.Add(copy); if (doctrineOf(unit.Id) is { Length: > 0 } doctrine) template.Doctrines[unit.Id] = doctrine; } @@ -225,6 +235,8 @@ public static class OrgTemplates problems.Add($"Unit '{u.Id}' appears twice."); if (string.IsNullOrWhiteSpace(u.Name)) problems.Add($"Unit '{u.Id}' has no name."); + if (u.Credentials?.Values.Any(h => h.Values.Any(v => v.Length > 0)) == true) + problems.Add($"Unit '{u.Id}' brings credentials — a template never carries one."); } foreach (var u in template.Units.Where(u => u.ParentId is not null && !units.Contains(u.ParentId))) problems.Add($"Unit '{u.Id}' hangs under '{u.ParentId}', which the template does not have."); diff --git a/src/ClawdDotNet.Core/Security/ConfigSecrets.cs b/src/ClawdDotNet.Core/Security/ConfigSecrets.cs index 01e48c7..64391b9 100644 --- a/src/ClawdDotNet.Core/Security/ConfigSecrets.cs +++ b/src/ClawdDotNet.Core/Security/ConfigSecrets.cs @@ -63,6 +63,13 @@ public static class ConfigSecrets foreach (var agent in config.Agents) Apply(agent, transform); + + // #692: everything a unit holds for its members is a secret, whatever its name. + foreach (var unit in config.Units) + foreach (var settings in unit.Credentials?.Values ?? Enumerable.Empty>()) + foreach (var key in settings.Keys.ToList()) + if (settings[key] is { Length: > 0 } value) + settings[key] = transform(value) ?? ""; } // ─── Agent ─── diff --git a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs index 8597742..d2e2dc5 100644 --- a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs +++ b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs @@ -159,9 +159,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher // Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace. var agentId = TaskAssignee.AgentId(task.Assignee); var config = _agents().FirstOrDefault(a => a.AgentId == agentId); - var toolConfig = config is not null && config.Tools.TryGetValue(task.ToolName, out var cfg) - ? (IReadOnlyDictionary)cfg.AsReadOnly() - : new Dictionary().AsReadOnly(); + // #692: completed by the credentials the agent's unit holds for the tool. + var toolConfig = config is null + ? new Dictionary().AsReadOnly() + : _engine.OrganisationProvider?.Invoke() is { } org + ? org.ToolSettingsFor(config, task.ToolName) + : config.Tools.TryGetValue(task.ToolName, out var cfg) + ? (IReadOnlyDictionary)cfg.AsReadOnly() + : new Dictionary().AsReadOnly(); var result = await provider.ExecuteJobAsync( task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index dbf05e5..8a3115a 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -100,6 +100,14 @@ "org.approvalsOperations": "Operationen", "org.approvalsPerDay": "Höchstens je Tag", "org.approvalsHours": "Stunden je Freigabe", + "org.credentials": "Zugangsdaten der Einheit", + "org.credentialsHint": "Zugangsdaten, die die Einheit für ihre Mitglieder und die Einheiten darunter hält — einmal gebraucht, nicht einmal je Agent, und andere Einheiten bekommen sie nie. Ein Mitglied nutzt sie nur für ein Werkzeug, das es hat, und nur, wo es keinen eigenen Wert trägt. Werte werden geschützt gespeichert und nie wieder angezeigt.", + "org.credentialsNone": "Die Einheit hält keine Zugangsdaten.", + "org.credentialSet": "gesetzt", + "org.credentialRemove": "Entfernen", + "org.credentialTool": "Werkzeug", + "org.credentialKey": "Einstellung", + "org.credentialValue": "Wert", "org.doctrine": "Doktrin", "org.doctrineHint": "Was in dieser Einheit gerade gilt — Rahmen für jede Aufgabe ihrer Mitglieder, von ihrer Leitung ohne Rückfrage gesetzt. Sie erreicht nur die Mitglieder dieser Einheit, nicht die Untereinheiten; die haben ihre eigene. Kurz halten: Sie geht in jeden Lauf. Leer entfernt sie.", "org.doctrinePlaceholder": "Jeder Lieferantenkontakt wird in der Datenbank erfasst, bevor er weitergegeben wird.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 3c8891c..d7447df 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -100,6 +100,14 @@ "org.approvalsOperations": "Operations", "org.approvalsPerDay": "Per day at most", "org.approvalsHours": "Hours per approval", + "org.credentials": "Credentials of the unit", + "org.credentialsHint": "Credentials the unit holds for its members and the units below — needed once, not once per agent, and other units never get them. A member uses them only for a tool it has, and only where it has no value of its own. Values are stored protected and never shown again.", + "org.credentialsNone": "The unit holds no credentials.", + "org.credentialSet": "set", + "org.credentialRemove": "Remove", + "org.credentialTool": "Tool", + "org.credentialKey": "Setting", + "org.credentialValue": "Value", "org.doctrine": "Doctrine", "org.doctrineHint": "What holds in this unit now — a frame for every task of its members, set by its lead without asking. It reaches this unit's members only, not the sub-units; they have their own. Short: it goes into every run. Empty removes it.", "org.doctrinePlaceholder": "Every supplier contact is entered in the database before it is passed on.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js index e008ce5..fd932ff 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js @@ -482,6 +482,7 @@ function unitPanel(unit) { cards.push(unitWorkspaceCard(unit)); cards.push(ceilingCard(unit)); + cards.push(credentialsCard(unit)); cards.push(approvalsCard(unit)); cards.push(doctrineCard(unit)); cards.push(inspectionCard(unit)); @@ -552,6 +553,43 @@ function ceilingCard(unit) { ]); } +/** + * #692: Credentials the unit holds for its members — one marketplace key for six agents, and + * the accounts department never gets it. Values are written, never shown: only which settings + * are held is listed. + */ +function credentialsCard(unit) { + const held = unit.credentialKeys ?? {}; + const tool = erstelleElement("input", { type: "text", placeholder: "DirectAPI" }); + const key = erstelleElement("input", { type: "text", placeholder: "apiKey" }); + const value = erstelleElement("input", { type: "password", autocomplete: "new-password" }); + const path = `/organisation/units/${encodeURIComponent(unit.id)}/credentials`; + + const rows = Object.entries(held).flatMap(([t1, keys]) => keys.map((k) => erstelleElement("li", { klasse: "gruppe-horizontal text-klein" }, + erstelleElement("span", { text: `${t1}.${k}` }), + erstellePlakette(t("org.credentialSet"), "erfolg"), + erstelleElement("button", { + type: "button", klasse: "flach klein", text: t("org.credentialRemove"), + onclick: () => send(path, "PUT", { tool: t1, key: k, value: null, reason: reason() }) + })))); + + return erstelleKarte(t("org.credentials"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.credentialsHint") }), + rows.length ? erstelleElement("ul", { klasse: "org-liste" }, ...rows) : erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.credentialsNone") }), + erstelleElement("div", { klasse: "formular-raster" }, + erstelleElement("label", { text: t("org.credentialTool") }), tool, + erstelleElement("label", { text: t("org.credentialKey") }), key, + erstelleElement("label", { text: t("org.credentialValue") }), value), + erstelleElement("button", { + type: "button", klasse: "klein", text: t("common.save"), + onclick: async () => { + const ok = await send(path, "PUT", { tool: tool.value.trim(), key: key.value.trim(), value: value.value, reason: reason() }); + if (ok) value.value = ""; + } + }) + ]); +} + /** * #687: What the unit's lead may approve instead of the human. One operation per line — * "Tool.operation", or "Tool" for all of it. Changes to agents are refused by the server. diff --git a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs index 9dc17dc..d6d6cd0 100644 --- a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs +++ b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs @@ -24,7 +24,9 @@ public sealed record UnitDto( // #693: the lead's spot checks; null: none. UnitInspectionDto? Inspection = null, // #702: what the unit puts to a blind vote; null: nothing. - UnitVotesDto? Votes = null); + UnitVotesDto? Votes = null, + // #692: the credentials the unit holds, by tool — names only, never values. + IReadOnlyDictionary>? CredentialKeys = null); /// #698: One version of a unit's doctrine. public sealed record DoctrineDto(int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason); @@ -42,6 +44,9 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens public sealed record FindingDto(string Kind, string Text); +/// #692: One credential a unit holds; an empty value removes it. +public sealed record UnitCredentialInputDto(string Tool, string Key, string? Value, string Reason = ""); + /// #691: A template to preview or import, with what the human changes on the way in. public sealed record TemplateImportDto( System.Text.Json.JsonElement Template, diff --git a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs index eacde97..e060a8a 100644 --- a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs @@ -30,7 +30,10 @@ internal static class OrganisationEndpoints chart.PathOf(u.Id), chart.Depth(u.Id), u.Approvals is { } ap ? new UnitApprovalsDto(ap.Operations, ap.PerDay, ap.Hours) : null, u.Inspection is { } ins ? new UnitInspectionDto(ins.Rate, ins.DenseRate, ins.NewDays, ins.PerDay) : null, - u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null)) + u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null, + u.Credentials is { Count: > 0 } held + ? held.ToDictionary(h => h.Key, h => (IReadOnlyList)h.Value.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList()) + : null)) .ToList(); var agents = host.Instance.Agents @@ -105,6 +108,10 @@ internal static class OrganisationEndpoints return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version}).")); }); + // #692: Credentials a unit holds for its members — written here, never read back. + org.MapPut("/units/{unitId}/credentials", (AppHost host, string unitId, UnitCredentialInputDto input) => + host.Organisation.SetUnitCredential(unitId, input.Tool, input.Key, input.Value, input.Reason).ToResponse()); + // #691: The organisation as a template file — exported without credentials, imported only after a preview and a confirmation. org.MapGet("/template", (AppHost host, string? name, string? description) => Results.Text(System.Text.Json.JsonSerializer.Serialize( diff --git a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs index 89d594f..ae95272 100644 --- a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs +++ b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs @@ -330,6 +330,22 @@ public sealed class OrgAdministrationTests : IDisposable Unit("marketing").Votes.ShouldBeNull(); } + // ═══════════════════ credentials per unit (#692) ═══════════════════ + + [Fact] + public void A_unit_holds_a_credential_until_it_is_removed() + { + BuildMarketing(); + + Org.SetUnitCredential("marketing", "", "apiKey", "x").Ok.ShouldBeFalse(); + Org.SetUnitCredential("marketing", "WebFetch", "token", "t-1", "shared key").Ok.ShouldBeTrue(); + Unit("marketing").Credentials!["WebFetch"]["token"].ShouldBe("t-1"); + Org.Current().ToolSettingsFor(Find("ads"), "WebFetch")["token"].ShouldBe("t-1", "ads sits in amazon, below marketing"); + + Org.SetUnitCredential("marketing", "webfetch", "TOKEN", null).Ok.ShouldBeTrue(); + Unit("marketing").Credentials.ShouldBeNull(); + } + // ═══════════════════ templates (#691) ═══════════════════ private static Core.Organisation.OrgTemplate Shop() => new() diff --git a/tests/ClawdDotNet.Core.Tests/Organisation/UnitCredentialsTests.cs b/tests/ClawdDotNet.Core.Tests/Organisation/UnitCredentialsTests.cs new file mode 100644 index 0000000..c9362eb --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Organisation/UnitCredentialsTests.cs @@ -0,0 +1,75 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Organisation; + +/// +/// #692: A unit holds credentials for its members — needed once, not six times, and the +/// accounts department never gets the marketplace key. +/// +public sealed class UnitCredentialsTests +{ + /// shop (holds DirectAPI.apiKey, Mail.password) › buying (holds DirectAPI.apiKey); accounts beside it. + private static OrgChart Org(params AgentConfig[] agents) => new( + [ + new UnitConfig + { + Id = "shop", Name = "Shop", + Credentials = new() { ["DirectAPI"] = new() { ["apiKey"] = "shop-key" }, ["Mail"] = new() { ["password"] = "shop-mail" } } + }, + new UnitConfig { Id = "buying", Name = "Buying", ParentId = "shop", Credentials = new() { ["directapi"] = new() { ["apiKey"] = "buying-key" } } }, + new UnitConfig { Id = "accounts", Name = "Accounts" } + ], + agents); + + private static AgentConfig Agent(string id, string unit, params (string Tool, Dictionary Settings)[] tools) + => new() { AgentId = id, UnitId = unit, Tools = tools.ToDictionary(t => t.Tool, t => t.Settings) }; + + [Fact] + public void A_member_gets_the_nearest_units_credential_for_a_tool_it_has() + { + var buyer = Agent("buyer", "buying", ("DirectAPI", new() { ["baseUrl"] = "https://api.example" }), ("Mail", new())); + var org = Org(buyer); + + org.ToolSettingsFor(buyer, "DirectAPI")["apiKey"].ShouldBe("buying-key", "the nearest unit first"); + org.ToolSettingsFor(buyer, "DirectAPI")["baseUrl"].ShouldBe("https://api.example"); + org.ToolSettingsFor(buyer, "Mail")["password"].ShouldBe("shop-mail", "from the unit above"); + } + + [Fact] + public void Its_own_value_wins_and_an_empty_one_is_filled() + { + var own = Agent("own", "shop", ("DirectAPI", new() { ["apiKey"] = "own-key" }), ("Mail", new() { ["password"] = "" })); + var org = Org(own); + + org.ToolSettingsFor(own, "DirectAPI")["apiKey"].ShouldBe("own-key"); + org.ToolSettingsFor(own, "Mail")["password"].ShouldBe("shop-mail"); + } + + [Fact] + public void No_credential_reaches_another_unit_or_a_tool_the_agent_lacks() + { + var clerk = Agent("clerk", "accounts", ("DirectAPI", new())); + var buyer = Agent("buyer", "buying", ("FileRW", new())); + var org = Org(clerk, buyer); + + org.ToolSettingsFor(clerk, "DirectAPI").ContainsKey("apiKey").ShouldBeFalse(); + org.ToolSettingsFor(buyer, "DirectAPI").ShouldBeEmpty("a credential grants no tool"); + } + + [Fact] + public void A_template_never_carries_what_a_unit_holds() + { + var instance = new InstanceConfig(); + instance.Units.Add(new UnitConfig { Id = "shop", Name = "Shop", Credentials = new() { ["DirectAPI"] = new() { ["apiKey"] = "shop-key" } } }); + + var template = OrgTemplates.Export(instance, "Shop", "", _ => null, DateTime.UtcNow); + + template.Units[0].Credentials.ShouldBeNull(); + template.Credentials.ShouldContain(new CredentialNeed("unit:shop", "DirectAPI", "apiKey")); + + template.Units[0].Credentials = new() { ["DirectAPI"] = new() { ["apiKey"] = "smuggled" } }; + OrgTemplates.Problems(template).ShouldContain(p => p.Contains("brings credentials")); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Security/SecretProtectorTests.cs b/tests/ClawdDotNet.Core.Tests/Security/SecretProtectorTests.cs index 21e90d3..62914f5 100644 --- a/tests/ClawdDotNet.Core.Tests/Security/SecretProtectorTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Security/SecretProtectorTests.cs @@ -281,6 +281,24 @@ public sealed class ConfigSecretsTests : IDisposable agent.Tools["Mail"]["password"]!.ToString().ShouldNotBeNull().ShouldNotContain("mail-passwort"); } + [Fact] + public void Credentials_a_unit_holds_are_protected_whatever_their_name() + { + // #692: everything a unit holds for its members is a secret. + var instance = new InstanceConfig(); + instance.Units.Add(new UnitConfig + { + Id = "purchasing", Name = "Purchasing", + Credentials = new() { ["DirectAPI"] = new() { ["marketplaceKey"] = "mk-123" } } + }); + + ConfigSecrets.Protect(instance); + instance.Units[0].Credentials!["DirectAPI"]["marketplaceKey"].ShouldNotContain("mk-123"); + + ConfigSecrets.Unprotect(instance); + instance.Units[0].Credentials!["DirectAPI"]["marketplaceKey"].ShouldBe("mk-123"); + } + [Fact] public void Nicht_geheime_Felder_bleiben_lesbar() {