diff --git a/openspec/changes/type-system-stiffening/.openspec.yaml b/openspec/changes/type-system-stiffening/.openspec.yaml new file mode 100644 index 000000000..9f7086699 --- /dev/null +++ b/openspec/changes/type-system-stiffening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/type-system-stiffening/design.md b/openspec/changes/type-system-stiffening/design.md new file mode 100644 index 000000000..333685be8 --- /dev/null +++ b/openspec/changes/type-system-stiffening/design.md @@ -0,0 +1,213 @@ +## Context + +Trust context in Netclaw flows from an inbound channel adapter through the +session pipeline into every tool-access and memory-scoping decision. The data +path is: + +``` +ChannelInput (adapter) + → MessageSourceFactory.Create + → MessageSource (per-turn trust snapshot) + → TrustContextDeriver.Derive → EffectiveTrustContext + → ToolAccessPolicy / memory gates / background jobs / sub-agents +``` + +Today, `ChannelInput`'s four trust fields (`Audience`, `Boundary`, `Principal`, +`Provenance`) are nullable with no default. `MessageSourceFactory.Create` +materialises a value with `input.X ?? options.DefaultX`, where the +`SessionPipelineOptions.DefaultX` properties carry permissive sentinels +(`TrustAudience.Public`, `SourceProvenance.StrictDefault()`). `MessageSource`'s +own trust fields carry the same sentinels as property-init defaults. The +compiler therefore cannot distinguish an adapter that deliberately omits trust +context from one that simply forgot — and a forgotten field silently produces +the most permissive trust label. PR #993 was exactly this failure: a +Personal-audience Slack DM lost its audience and was gated as Public. + +Three persisted record types (`BackgroundJobDefinition`, `ActiveJobInfo`, +`ReminderDefinition`) carry the same sentinel-default shape on disk, with an +*elevated* default (`TrustAudience.Personal`) — a forgotten field there is a +silent privilege escalation, not just a degradation. + +Constraints: +- The constitution forbids silent fallbacks, especially on security paths. +- No on-disk or on-wire format change is permitted in this change. Legacy + documents must remain loadable through an explicit, loud path. +- Actor message types crossing the wire are protobuf-mapped; their record + *shape* cannot change, but the trust fields involved here are not + wire-serialized as nullable in a way this change alters. + +## Goals / Non-Goals + +**Goals:** + +- Make the four trust fields (`Audience`, `Boundary`, `Principal`, + `Provenance`) impossible to omit at any actor boundary — enforced by the + compiler, not by review. +- Delete the `SessionPipelineOptions.DefaultX` escape hatch and the + `MessageSourceFactory` fallback arms so there is no code path that + synthesizes trust context. +- Convert elevated-fallback escalation sites to explicit `throw`. +- Type `ToolExecutionContext.Audience` and `RunSubAgent.Audience` as parsed + `TrustAudience`, moving parse failure to construction time. +- Make persisted trust fields `required` while keeping legacy JSON documents + loadable through a loud, operator-visible path (no on-disk migration). + +**Non-Goals:** + +- The broad value-object adoption pass (wrapping `SenderId`, `TurnId`, + `ToolCallId`, etc.) — tracked separately. +- The Pass 5/6 primary-constructor and `required`-keyword cleanups on + non-security records — cosmetic, separate change. +- Any change to wire or on-disk serialization format. +- Changing the *values* of fail-closed conservative fallbacks in + `TrustContextDeriver` (`UntrustedExternal`, `StrictDefault()` when source is + genuinely absent) — those are correct. + +## Decisions + +### D1 — `ChannelInput` / `MessageSource`: `required` properties, not primary constructors + +Both records have ~15 properties. A primary constructor with 15 positional +parameters is unreadable. Use `required` on the four trust fields and leave the +rest as property-init. `required` gives the same compile-time enforcement +(every object initializer must set the field) without the positional-argument +noise. *Alternative considered*: primary constructor — rejected on readability +for types this wide. + +### D2 — `SourceProvenance`: 2-parameter primary constructor + +`SourceProvenance` has two trust fields (`TransportAuthenticity`, +`PayloadTaint`) and two optional metadata fields (`SourceScope`, `SourceKind`). +Callsite inspection confirms every construction site sets both trust fields +explicitly and most set `SourceKind`; `SourceScope` is frequently omitted. +A 2-parameter primary constructor forces the trust fields and keeps the +metadata as optional `init`: + +```csharp +public sealed record SourceProvenance( + TransportAuthenticity TransportAuthenticity, + PayloadTaint PayloadTaint) : IWireType +{ + public string? SourceScope { get; init; } + public string? SourceKind { get; init; } +} +``` + +The `StrictDefault()` factory is removed; the one genuinely conservative +fallback (in `TrustContextDeriver` when `source` is null) constructs +`new SourceProvenance(TransportAuthenticity.Unverified, PayloadTaint.Public)` +explicitly so the conservatism is visible at the callsite. + +### D3 — Delete `SessionPipelineOptions.DefaultX` rather than make it `required` + +The four `Default*` properties exist only to feed the `MessageSourceFactory` +fallback arms. Making them `required` would preserve the escape hatch. Deleting +them forces each of the five `BuildOptions()` consumers (Slack, Discord, +SignalR, Webhook, Reminder binding actors) to stamp explicit trust context onto +the `ChannelInput` they construct. The per-adapter values that previously lived +in `DefaultX` move to the adapter as named local constants or computed values. + +### D4 — Elevated-fallback sites become `throw`, not fail-closed defaults + +`SessionToolExecutionPipeline` (background-job submission) and `SubAgentActor` +both default a missing audience to `TrustAudience.Personal` — an escalation. +After D1/D3 the only way `source` is null at these sites is a programming +error. They become `throw new InvalidOperationException(...)`. This is not a +fail-closed default (which would be `Public`); it is a loud assertion that the +invariant held by D1 was violated. `NullPromptInjectionDetector` substitution +becomes `throw` for the same reason — the real detector is a DI singleton, so +null means broken wiring. + +### D5 — `ToolExecutionContext.Audience`: `string?` → `TrustAudience?` + +`ToolExecutionContext` is a mutable `class` (not a record); tools mutate it. +Changing `Audience` from wire-string `string?` to `TrustAudience?` moves the +parse to the point where the context is built (`SessionToolExecutionPipeline`, +`SubAgentActor`), so an unparseable value fails there rather than silently +degrading to `Public` inside `ToolAccessPolicy`. `Boundary` stays `string?` — +it is a free-form partition label with no parse step. `SecurityPolicyDefaults.ParseAudienceOrPublic` +and `ResolveAudienceWithFallback` become dead code on the read path and are +deleted. `RunSubAgent.Audience` changes correspondingly. + +### D6 — Persisted records: reject legacy documents at load, no backfill + +`BackgroundJobDefinition`, `ActiveJobInfo`, `ReminderDefinition` make their +trust fields `required` — this is the type-system win: every in-process +construction is compiler-enforced. + +`ActiveJobInfo` is protobuf-serialized; proto3 has no notion of an absent +field, and a legacy record deserializes its audience to enum `0`, which is +`TrustAudience.Public` (fail-closed). So `ActiveJobInfo` needs no special +handling — `required` is purely a compile-time change there. + +`BackgroundJobDefinition` and `ReminderDefinition` are JSON +(`BackgroundJobDefinitionStore`, `ReminderDefinitionStore`). A legacy document +that omits the trust keys (or carries an explicit `null`) is **rejected** at +load — not coerced to a substitute audience. On the deserialization path each +store parses the document into a `JsonObject` and checks for the trust keys via +a shared helper (`LegacyTrustFieldGuard.MissingTrustFields`); if any are +absent, the store logs an **error** naming the file and the missing fields, +and excludes the document — `Get` returns null, `List` skips it. The reminder +store returns the rejection without deleting the file (it is operator-authored +data, distinct from corrupt JSON, so the operator can repair or remove it). + +There is no backfill. A job or reminder with no persisted trust context cannot +be run safely: its trust tier is unknown, and these features are typically +disabled at the most-restrictive audience — so a `Public` substitute would +fabricate a nonsensical state (a feature that is gated off), and a `Personal` +substitute would silently escalate privilege. *Alternatives considered*: +(a) backfill `Public` — rejected, it produces a job/reminder in a +contradictory state (running at an audience where the feature is disabled); +(b) backfill `Personal` — rejected, an elevated default is precisely the +anti-pattern this change exists to remove. Rejecting the document is the only +choice that neither escalates nor fabricates. Pre-#994 a legacy reminder +already failed (it threw at execution for a missing audience); rejecting it at +load is the same outcome, surfaced earlier and without a per-fire crash. + +### D7 — Sequencing as four independent PRs + +PR-A (`ChannelInput`/`MessageSource`/`SourceProvenance`/`MessageSourceFactory`/ +`SessionPipelineOptions` + adapters), PR-B (elevated-fallback throws), PR-C +(`ToolExecutionContext`/`RunSubAgent` typing), PR-D (persisted records: +`required` trust fields + legacy-document rejection). PR-A is a prerequisite +for PR-B (it establishes the non-null `source` invariant). PR-C and PR-D are +independent of A/B. Each is independently reviewable and compiler-verified. + +## Risks / Trade-offs + +- **Large mechanical diff across channel adapters** → The compiler drives the + refactor: every missing `required` field is a build error pointing at the + exact callsite. Fix per error, no guesswork. Tests adapt the same way. +- **Legacy persisted documents stop loading** → A pre-#994 job/reminder file + with no trust fields is rejected at load and no longer runs. Mitigation: the + rejection is logged at error level naming the file and the missing fields, + and the file is preserved so the operator can repair (add the fields) or + remove it. For reminders this matches the pre-#994 outcome (a missing + audience already failed at execution); the failure simply moves earlier and + loses the per-fire crash loop. +- **`throw` on a missing turn source could crash a session if the invariant is + wrong** → The invariant (every tool execution and background-job submission + has a turn source) is established by D1/D3 making `MessageSource` mandatory. + If a path genuinely has no source, the `throw` surfaces it in testing rather + than letting it escalate silently in production. Acceptable: loud failure in + a test beats silent escalation in prod. +- **`ToolExecutionContext.Audience` retype touches every tool** → Blast radius + is bounded to tools that read `context.Audience` (enumerated in the proposal + impact section). Mechanical; compiler-verified. + +## Migration Plan + +1. PR-A → PR-B → PR-C → PR-D land in order; each is a normal `dev`-branch PR + with green build + tests. +2. No deployment-time migration. On first daemon start after PR-D, any legacy + persisted job/reminder document missing trust fields is rejected at load + with an error log; the file is preserved. An operator who wants such a job + or reminder back adds the `audience`/`boundary` fields or recreates it. + Regression tests exercise the legacy-document rejection for both stores. +3. **Rollback**: each PR is independently revertable. PR-D's rejection is + confined to the two stores' deserialization paths; reverting it restores + the prior behavior. No on-disk data is rewritten or deleted by this change. + +## Open Questions + +- None. diff --git a/openspec/changes/type-system-stiffening/proposal.md b/openspec/changes/type-system-stiffening/proposal.md new file mode 100644 index 000000000..063c33137 --- /dev/null +++ b/openspec/changes/type-system-stiffening/proposal.md @@ -0,0 +1,115 @@ +## Why + +PR #993 fixed a production bug where a Personal-audience Slack DM was silently +downgraded to Public, denying the operator's `shell_execute`. The root cause was +not logic — it was type shape: `ChannelInput.Audience` is `TrustAudience?` +(nullable, optional) and `MessageSourceFactory.Create` invents a default via +`input.Audience ?? options.DefaultAudience`. The compiler could not tell a +forgetful adapter from a deliberate caller. The constitution's "No silent +fallbacks" rule names this anti-pattern, but trust-bearing records across the +codebase still carry security-relevant fields as nullable-with-fallback or +sentinel-default rather than `required`. The audience field bit us first; it is +unlikely to be the last. This change makes the type system a primary correctness +gate so the next PR #993 cannot compile. + +## What Changes + +- **BREAKING** (internal API) — `ChannelInput`'s trust fields (`Audience`, + `Boundary`, `Principal`, `Provenance`) become `required` and non-nullable. + Every inbound channel adapter must supply explicit trust context. +- **BREAKING** (internal API) — `MessageSource`'s four trust fields become + `required`; the permissive sentinel-default initializers + (`= TrustAudience.Public`, `= SourceProvenance.StrictDefault()`, etc.) are + removed. +- `SourceProvenance` converts to a 2-parameter primary constructor + (`TransportAuthenticity`, `PayloadTaint` required; `SourceScope`/`SourceKind` + remain optional `init` metadata). The `Unknown`/`Unknown` sentinel defaults + are removed. +- The four `?? options.DefaultX` fallback arms in `MessageSourceFactory.Create` + are deleted (unreachable once `ChannelInput` is required). +- **BREAKING** (internal API) — `SessionPipelineOptions.DefaultAudience`, + `DefaultBoundary`, `DefaultPrincipal`, `DefaultProvenance` are removed. They + exist only to feed the deleted fallback arms. +- Elevated-fallback escalation sites + (`source?.Audience ?? TrustAudience.Personal` in `SessionToolExecutionPipeline`, + `msg.Audience ?? TrustAudience.Personal.ToWireValue()` in `SubAgentActor`) + are replaced with explicit `throw` — a missing turn source is a programming + error, not a runtime condition. +- `NullPromptInjectionDetector` substitution via `?? new NullPromptInjectionDetector()` + is replaced with `throw`; the null detector silently disables injection + scanning and must never be selected by accident. +- `ToolExecutionContext.Audience` changes from wire-string `string?` to parsed + `TrustAudience?`, so an unparseable value fails at construction rather than + silently degrading to `Public` at gate-check time. `RunSubAgent.Audience` + changes correspondingly. +- Persisted records (`BackgroundJobDefinition`, `ActiveJobInfo`, + `ReminderDefinition`) make their trust fields `required` — enforcing every + in-process construction at compile time. A legacy JSON document missing trust + fields is **rejected** at load: the job/reminder store logs an error naming + the file, excludes the document (it is not loaded or scheduled), and + preserves the file for operator inspection. There is no backfill — a job or + reminder with no persisted trust context cannot be run safely, and these + features are typically disabled at the most-restrictive audience, so coercing + a substitute audience would fabricate a nonsensical or privilege-escalating + state. No on-disk migration and no doctor tooling. + +## Capabilities + +### New Capabilities + +- `trust-context-integrity`: Establishes the cross-cutting invariant that + trust-bearing context (audience, principal, boundary, provenance, transport + authenticity, payload taint) is mandatory and non-optional at every actor + boundary, that no security-relevant field may carry a permissive or elevated + sentinel default, and that missing trust context fails loud rather than + silently defaulting. + +### Modified Capabilities + +- `netclaw-input-adapters`: Inbound channel adapters SHALL supply complete, + explicit trust context on every `ChannelInput`; the pipeline SHALL NOT + synthesize a default audience/principal/provenance/boundary. +- `audience-context-filtering`: The session pipeline SHALL derive audience only + from an explicitly-supplied turn source; there is no `DefaultAudience` + fallback. +- `background-job-execution`: Background-job submission SHALL fail loud when no + turn source is present rather than defaulting to `Personal` audience; + persisted job records SHALL carry explicit, required trust fields, and a + legacy job document missing them SHALL be rejected at load rather than + coerced. +- `reminder-execution-history`: Persisted reminder definitions SHALL carry + explicit, required trust fields; a legacy document missing them SHALL be + rejected at load — logged as an error, excluded from scheduling, and the file + preserved — never coerced to a substitute audience. +- `netclaw-tools`: `ToolExecutionContext` SHALL carry audience as a parsed + `TrustAudience`, not a wire string; an unparseable audience SHALL fail at + construction. +- `netclaw-subagents`: Sub-agent spawn messages SHALL carry an explicit parsed + audience; a missing audience SHALL fail loud rather than defaulting to + `Personal`. + +## Impact + +- **Affected code**: `Netclaw.Actors` (`Channels/`, `Sessions/Pipelines/`, + `SubAgents/`, `Jobs/`, `Reminders/`, `Persistence/`), `Netclaw.Tools.Abstractions` + (`ToolExecutionContext`), `Netclaw.Configuration` (`SecurityPolicyDefaults` — + `ParseAudienceOrPublic` deleted, `ResolveAudienceWithFallback` retyped), + `Netclaw.Channels.Slack` / `Netclaw.Channels.Discord` (binding actors and + history fetchers), `Netclaw.Daemon` (`SignalRSessionActor`, + `WebhookExecutionActor`). +- **APIs**: Internal-only. No wire-format or on-disk-format change. No public + NuGet surface. +- **Persistence**: No on-disk or on-wire format change. A legacy + `BackgroundJobDefinition` / `ReminderDefinition` JSON document that predates + this change and lacks trust fields is rejected at load — logged as an error, + excluded, the file preserved. The job/reminder does not run. `ActiveJobInfo` + is protobuf-serialized; proto3 cannot express an absent field, so a legacy + record deserializes its audience to enum `0` = `Public` (fail-closed) — it + needs no special handling. +- **Tests**: `Netclaw.Actors.Tests`, `Netclaw.Channels.Slack.Tests`, + `Netclaw.Channels.Discord.Tests`, `Netclaw.Daemon.Tests` adapt mechanically + to the required-property and primary-constructor shapes. +- **Out of scope**: The broader value-object adoption pass (Pass 7 in the + planning doc — wrapping raw-string identifiers in value objects) is tracked + separately and not part of this change. This change is the trust-tier + hardening only (Passes 1–4). diff --git a/openspec/changes/type-system-stiffening/specs/audience-context-filtering/spec.md b/openspec/changes/type-system-stiffening/specs/audience-context-filtering/spec.md new file mode 100644 index 000000000..60682e058 --- /dev/null +++ b/openspec/changes/type-system-stiffening/specs/audience-context-filtering/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Audience derivation has no default-audience fallback + +The session pipeline SHALL derive a turn's audience only from the explicitly +supplied turn source. There SHALL be no pipeline-level `DefaultAudience`, +`DefaultBoundary`, `DefaultPrincipal`, or `DefaultProvenance` configuration +property. A turn that reaches audience derivation without a turn source SHALL +fail loudly rather than adopt a default audience. + +#### Scenario: No default-audience configuration exists + +- **WHEN** session pipeline options are constructed +- **THEN** there is no `DefaultAudience` (or sibling `Default*` trust) property + to set +- **AND** trust context can only enter the pipeline by way of an inbound + `ChannelInput` + +#### Scenario: Audience derivation uses the supplied turn source + +- **GIVEN** a turn with an explicit turn source carrying `TrustAudience.Personal` +- **WHEN** the pipeline derives the effective audience +- **THEN** the derived audience reflects the Personal source audience +- **AND** no default-audience value participates in the derivation diff --git a/openspec/changes/type-system-stiffening/specs/background-job-execution/spec.md b/openspec/changes/type-system-stiffening/specs/background-job-execution/spec.md new file mode 100644 index 000000000..83b4ad0cc --- /dev/null +++ b/openspec/changes/type-system-stiffening/specs/background-job-execution/spec.md @@ -0,0 +1,65 @@ +## MODIFIED Requirements + +### Requirement: Job delivery carries originating audience + +Background job results delivered via `DeliverTrustedSessionTurn` SHALL carry +the originating session's `TrustAudience` and trust boundary. The job +definition SHALL persist these values at creation time as `required`, +non-optional fields. Trusted delivery SHALL be scoped to that originating +session and persisted originating audience/boundary only. + +Background-job submission SHALL fail loudly when no turn source is present. The +submission path SHALL NOT default a missing audience to `TrustAudience.Personal` +or a missing boundary to the personal boundary; a missing turn source is a +programming error and SHALL raise an explicit exception. + +#### Scenario: Job delivery uses originating audience + +- **GIVEN** a background job was started from a Personal-audience session +- **WHEN** the job completes and delivers results +- **THEN** `DeliverTrustedSessionTurn` carries `TrustAudience.Personal` +- **AND** the session processes the turn with Personal-level grants + +#### Scenario: Trusted delivery remains scoped to originating boundary + +- **GIVEN** a background job was started with a specific originating trust + boundary +- **WHEN** the job completes and delivers results +- **THEN** the delivery uses that persisted originating trust boundary +- **AND** the result is not delivered with a broader boundary than the one + stored at job creation time + +#### Scenario: Submission without a turn source fails loud + +- **WHEN** background-job submission is reached without a turn source +- **THEN** the submission throws an explicit exception +- **AND** no job is created with a substituted `Personal` audience or boundary + +## ADDED Requirements + +### Requirement: Persisted job records carry required trust fields + +The persisted `BackgroundJobDefinition` and `ActiveJobInfo` records SHALL +declare their audience and boundary fields as `required` and non-optional, so +that every in-process construction is enforced by the compiler. A legacy +`BackgroundJobDefinition` JSON document that lacks these fields SHALL be +rejected at load — the job store SHALL log an error naming the document and the +missing fields and SHALL exclude the document from `Get` and `List`. The system +SHALL NOT substitute an audience or boundary for a job with no persisted trust +context — neither the previous `Personal` default nor a `Public` fallback. + +#### Scenario: Legacy job document is rejected at load + +- **GIVEN** a persisted `BackgroundJobDefinition` JSON document that predates + this change and lacks an audience or boundary field +- **WHEN** the job store reads it +- **THEN** the document is excluded — `Get` returns nothing and `List` omits it +- **AND** an error naming the document and the missing fields is logged +- **AND** no audience or boundary is substituted, so the job does not run + +#### Scenario: Current job documents round-trip unchanged + +- **GIVEN** a `BackgroundJobDefinition` written after this change with explicit + audience and boundary +- **WHEN** the job store deserializes it +- **THEN** the audience and boundary are read verbatim with no error logged diff --git a/openspec/changes/type-system-stiffening/specs/netclaw-input-adapters/spec.md b/openspec/changes/type-system-stiffening/specs/netclaw-input-adapters/spec.md new file mode 100644 index 000000000..f39c7f2c4 --- /dev/null +++ b/openspec/changes/type-system-stiffening/specs/netclaw-input-adapters/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Inbound adapters supply explicit trust context + +Every inbound channel adapter SHALL stamp complete, explicit trust context — +audience, principal, boundary, and provenance — onto each `ChannelInput` it +constructs. The session pipeline SHALL NOT synthesize a default audience, +principal, boundary, or provenance for an inbound message. The +`ChannelInput`-to-`MessageSource` factory SHALL carry trust context through by +direct assignment, with no null-coalescing fallback. + +#### Scenario: Adapter omitting trust context fails to compile + +- **WHEN** an inbound adapter constructs a `ChannelInput` without every trust + field set +- **THEN** the build fails with a missing-required-member error + +#### Scenario: History-fetched messages carry the resolved audience + +- **GIVEN** a Slack DM configured with `Slack.ChannelAudiences["dm"] = "personal"` +- **WHEN** the thread-history fetcher converts a historical message into a + `ChannelInput` +- **THEN** the `ChannelInput` carries `TrustAudience.Personal` as resolved by + the channel's audience policy +- **AND** the pipeline applies Personal-level grants without any Public + fallback + +#### Scenario: Pipeline does not synthesize trust context + +- **WHEN** the message-source factory builds a `MessageSource` from a + `ChannelInput` +- **THEN** every trust field on the `MessageSource` is the value carried on the + `ChannelInput` +- **AND** no value originates from a pipeline-level default diff --git a/openspec/changes/type-system-stiffening/specs/netclaw-subagents/spec.md b/openspec/changes/type-system-stiffening/specs/netclaw-subagents/spec.md new file mode 100644 index 000000000..b5006adc4 --- /dev/null +++ b/openspec/changes/type-system-stiffening/specs/netclaw-subagents/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Sub-agent spawn carries an explicit audience + +A `RunSubAgent` spawn message SHALL carry the spawning session's audience as a +parsed `TrustAudience`. The sub-agent actor SHALL NOT default a missing +audience to `TrustAudience.Personal`; a sub-agent spawned from a live session +always has a parent audience, so an absent audience is a programming error and +SHALL raise an explicit exception. + +#### Scenario: Sub-agent inherits the parent session audience + +- **GIVEN** a sub-agent spawned from a Public-audience session +- **WHEN** the sub-agent actor initializes its tool execution context +- **THEN** the context carries `TrustAudience.Public` +- **AND** the audience is not elevated to `Personal` + +#### Scenario: Missing spawn audience fails loud + +- **WHEN** a `RunSubAgent` message reaches the sub-agent actor without an + audience +- **THEN** the actor throws an explicit exception +- **AND** no `Personal` audience is substituted diff --git a/openspec/changes/type-system-stiffening/specs/netclaw-tools/spec.md b/openspec/changes/type-system-stiffening/specs/netclaw-tools/spec.md new file mode 100644 index 000000000..032db1738 --- /dev/null +++ b/openspec/changes/type-system-stiffening/specs/netclaw-tools/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Tool execution context carries a parsed audience + +`ToolExecutionContext` SHALL represent the execution audience as a parsed +`TrustAudience`, not as an unvalidated wire string. The audience SHALL be +parsed when the context is built, so an unparseable value fails at construction +rather than at a later tool authorization check. Tool authorization SHALL read +the parsed audience directly and SHALL NOT re-parse a string or apply a +parse-failure fallback to `Public`. + +#### Scenario: Context built with an unparseable audience fails loud + +- **WHEN** a `ToolExecutionContext` is built from an audience value that cannot + be parsed +- **THEN** construction throws an explicit parse error +- **AND** the failure occurs before any tool runs + +#### Scenario: Tool authorization reads the parsed audience + +- **GIVEN** a `ToolExecutionContext` carrying a parsed `TrustAudience` +- **WHEN** `ToolAccessPolicy` evaluates a tool invocation +- **THEN** it reads the audience as a typed value +- **AND** it performs no string parsing and applies no `Public` parse-failure + fallback diff --git a/openspec/changes/type-system-stiffening/specs/reminder-execution-history/spec.md b/openspec/changes/type-system-stiffening/specs/reminder-execution-history/spec.md new file mode 100644 index 000000000..9c9e7821e --- /dev/null +++ b/openspec/changes/type-system-stiffening/specs/reminder-execution-history/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Persisted reminder definitions carry required trust fields + +A persisted `ReminderDefinition` SHALL declare its audience and boundary fields +as `required` and non-optional, so that every in-process construction is +enforced by the compiler. A legacy reminder JSON document that lacks these +fields SHALL be rejected at load — the reminder store SHALL log an error naming +the document and the missing fields, SHALL exclude the reminder from `Get` and +`List` (so it is never scheduled), and SHALL preserve the file on disk. The +system SHALL NOT substitute an audience or boundary for a reminder with no +persisted trust context. + +#### Scenario: Legacy reminder document is rejected at load + +- **GIVEN** a persisted `ReminderDefinition` JSON document that predates this + change and lacks an audience or boundary field +- **WHEN** the reminder store reads it +- **THEN** the reminder is excluded — `Get` returns nothing and `List` omits it +- **AND** an error naming the document and the missing fields is logged +- **AND** the file is preserved on disk for the operator to repair or remove +- **AND** no audience or boundary is substituted, so the reminder is not scheduled + +#### Scenario: Current reminder documents round-trip unchanged + +- **GIVEN** a `ReminderDefinition` written after this change with explicit + audience and boundary +- **WHEN** the reminder store deserializes it +- **THEN** the audience and boundary are read verbatim with no error logged diff --git a/openspec/changes/type-system-stiffening/specs/trust-context-integrity/spec.md b/openspec/changes/type-system-stiffening/specs/trust-context-integrity/spec.md new file mode 100644 index 000000000..90af64404 --- /dev/null +++ b/openspec/changes/type-system-stiffening/specs/trust-context-integrity/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Trust context is mandatory at actor boundaries + +Every record that carries trust context across an actor boundary SHALL declare +its trust-bearing fields — audience, principal, boundary, provenance, transport +authenticity, and payload taint — as non-optional. A trust-bearing field SHALL +NOT be nullable and SHALL NOT carry a sentinel default value. The compiler +SHALL reject construction of such a record that omits any trust-bearing field. + +#### Scenario: Omitting a trust field fails to compile + +- **WHEN** code constructs a trust-bearing record without supplying every + trust-bearing field +- **THEN** the build fails with a missing-required-member error +- **AND** no permissive or elevated value is substituted + +#### Scenario: Trust-bearing record carries explicit values + +- **WHEN** a trust-bearing record is constructed +- **THEN** every trust-bearing field holds a value explicitly supplied by the + caller +- **AND** no field was populated by a framework-supplied default + +### Requirement: No permissive or elevated defaults on security-relevant fields + +A security-relevant field SHALL NOT be assigned a permissive default (a value +granting broader trust than the caller intended) or an elevated default (a +value granting narrower-but-higher-privilege trust such as `Personal`) when its +source value is absent. When trust context is genuinely required but absent, +the system SHALL fail loudly rather than substitute any default. + +#### Scenario: Missing turn source fails loud + +- **GIVEN** a code path that requires a turn source to derive trust context +- **WHEN** the turn source is absent +- **THEN** the system throws an explicit error identifying the missing context +- **AND** the operation does not proceed with a substituted audience or + boundary + +#### Scenario: Conservative fallback only where partial absence is normal + +- **GIVEN** a derivation path where the absence of a source is a defined, + normal condition +- **WHEN** the source is absent +- **THEN** the system MAY substitute a documented fail-closed value (the most + restrictive trust level) +- **AND** the system SHALL NOT substitute a value more permissive or more + privileged than fail-closed + +### Requirement: Parsed trust types instead of wire strings + +Trust context carried into tool execution SHALL be represented as parsed, +strongly-typed values. An audience SHALL be a parsed `TrustAudience`, not an +unvalidated wire string. A value that cannot be parsed SHALL fail at the point +of construction, not at the point of a later authorization check. + +#### Scenario: Unparseable audience fails at construction + +- **WHEN** trust context is built from an audience value that cannot be parsed +- **THEN** construction throws an explicit parse error +- **AND** the failure occurs before any tool authorization check runs + +#### Scenario: Tool authorization reads a parsed audience + +- **WHEN** a tool authorization check reads the execution audience +- **THEN** the audience is already a parsed `TrustAudience` +- **AND** the check performs no string parsing and applies no parse-failure + fallback diff --git a/openspec/changes/type-system-stiffening/tasks.md b/openspec/changes/type-system-stiffening/tasks.md new file mode 100644 index 000000000..900039304 --- /dev/null +++ b/openspec/changes/type-system-stiffening/tasks.md @@ -0,0 +1,51 @@ +## 1. PR-A — Trust-bearing record shapes + +- [x] 1.1 Convert `SourceProvenance` (`Netclaw.Actors/Channels/SourceProvenance.cs`) to a 2-parameter primary constructor `(TransportAuthenticity, PayloadTaint)`; keep `SourceScope`/`SourceKind` as optional `init`; remove the `Unknown` sentinel defaults. +- [x] 1.2 Remove the `SourceProvenance.StrictDefault()` factory; update its callers to construct an explicit `new SourceProvenance(TransportAuthenticity.Unverified, PayloadTaint.Public)` where a conservative value is genuinely needed. +- [x] 1.3 Make `ChannelInput.Audience`, `Boundary`, `Principal`, `Provenance` (`Netclaw.Actors/Channels/ChannelInput.cs`) `required` and non-nullable. +- [x] 1.4 Make `MessageSource.Audience`, `Boundary`, `Principal`, `Provenance` (`Netclaw.Actors/Channels/MessageSource.cs`) `required`; delete the four sentinel-default initializers. +- [x] 1.5 Remove the four `?? options.DefaultX` fallback arms in `MessageSourceFactory.Create` (`Netclaw.Actors/Channels/ChannelPipeline.cs`); assign trust fields directly from `input`. +- [x] 1.6 Delete `SessionPipelineOptions.DefaultAudience`, `DefaultBoundary`, `DefaultPrincipal`, `DefaultProvenance`. +- [x] 1.7 Update `SlackThreadBindingActor.BuildOptions` and `SlackThreadHistoryFetcher.ConvertMessageAsync` to stamp explicit `Audience`, `Boundary`, `Principal`, `Provenance` onto every `ChannelInput`. +- [x] 1.8 Update `DiscordSessionBindingActor.BuildOptions` and `DiscordThreadHistoryFetcher` to stamp explicit trust context onto every `ChannelInput`. +- [x] 1.9 Update `SignalRSessionActor.BuildOptions` (`Netclaw.Daemon/Gateway`) to stamp explicit trust context. +- [x] 1.10 Update `WebhookExecutionActor.InitializeAsync` (`Netclaw.Daemon/Webhooks`) to stamp explicit trust context onto its `ChannelInput`. +- [x] 1.11 Update `ReminderExecutionActor.InitializeAsync` (`Netclaw.Actors/Reminders`) to stamp explicit trust context onto its `ChannelInput`. +- [x] 1.12 Fix all remaining compiler errors from the required-property change across `Netclaw.Actors`, channel projects, and `Netclaw.Daemon`. +- [x] 1.13 Update affected unit tests (`Netclaw.Actors.Tests`, `Netclaw.Channels.Slack.Tests`, `Netclaw.Channels.Discord.Tests`, `Netclaw.Daemon.Tests`) to construct trust-bearing records with explicit trust context. +- [x] 1.14 Verify PR-A: `dotnet build` clean, `dotnet test` green for affected projects, `dotnet slopwatch analyze` no new violations, `./scripts/Add-FileHeaders.ps1 -Verify` passes. + +## 2. PR-B — Elevated-fallback sites become explicit throws + +- [x] 2.1 Replace `source?.Audience ?? TrustAudience.Personal` / `source?.Boundary ?? SecurityPolicyDefaults.PersonalBoundary` in `SessionToolExecutionPipeline` (background-job submission) with an explicit `throw new InvalidOperationException` on a missing turn source. +- [x] 2.2 Replace `msg.Audience ?? TrustAudience.Personal.ToWireValue()` in `SubAgentActor` with an explicit `throw` on a missing audience. +- [x] 2.3 Replace `?? new NullPromptInjectionDetector()` in `SlackThreadBindingActor`, `SlackChannel`, `DiscordSessionBindingActor`, and `DiscordChannel` with an explicit `throw`; delete the now-unused `NullPromptInjectionDetector`. +- [x] 2.4 Update tests for the new throw behavior (`RunSubAgent` carries an explicit audience; gateway-dependency fixtures wire a real detector). +- [x] 2.5 Verify PR-B: build clean, tests green, slopwatch clean, file headers verified. + +## 3. PR-C — `ToolExecutionContext` / `RunSubAgent` audience typing + +- [x] 3.1 Change `ToolExecutionContext.Audience` (`Netclaw.Tools.Abstractions`) from `string?` to `TrustAudience?`. +- [x] 3.2 Update the write sites that build `ToolExecutionContext` (`SessionToolExecutionPipeline`, `LlmSessionActor`, `SubAgentActor`, daemon REST reminder path) to set the typed audience directly. +- [x] 3.3 Update read sites (`SpawnAgentTool`, `SubAgentSpawner`, `SkillLoadTool`, `SkillReadResourceTool`, `ToolAccessPolicy`, `CheckBackgroundJobTool`, `SetReminderTool`, `ToolRegistry`) to consume the typed audience. +- [x] 3.4 Change `RunSubAgent.Audience` (`Netclaw.Actors/SubAgents/SubAgentProtocol.cs`) from `string?` to `TrustAudience?`. +- [x] 3.5 Delete `SecurityPolicyDefaults.ParseAudienceOrPublic`; retype `ResolveAudienceWithFallback` (and `MemoryPolicyScopeResolver.ResolveAudience`) to take `TrustAudience?` so no wire-string parsing remains on the read path. +- [x] 3.6 Update affected tests for the typed audience. +- [x] 3.7 Verify PR-C: build clean, tests green, slopwatch clean, file headers verified. Eval suite not triggered — PR-C is an internal type change and does not alter model-facing tool schemas, grant categories, or definitions. + +## 4. PR-D — Persisted records: required trust fields, reject legacy documents + +- [x] 4.1 Add a shared `LegacyTrustFieldGuard` helper (`Netclaw.Actors/Persistence/`) that, given a job/reminder JSON document, returns which `audience`/`boundary` keys are absent or explicitly null. +- [x] 4.2 Make `BackgroundJobDefinition.Audience`/`Boundary` (`Netclaw.Actors/Jobs/BackgroundJobProtocol.cs`) `required`; reject a legacy document in `BackgroundJobDefinitionStore` — log an error and exclude it from `Get`/`List`. +- [x] 4.3 Make `ActiveJobInfo.Audience`/`Boundary` (`Netclaw.Actors/Jobs/ActiveJobInfo.cs`) `required` — compile-time only; `ActiveJobInfo` is protobuf-serialized and proto3 defaults a missing audience to `Public` (fail-closed). +- [x] 4.4 Make `ReminderDefinition.Audience`/`Boundary` (`Netclaw.Actors/Reminders/ReminderProtocol.cs`) `required` and non-nullable; reject a legacy document in `ReminderDefinitionStore` — log an error, exclude it from `Get`/`List`, preserve the file (do not prune it as corrupt JSON). +- [x] 4.5 Fix in-process construction sites that omit the now-required trust fields (`SetReminderTool`, `ReminderManagerActor`, `ReminderExecutionActor` dead null-checks). +- [x] 4.6 Add tests: a legacy reminder document missing trust fields is rejected and preserved (regression test — excluded from `Get`/`List`, error logged, file kept); the legacy-job equivalent; and current documents round-trip verbatim. +- [x] 4.7 Verify PR-D: build clean, tests green, slopwatch clean, file headers verified. + +## 5. Cross-cutting verification and documentation + +- [ ] 5.1 Manual smoke: restart the daemon with the Personal-DM Slack configuration from PR #993; confirm `shell_execute` is permitted (no Public downgrade). +- [ ] 5.2 Manual smoke: place a known legacy `*.job.json` / reminder document (no trust fields) in the persistence directory; confirm the store rejects it loudly (error logged, job/reminder not loaded) and leaves the file in place. +- [ ] 5.3 Update operator-facing docs / runbook with the upgrade note: legacy job/reminder documents missing trust fields are rejected at load and must be recreated or have `audience`/`boundary` added. +- [ ] 5.4 Run `/opsx-verify` against this change, then `/opsx-sync` and `/opsx-archive`. diff --git a/src/Netclaw.Actors.Tests/Channels/AdoptedContextContentBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/AdoptedContextContentBuilderTests.cs index 2635c6d10..aaf3adff3 100644 --- a/src/Netclaw.Actors.Tests/Channels/AdoptedContextContentBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/AdoptedContextContentBuilderTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.AI; using Netclaw.Actors.Channels; using Netclaw.Channels; +using Netclaw.Configuration; using Xunit; namespace Netclaw.Actors.Tests.Channels; @@ -23,7 +24,11 @@ public void MergeWithCurrentMessage_escapes_marker_like_attribute_values() SenderId = "user]\n[current-authorized-message author=mallory]", MessageId = "msg [oops]", Contents = [new TextContent("history body")], - ReceivedAt = new DateTimeOffset(2026, 4, 28, 12, 0, 0, TimeSpan.Zero) + ReceivedAt = new DateTimeOffset(2026, 4, 28, 12, 0, 0, TimeSpan.Zero), + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) }, AdoptedMessageAuthority.Pending) ]; @@ -58,7 +63,11 @@ public void MergeWithCurrentMessage_escapes_reserved_marker_prefixes_in_body_lin SenderId = "user-1", MessageId = "msg-1", Contents = [new TextContent($"{reservedPrefix}\nnormal line")], - ReceivedAt = new DateTimeOffset(2026, 4, 28, 12, 0, 0, TimeSpan.Zero) + ReceivedAt = new DateTimeOffset(2026, 4, 28, 12, 0, 0, TimeSpan.Zero), + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) }, AdoptedMessageAuthority.Pending) ]; diff --git a/src/Netclaw.Actors.Tests/Channels/ChannelPipelineAckTargetTests.cs b/src/Netclaw.Actors.Tests/Channels/ChannelPipelineAckTargetTests.cs index eb54a73fe..b3af1a858 100644 --- a/src/Netclaw.Actors.Tests/Channels/ChannelPipelineAckTargetTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/ChannelPipelineAckTargetTests.cs @@ -91,7 +91,11 @@ await Source.Single(cmd) Contents = [new TextContent("hello")], ReceivedAt = DateTimeOffset.UtcNow, ReminderId = reminderId, - AckTarget = ackTarget + AckTarget = ackTarget, + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) }; private static SendUserMessage BuildCommand(ChannelInput input) diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordGatewayContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordGatewayContractTests.cs index 3f872f746..20aeb7810 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordGatewayContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordGatewayContractTests.cs @@ -47,6 +47,7 @@ protected override IActorRef CreateGateway(ChannelOptionsBuilder options) AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestDiscordGatewayDeps.DefaultVisionCapableModel, Paths: TestDiscordGatewayDeps.NewTestPaths(), + PromptInjectionDetector: SafePromptInjectionDetector.Instance, SessionPropsFactory: (sid, chId, replyId, threadId, rootId, d) => Props.Create(() => new ForwardActor(TestActor))); diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs index 6b1132d1a..42cad0722 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs @@ -76,9 +76,8 @@ protected override object CreateInboundMessage(string text, string senderId) SenderId: new DiscordUserId(senderId), Audience: TrustAudience.Team, Principal: PrincipalClassification.UntrustedExternal, - Provenance: new SourceProvenance + Provenance: new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) { - TransportAuthenticity = TransportAuthenticity.Verified, SourceKind = "discord" }, Text: text, @@ -129,7 +128,11 @@ protected override IReadOnlyList CreateHistoryItems(int count) ChannelId = "ch-test", MessageId = (900_000_000_000_000_000UL + (ulong)i).ToString(), Contents = [new Microsoft.Extensions.AI.TextContent($"history message {i}")], - ReceivedAt = TimeProvider.System.GetUtcNow().AddMinutes(-count + i) + ReceivedAt = TimeProvider.System.GetUtcNow().AddMinutes(-count + i), + Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.PublicBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) }); } @@ -151,9 +154,8 @@ protected override object CreateHydrationTriggerInboundMessage(string text, stri SenderId: new DiscordUserId(senderId), Audience: TrustAudience.Team, Principal: PrincipalClassification.UntrustedExternal, - Provenance: new SourceProvenance + Provenance: new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) { - TransportAuthenticity = TransportAuthenticity.Verified, SourceKind = "discord" }, Text: text, diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs index 75940b007..ce7ed056f 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SlackSessionBindingContractTests.cs @@ -58,9 +58,8 @@ protected override object CreateInboundMessage(string text, string senderId) SenderId: senderId, Audience: TrustAudience.Team, Principal: PrincipalClassification.UntrustedExternal, - Provenance: new SourceProvenance + Provenance: new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) { - TransportAuthenticity = TransportAuthenticity.Verified, SourceKind = "slack" }, Text: text, @@ -119,7 +118,11 @@ protected override IReadOnlyList CreateHistoryItems(int count) ChannelId = "C-test", MessageId = $"C-test:{900 + i}.1", Contents = [new TextContent($"history message {i}")], - ReceivedAt = TimeProvider.System.GetUtcNow().AddMinutes(-(count - i)) + ReceivedAt = TimeProvider.System.GetUtcNow().AddMinutes(-(count - i)), + Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.SlackWorkspaceBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) }); } return items; diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordConversationActorTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordConversationActorTests.cs index 597a7f5c3..34977e0b2 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordConversationActorTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordConversationActorTests.cs @@ -458,6 +458,7 @@ private static DiscordGatewayDependencies CreateDependencies( ModelCapabilities: TestDiscordGatewayDeps.DefaultVisionCapableModel, Paths: TestDiscordGatewayDeps.NewTestPaths(), BotUserId: botUserId, + PromptInjectionDetector: SafePromptInjectionDetector.Instance, SessionPropsFactory: sessionPropsFactory); } @@ -493,9 +494,8 @@ private static DiscordGatewayMessage CreateMessage( Audience = TrustAudience.Team, Boundary = "trusted-instance", Principal = PrincipalClassification.TrustedInternal, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.Verified, SourceKind = "reminder" }, ReminderId = "rem-1" diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs index d720dab81..d94e2db9a 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Hosting; using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; +using Netclaw.Actors.Tests.Channels.TestHelpers; using Netclaw.Actors.Memory; using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; @@ -309,7 +310,8 @@ private DiscordGatewayDependencies CreateDependencies( ModelCapabilities: TestDiscordGatewayDeps.DefaultVisionCapableModel, Paths: _paths, BotUserId: new DiscordUserId("UBOT"), - HttpClient: httpClient); + HttpClient: httpClient, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); } private sealed class FailingContentScanner : IContentScanner diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordGatewayActorTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordGatewayActorTests.cs index 966bcb42d..be51f6e64 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordGatewayActorTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordGatewayActorTests.cs @@ -246,9 +246,8 @@ public async Task Gateway_routes_trusted_session_turn_to_conversation_actor() Audience = TrustAudience.Team, Boundary = "trusted-instance", Principal = PrincipalClassification.TrustedInternal, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.Verified, SourceKind = "reminder" }, ReminderId = "rem-1" @@ -281,9 +280,8 @@ public async Task Gateway_nacks_trusted_session_turn_with_invalid_session_id() Audience = TrustAudience.Team, Boundary = "trusted-instance", Principal = PrincipalClassification.TrustedInternal, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.Verified, SourceKind = "reminder" }, ReminderId = "rem-1" @@ -314,7 +312,8 @@ private static DiscordGatewayDependencies CreateDependencies( AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestDiscordGatewayDeps.DefaultVisionCapableModel, Paths: TestDiscordGatewayDeps.NewTestPaths(), - ConversationPropsFactory: conversationPropsFactory); + ConversationPropsFactory: conversationPropsFactory, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); } private static DiscordGatewayMessage CreateMessage( diff --git a/src/Netclaw.Actors.Tests/Channels/MessageSourceFactoryTests.cs b/src/Netclaw.Actors.Tests/Channels/MessageSourceFactoryTests.cs index 3ecff4908..5734c5540 100644 --- a/src/Netclaw.Actors.Tests/Channels/MessageSourceFactoryTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MessageSourceFactoryTests.cs @@ -1,8 +1,9 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Akka.Actor; using Akka.Hosting; using Akka.Hosting.TestKit; using Microsoft.Extensions.AI; @@ -18,59 +19,48 @@ public MessageSourceFactoryTests(ITestOutputHelper output) : base(output: output protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) { } - [Fact] - public void Create_uses_strict_pipeline_defaults_when_input_has_no_hints() - { - var input = new ChannelInput + private static ChannelInput BuildInput( + TrustAudience audience = TrustAudience.Public, + string? boundary = null, + PrincipalClassification principal = PrincipalClassification.UntrustedExternal, + SourceProvenance? provenance = null, + string? reminderId = null, + IActorRef? ackTarget = null, + bool hasThirdParty = false, + IReadOnlyList? adoptedSpeakerIds = null) + => new() { SenderId = "user-1", + Audience = audience, + Boundary = boundary ?? SecurityPolicyDefaults.PublicBoundary, + Principal = principal, + Provenance = provenance + ?? new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public), Contents = [new TextContent("hello")], - ReceivedAt = DateTimeOffset.UtcNow - }; - - var options = new SessionPipelineOptions - { - ChannelType = ChannelType.Slack + ReceivedAt = DateTimeOffset.UtcNow, + ReminderId = reminderId, + AckTarget = ackTarget, + HasThirdPartyAdoptedContext = hasThirdParty, + AdoptedSpeakerIds = adoptedSpeakerIds ?? [], }; - var result = MessageSourceFactory.Create(input, options, "turn-1"); - - Assert.Equal(TrustAudience.Public, result.Audience); - Assert.Equal(SecurityPolicyDefaults.SlackWorkspaceBoundary, result.Boundary); - Assert.Equal(PrincipalClassification.UntrustedExternal, result.Principal); - Assert.Equal(TransportAuthenticity.Unverified, result.Provenance.TransportAuthenticity); - Assert.Equal(PayloadTaint.Public, result.Provenance.PayloadTaint); - } - [Fact] - public void Create_prefers_explicit_input_hints_over_pipeline_defaults() + public void Create_copies_trust_context_verbatim_from_ChannelInput() { - var input = new ChannelInput - { - SenderId = "user-1", - Audience = TrustAudience.Team, - Boundary = SecurityPolicyDefaults.TeamBoundary, - Principal = PrincipalClassification.TrustedInternal, - Provenance = new SourceProvenance + var input = BuildInput( + audience: TrustAudience.Team, + boundary: SecurityPolicyDefaults.TeamBoundary, + principal: PrincipalClassification.TrustedInternal, + provenance: new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Community) { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Community, SourceKind = "slack" - }, - Contents = [new TextContent("hello")], - ReceivedAt = DateTimeOffset.UtcNow - }; + }); - var options = new SessionPipelineOptions - { - ChannelType = ChannelType.Slack, - DefaultAudience = TrustAudience.Public, - DefaultPrincipal = PrincipalClassification.UntrustedExternal, - DefaultProvenance = SourceProvenance.StrictDefault() - }; - - var result = MessageSourceFactory.Create(input, options, "turn-1"); + var result = MessageSourceFactory.Create( + input, new SessionPipelineOptions { ChannelType = ChannelType.Slack }, "turn-1"); + // The factory is a pure mapper — trust context is whatever the adapter + // stamped on the ChannelInput, never a pipeline-synthesized default. Assert.Equal(TrustAudience.Team, result.Audience); Assert.Equal(SecurityPolicyDefaults.TeamBoundary, result.Boundary); Assert.Equal(PrincipalClassification.TrustedInternal, result.Principal); @@ -82,16 +72,8 @@ public void Create_prefers_explicit_input_hints_over_pipeline_defaults() [Fact] public void Create_propagates_null_ReminderId_and_AckTarget_by_default() { - var input = new ChannelInput - { - SenderId = "user-1", - Contents = [new TextContent("hello")], - ReceivedAt = DateTimeOffset.UtcNow - }; - - var options = new SessionPipelineOptions { ChannelType = ChannelType.Slack }; - - var result = MessageSourceFactory.Create(input, options, "turn-1"); + var result = MessageSourceFactory.Create( + BuildInput(), new SessionPipelineOptions { ChannelType = ChannelType.Slack }, "turn-1"); Assert.Null(result.ReminderId); Assert.Null(result.AckTarget); @@ -102,18 +84,12 @@ public void Create_propagates_ReminderId_and_AckTarget_from_ChannelInput() { var probe = CreateTestProbe("ack-probe"); - var input = new ChannelInput - { - SenderId = "reminder-system", - Contents = [new TextContent("check PR")], - ReceivedAt = DateTimeOffset.UtcNow, - ReminderId = "check-pr:1712000000000", - AckTarget = probe.Ref - }; + var input = BuildInput( + reminderId: "check-pr:1712000000000", + ackTarget: probe.Ref); - var options = new SessionPipelineOptions { ChannelType = ChannelType.Slack }; - - var result = MessageSourceFactory.Create(input, options, "turn-1"); + var result = MessageSourceFactory.Create( + input, new SessionPipelineOptions { ChannelType = ChannelType.Slack }, "turn-1"); Assert.Equal("check-pr:1712000000000", result.ReminderId); Assert.Same(probe.Ref, result.AckTarget); @@ -122,16 +98,10 @@ public void Create_propagates_ReminderId_and_AckTarget_from_ChannelInput() [Fact] public void Create_propagates_self_only_adopted_context_without_third_party_flag() { - var input = new ChannelInput - { - SenderId = "user-1", - Contents = [new TextContent("hello")], - ReceivedAt = DateTimeOffset.UtcNow, - HasThirdPartyAdoptedContext = false, - AdoptedSpeakerIds = ["user-1"] - }; + var input = BuildInput(hasThirdParty: false, adoptedSpeakerIds: ["user-1"]); - var result = MessageSourceFactory.Create(input, new SessionPipelineOptions { ChannelType = ChannelType.Slack }, "turn-1"); + var result = MessageSourceFactory.Create( + input, new SessionPipelineOptions { ChannelType = ChannelType.Slack }, "turn-1"); Assert.True(result.HasAdoptedContext); Assert.False(result.HasThirdPartyAdoptedContext); @@ -141,16 +111,10 @@ public void Create_propagates_self_only_adopted_context_without_third_party_flag [Fact] public void Create_propagates_third_party_adopted_context_flag() { - var input = new ChannelInput - { - SenderId = "user-1", - Contents = [new TextContent("hello")], - ReceivedAt = DateTimeOffset.UtcNow, - HasThirdPartyAdoptedContext = true, - AdoptedSpeakerIds = ["user-1", "user-2"] - }; + var input = BuildInput(hasThirdParty: true, adoptedSpeakerIds: ["user-1", "user-2"]); - var result = MessageSourceFactory.Create(input, new SessionPipelineOptions { ChannelType = ChannelType.Slack }, "turn-1"); + var result = MessageSourceFactory.Create( + input, new SessionPipelineOptions { ChannelType = ChannelType.Slack }, "turn-1"); Assert.True(result.HasAdoptedContext); Assert.True(result.HasThirdPartyAdoptedContext); diff --git a/src/Netclaw.Actors.Tests/Channels/SlackActorHierarchyTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackActorHierarchyTests.cs index 6890351d4..9e141f074 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackActorHierarchyTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackActorHierarchyTests.cs @@ -284,7 +284,8 @@ private static SlackGatewayDependencies CreateDependencies( ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, Paths: TestSlackGatewayDeps.NewTestPaths(), ConversationPropsFactory: conversationPropsFactory, - ThreadPropsFactory: threadPropsFactory); + ThreadPropsFactory: threadPropsFactory, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); } private static SlackInboundMessage CreateMessage( @@ -435,10 +436,8 @@ public async Task Conversation_rejects_DeliverTrustedSessionTurn_for_other_chann Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.SlackWorkspaceBoundary, Principal = PrincipalClassification.VerifiedAutomation, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.LocalProcess, PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.LocalProcess, - PayloadTaint = PayloadTaint.Trusted, SourceKind = "reminder" }, ReceivedAt = DateTimeOffset.UtcNow, diff --git a/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs index bb1cff6ac..19e4847f9 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Hosting; using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; +using Netclaw.Actors.Tests.Channels.TestHelpers; using Netclaw.Actors.Memory; using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; @@ -140,7 +141,8 @@ private IActorRef BuildGateway( AudienceProfiles: profiles, ModelCapabilities: Host.Services.GetRequiredService(), Paths: _paths, - HttpClient: httpClient); + HttpClient: httpClient, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); return Sys.ActorOf(SlackGatewayActor.CreateProps(deps), gatewayName); } diff --git a/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs index c34a399f0..3ac632723 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs @@ -20,6 +20,7 @@ using Netclaw.Actors.Memory; using Netclaw.Actors.Sessions; using Netclaw.Actors.Protocol; +using Netclaw.Actors.Tests.Channels.TestHelpers; using Netclaw.Actors.Tests.Hosting; using Netclaw.Actors.Tests.Sessions; using Netclaw.Channels.Slack; @@ -135,7 +136,8 @@ public async Task Inbound_image_file_is_downloaded_and_persisted_to_session_medi AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, Paths: _paths, - HttpClient: httpClient); + HttpClient: httpClient, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-file-test"); @@ -218,7 +220,8 @@ public async Task App_mention_with_file_only_is_downloaded_and_persisted() AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, Paths: _paths, - HttpClient: httpClient); + HttpClient: httpClient, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-mention-test"); @@ -290,7 +293,8 @@ public async Task File_share_subtype_with_text_flows_through_full_pipeline() AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, Paths: _paths, - HttpClient: httpClient); + HttpClient: httpClient, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-fileshare-test"); @@ -410,7 +414,8 @@ public async Task Failed_turn_posts_single_error_without_generic_fallback() ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-error-turn-test"); @@ -463,7 +468,8 @@ public async Task Timed_out_slack_post_does_not_block_later_turns() ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-post-timeout-test"); @@ -538,7 +544,8 @@ public async Task Retryable_slack_content_rejection_is_fed_back_to_session_for_c ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-delivery-feedback-test"); @@ -612,7 +619,8 @@ public async Task Retryable_slack_file_upload_rejection_is_fed_back_to_session() ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/8000.1"), @@ -671,7 +679,8 @@ public async Task Timeout_during_post_sends_transport_failure_feedback_to_sessio ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/9000.1"), @@ -734,7 +743,8 @@ public async Task Text_approval_reply_routes_tool_interaction_response() ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/9050.1"), @@ -756,7 +766,7 @@ await AwaitAssertAsync(() => SenderId: "U123", Audience: TrustAudience.Personal, Principal: PrincipalClassification.Operator, - Provenance: SourceProvenance.StrictDefault(), + Provenance: new SourceProvenance(TransportAuthenticity.Unverified, PayloadTaint.Public), Text: "a", ReceivedAt: TimeProvider.System.GetUtcNow())); @@ -824,7 +834,8 @@ public async Task Approval_request_posts_block_buttons_with_text_fallback() ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/9055.1"), @@ -895,7 +906,8 @@ public async Task Button_approval_reply_routes_tool_interaction_response() ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/9060.1"), @@ -966,7 +978,8 @@ public async Task Button_approval_response_forwards_to_session_when_binding_cold ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/9061.1"), @@ -1033,7 +1046,8 @@ public async Task Generic_exception_during_post_sends_unknown_failure_feedback_t ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/9100.1"), @@ -1095,7 +1109,8 @@ public async Task Content_rejection_msg_too_long_sends_message_too_large_feedbac ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/9200.1"), @@ -1158,7 +1173,8 @@ public async Task Content_rejection_invalid_blocks_sends_content_rejected_feedba ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var actor = Sys.ActorOf(SlackThreadBindingActor.CreateProps( new SessionId("D7/9300.1"), @@ -1208,7 +1224,8 @@ public async Task Inbound_image_with_real_scanner_flows_to_llm() AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, Paths: _paths, - HttpClient: httpClient); + HttpClient: httpClient, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-real-scanner-test"); @@ -1269,7 +1286,8 @@ public async Task Scanner_failure_rejects_attachment_and_does_not_inline() AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, Paths: _paths, - HttpClient: httpClient); + HttpClient: httpClient, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-failing-scanner-test"); diff --git a/src/Netclaw.Actors.Tests/Channels/SlackProactiveThreadTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackProactiveThreadTests.cs index c2b791304..69f985d93 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackProactiveThreadTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackProactiveThreadTests.cs @@ -594,7 +594,8 @@ private static SlackGatewayDependencies CreateDependencies( ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, Paths: TestSlackGatewayDeps.NewTestPaths(), ConversationPropsFactory: conversationPropsFactory, - ThreadPropsFactory: threadPropsFactory); + ThreadPropsFactory: threadPropsFactory, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); } private static SlackInboundMessage CreateAppMention( diff --git a/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs index 2a72fa68c..f5d96f377 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs @@ -17,6 +17,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; +using Netclaw.Actors.Tests.Channels.TestHelpers; using Netclaw.Actors.Memory; using Netclaw.Actors.Sessions; using Netclaw.Actors.Protocol; @@ -136,7 +137,8 @@ public async Task Backfill_messages_are_merged_into_single_user_turn_and_exclude ThreadHistoryFetcher: fetcher, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-backfill"); @@ -226,7 +228,8 @@ public async Task Backfill_runs_once_per_runtime_and_runs_again_after_restart() ThreadHistoryFetcher: countingFetcher, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-recovery"); @@ -485,7 +488,8 @@ public async Task Bot_replies_below_thread_root_are_excluded_from_adopted_contex ThreadHistoryFetcher: fetcher, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-bot-exclude"); @@ -594,7 +598,8 @@ public async Task Bot_authored_thread_root_is_hydrated_for_proactive_post_bootst ThreadHistoryFetcher: fetcher, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-proactive-root"); @@ -667,7 +672,8 @@ public async Task Older_out_of_order_live_event_is_dropped_after_cursor_advances ThreadHistoryFetcher: fetcher, AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-stale-ordering"); @@ -797,7 +803,8 @@ public async Task Backfill_document_in_public_channel_is_not_forwarded_as_data_c ThreadHistoryFetcher: fetcher, AudienceProfiles: profiles, ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, - Paths: _paths); + Paths: _paths, + PromptInjectionDetector: SafePromptInjectionDetector.Instance); var gateway = Sys.ActorOf(SlackGatewayActor.CreateProps(deps), "slack-gw-backfill-public-doc"); diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/SafePromptInjectionDetector.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/SafePromptInjectionDetector.cs new file mode 100644 index 000000000..5dceeeb9c --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/SafePromptInjectionDetector.cs @@ -0,0 +1,26 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Security; + +namespace Netclaw.Actors.Tests.Channels.TestHelpers; + +/// +/// A test double for that always returns a safe +/// (no-injection) result. Use this in test fixtures that construct +/// or +/// when the test does +/// not exercise prompt-injection detection behavior. +/// +internal sealed class SafePromptInjectionDetector : IPromptInjectionDetector +{ + public static readonly SafePromptInjectionDetector Instance = new(); + + public Task DetectAsync( + string text, + string sourceContext, + CancellationToken cancellationToken = default) + => Task.FromResult(PromptInjectionResult.Safe()); +} diff --git a/src/Netclaw.Actors.Tests/Channels/TrustContextDeriverTests.cs b/src/Netclaw.Actors.Tests/Channels/TrustContextDeriverTests.cs index efe7b2182..93bff4427 100644 --- a/src/Netclaw.Actors.Tests/Channels/TrustContextDeriverTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/TrustContextDeriverTests.cs @@ -43,11 +43,10 @@ public void Derive_takes_narrowest_of_deployment_and_source_audience() ChannelType = ChannelType.Slack, SenderId = "U123", Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, Principal = PrincipalClassification.TrustedInternal, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Public, SourceKind = "slack" }, ReceivedAt = DateTimeOffset.UtcNow @@ -71,11 +70,10 @@ public void Derive_applies_working_context_downgrade_last() ChannelType = ChannelType.SignalR, SenderId = "local-user", Audience = TrustAudience.Personal, + Boundary = SecurityPolicyDefaults.PersonalBoundary, Principal = PrincipalClassification.Operator, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.LocalProcess, PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.LocalProcess, - PayloadTaint = PayloadTaint.Trusted, SourceKind = "signalr" }, ReceivedAt = DateTimeOffset.UtcNow @@ -100,11 +98,10 @@ public void Derive_does_not_upgrade_when_working_context_is_broader_than_effecti ChannelType = ChannelType.Slack, SenderId = "U123", Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.SlackWorkspaceBoundary, Principal = PrincipalClassification.TrustedInternal, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Community) { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Community, SourceKind = "slack" }, ReceivedAt = DateTimeOffset.UtcNow diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobDefinitionStoreTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobDefinitionStoreTests.cs new file mode 100644 index 000000000..15f02994b --- /dev/null +++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobDefinitionStoreTests.cs @@ -0,0 +1,131 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Logging; +using Netclaw.Actors.Jobs; +using Netclaw.Configuration; +using Xunit; + +namespace Netclaw.Actors.Tests.Jobs; + +public sealed class BackgroundJobDefinitionStoreTests : IDisposable +{ + private readonly string _basePath = Path.Combine(Path.GetTempPath(), $"netclaw-job-store-tests-{Guid.NewGuid():N}"); + private readonly NetclawPaths _paths; + + public BackgroundJobDefinitionStoreTests() + { + _paths = new NetclawPaths(_basePath); + _paths.EnsureDirectoriesExist(); + } + + /// + /// Regression test for issue #994. A pre-#994 background job document missing + /// the required audience/boundary keys carries no trust context + /// and cannot be run safely. The store SHALL reject it loudly — exclude it + /// from Get/List and log an error — never coercing a substitute + /// audience. + /// + [Fact] + public void Legacy_job_without_trust_fields_is_rejected() + { + // Authentic legacy shape: camelCase keys, enums as strings, no audience or boundary. + var jobId = "legacy-job-001"; + var legacyJson = $$""" + { + "id": "{{jobId}}", + "command": "make build", + "sessionId": "C0TEST/1712000000.000001", + "rationale": "Build the project artifacts.", + "status": "Pending", + "timeoutSeconds": 600, + "startedAtMs": 0 + } + """; + + var filePath = Path.Combine(_paths.JobsDirectory, $"{Uri.EscapeDataString(jobId)}.json"); + File.WriteAllText(filePath, legacyJson); + + var logger = new CapturingJobLogger(); + var store = new BackgroundJobDefinitionStore(_paths, logger); + + // Rejected — not coerced to a substitute audience. + Assert.Null(store.Get(new BackgroundJobId(jobId))); + Assert.Empty(store.List()); + + // Loud — an error naming the document and the missing fields was logged. + Assert.NotEmpty(logger.Errors); + Assert.Contains(logger.Errors, e => e.Contains(jobId) && e.Contains("audience")); + } + + /// + /// Positive control: a current document with explicit Audience and Boundary round-trips + /// correctly through a fresh store instance (Save then re-read). + /// + [Fact] + public void Current_job_with_trust_fields_roundtrips_exact_values() + { + var store = new BackgroundJobDefinitionStore(_paths); + var jobId = "roundtrip-job-001"; + + store.Save(new BackgroundJobDefinition + { + Id = jobId, + Command = "dotnet test", + SessionId = "C0ABC/1712000000.000001", + Rationale = "Run the test suite.", + Status = BackgroundJobStatus.Pending, + TimeoutSeconds = 300, + Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.TeamBoundary, + OriginChannelType = Netclaw.Actors.Channels.ChannelType.Slack + }); + + // Re-open from a fresh store instance to exercise deserialization + var freshStore = new BackgroundJobDefinitionStore(_paths); + var loaded = freshStore.Get(new BackgroundJobId(jobId)); + + Assert.NotNull(loaded); + Assert.Equal(TrustAudience.Team, loaded!.Audience); + Assert.Equal(SecurityPolicyDefaults.TeamBoundary, loaded.Boundary); + Assert.Equal(jobId, loaded.Id); + Assert.Equal("dotnet test", loaded.Command); + Assert.Equal("C0ABC/1712000000.000001", loaded.SessionId); + } + + public void Dispose() + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } +} + +/// +/// Capturing that records formatted messages by level. +/// Used to verify the store logs a loud error when it rejects a legacy document. +/// +internal sealed class CapturingJobLogger : ILogger +{ + public List Warnings { get; } = []; + public List Errors { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var message = formatter(state, exception); + if (logLevel >= LogLevel.Error) + Errors.Add(message); + else if (logLevel == LogLevel.Warning) + Warnings.Add(message); + } +} diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs index 529558701..e4615f1ee 100644 --- a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs @@ -155,7 +155,7 @@ public async Task CancelRunningJob_ViaCheckBackgroundJobTool() }; var context = new ToolExecutionContext("C0123ABC/1712000000.000001", "/tmp") { - Audience = TrustAudience.Personal.ToString(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.PersonalBoundary }; diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs index e31100c6c..6def8b45c 100644 --- a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs +++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs @@ -144,4 +144,59 @@ await AwaitAssertAsync(() => return Task.CompletedTask; }, duration: TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); } + + [Fact] + public async Task StartupReconciliation_EmitsAlert_ForLegacyJobMissingTrustFields() + { + const string jobId = "legacy-job-alert"; + var filePath = Path.Combine(_dir.Path, "jobs", $"{Uri.EscapeDataString(jobId)}.json"); + File.WriteAllText(filePath, $$""" + { + "id": "{{jobId}}", + "command": "echo hello", + "sessionId": "test/thread", + "rationale": "legacy job", + "status": "Pending", + "timeoutSeconds": 60, + "startedAtMs": 0 + } + """); + + var paths = new NetclawPaths(_dir.Path); + var store = new BackgroundJobDefinitionStore(paths); + var sink = new RecordingNotificationSink(); + + Sys.ActorOf( + Props.Create(() => new BackgroundJobManagerActor(store, TimeProvider.System, sink)), + "legacy-job-alert-manager"); + + await AwaitAssertAsync(() => + { + Assert.Contains(sink.Alerts, alert => + alert.Category == AlertType.BackgroundJobSchemaDropped + && alert.Summary.Contains(jobId, StringComparison.Ordinal)); + return Task.CompletedTask; + }, duration: TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + } + + private sealed class RecordingNotificationSink : IOperationalNotificationSink + { + private readonly object _sync = new(); + private readonly List _alerts = []; + + public IReadOnlyList Alerts + { + get + { + lock (_sync) + return _alerts.ToArray(); + } + } + + public void Emit(OperationalAlert alert) + { + lock (_sync) + _alerts.Add(alert); + } + } } diff --git a/src/Netclaw.Actors.Tests/Jobs/CheckBackgroundJobToolTests.cs b/src/Netclaw.Actors.Tests/Jobs/CheckBackgroundJobToolTests.cs index b2a7ee0f0..d5922412d 100644 --- a/src/Netclaw.Actors.Tests/Jobs/CheckBackgroundJobToolTests.cs +++ b/src/Netclaw.Actors.Tests/Jobs/CheckBackgroundJobToolTests.cs @@ -20,7 +20,7 @@ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IService private ToolExecutionContext MakeContext(string sessionId = "test/thread") => new(sessionId, "/tmp") { - Audience = TrustAudience.Personal.ToString(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.PersonalBoundary }; diff --git a/src/Netclaw.Actors.Tests/Memory/MemoryRedesignedEvalSuiteTests.cs b/src/Netclaw.Actors.Tests/Memory/MemoryRedesignedEvalSuiteTests.cs index a5b7165da..53401478d 100644 --- a/src/Netclaw.Actors.Tests/Memory/MemoryRedesignedEvalSuiteTests.cs +++ b/src/Netclaw.Actors.Tests/Memory/MemoryRedesignedEvalSuiteTests.cs @@ -273,7 +273,7 @@ await _store.ApplyCurationBatchAsync( ["Query"] = "stir trek hotel", ["Limit"] = 5 }, - new ToolExecutionContext("slack/thread-2", null), + new ToolExecutionContext("slack/thread-2", null) { Audience = TrustAudience.Personal }, CancellationToken.None); Assert.Contains("Hotel options", search); @@ -450,7 +450,7 @@ await _store.ApplyCurationBatchAsync( ["Query"] = "stir trek shuttle", ["Limit"] = 5 }, - new ToolExecutionContext("slack/thread-3", null), + new ToolExecutionContext("slack/thread-3", null) { Audience = TrustAudience.Personal }, CancellationToken.None); var debug = await tool.ExecuteAsync( new Dictionary @@ -459,7 +459,7 @@ await _store.ApplyCurationBatchAsync( ["Limit"] = 5, ["IncludeStale"] = true }, - new ToolExecutionContext("slack/thread-3", null), + new ToolExecutionContext("slack/thread-3", null) { Audience = TrustAudience.Personal }, CancellationToken.None); Assert.Equal("No memories found.", normal); @@ -562,7 +562,7 @@ await _store.ApplyCurationBatchAsync( ["Query"] = "stir trek hotel", ["Limit"] = 5 }, - new ToolExecutionContext("slack/thread-report", null), + new ToolExecutionContext("slack/thread-report", null) { Audience = TrustAudience.Personal }, CancellationToken.None); var staleDebug = await searchTool.ExecuteAsync( new Dictionary @@ -571,7 +571,7 @@ await _store.ApplyCurationBatchAsync( ["Limit"] = 5, ["IncludeStale"] = true }, - new ToolExecutionContext("slack/thread-report", null), + new ToolExecutionContext("slack/thread-report", null) { Audience = TrustAudience.Personal }, CancellationToken.None); var autoRecallHitRate = auto.Items.Any(x => x.Content.Contains("United Airlines", StringComparison.Ordinal)) ? 1.0 : 0.0; diff --git a/src/Netclaw.Actors.Tests/Memory/SqliteMemoryPolicyScopeTests.cs b/src/Netclaw.Actors.Tests/Memory/SqliteMemoryPolicyScopeTests.cs index f183a8330..a1b30a5bc 100644 --- a/src/Netclaw.Actors.Tests/Memory/SqliteMemoryPolicyScopeTests.cs +++ b/src/Netclaw.Actors.Tests/Memory/SqliteMemoryPolicyScopeTests.cs @@ -79,7 +79,7 @@ await _store.ApplyCurationBatchAsync( var tool = new SqliteGetMemoriesTool(_store, logger: NullLogger.Instance); var context = new ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Public.ToWireValue(), + Audience = TrustAudience.Public, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary }; @@ -99,7 +99,7 @@ public async Task StoreMemory_uses_explicit_context_policy_scope() var tool = new SqliteStoreMemoryTool(sink, NullLogger.Instance); var context = new ToolExecutionContext("slack/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.PersonalBoundary }; diff --git a/src/Netclaw.Actors.Tests/Memory/SqliteMemoryToolsTests.cs b/src/Netclaw.Actors.Tests/Memory/SqliteMemoryToolsTests.cs index 867f20dce..9422f408b 100644 --- a/src/Netclaw.Actors.Tests/Memory/SqliteMemoryToolsTests.cs +++ b/src/Netclaw.Actors.Tests/Memory/SqliteMemoryToolsTests.cs @@ -105,7 +105,7 @@ await _store.ApplyCurationBatchAsync( ["Query"] = "stir trek hotel", ["Limit"] = 5 }, - new ToolExecutionContext("slack/thread-1", sessionDirectory: null), + new ToolExecutionContext("slack/thread-1", sessionDirectory: null) { Audience = TrustAudience.Personal }, CancellationToken.None); Assert.Contains("Conference destination", result); @@ -149,7 +149,7 @@ await _store.ApplyCurationBatchAsync( var tool = new SqliteGetMemoriesTool(_store, _timeProvider); var result = await tool.ExecuteAsync( new Dictionary { ["Ids"] = "rec:rec-stale" }, - new ToolExecutionContext("slack/thread-1", sessionDirectory: null), + new ToolExecutionContext("slack/thread-1", sessionDirectory: null) { Audience = TrustAudience.Personal }, CancellationToken.None); Assert.Contains("class=evidence", result); @@ -196,7 +196,7 @@ await _store.ApplyCurationBatchAsync( ["Query"] = "stir trek parking", ["Limit"] = 5 }, - new ToolExecutionContext("slack/thread-1", sessionDirectory: null), + new ToolExecutionContext("slack/thread-1", sessionDirectory: null) { Audience = TrustAudience.Personal }, CancellationToken.None); var debug = await tool.ExecuteAsync( @@ -206,7 +206,7 @@ await _store.ApplyCurationBatchAsync( ["Limit"] = 5, ["IncludeStale"] = true }, - new ToolExecutionContext("slack/thread-1", sessionDirectory: null), + new ToolExecutionContext("slack/thread-1", sessionDirectory: null) { Audience = TrustAudience.Personal }, CancellationToken.None); Assert.Equal("No memories found.", normal); @@ -269,7 +269,7 @@ await _store.ApplyCurationBatchAsync( var tool = new SqliteGetMemoriesTool(_store); var result = await tool.ExecuteAsync( new Dictionary { ["Ids"] = "doc:doc-team,doc:doc-personal" }, - new ToolExecutionContext("slack/thread-1", sessionDirectory: null), + new ToolExecutionContext("slack/thread-1", sessionDirectory: null) { Audience = TrustAudience.Team }, CancellationToken.None); Assert.Contains("Repository name", result); diff --git a/src/Netclaw.Actors.Tests/Reminders/ReminderDefinitionStoreTests.cs b/src/Netclaw.Actors.Tests/Reminders/ReminderDefinitionStoreTests.cs index d62697d86..4a2c76afd 100644 --- a/src/Netclaw.Actors.Tests/Reminders/ReminderDefinitionStoreTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/ReminderDefinitionStoreTests.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Microsoft.Extensions.Logging; using Netclaw.Actors.Reminders; using Netclaw.Configuration; using Xunit; @@ -55,6 +56,8 @@ public void Constructor_keeps_valid_definitions_while_pruning_invalid_files() Type = ReminderScheduleType.OneShot, FireAt = now.AddMinutes(30) }, + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now, @@ -72,9 +75,132 @@ public void Constructor_keeps_valid_definitions_while_pruning_invalid_files() Assert.False(File.Exists(invalidPath)); } + /// + /// Regression test for issue #994. A pre-#994 reminder document missing the + /// required audience/boundary keys carries no trust context + /// and cannot be run safely. The store SHALL reject it loudly — exclude it + /// from Get/List and log an error — and SHALL preserve the + /// file (operator-authored data, not corrupt JSON), never coercing a + /// substitute audience. + /// + [Fact] + public void Legacy_reminder_without_trust_fields_is_rejected_and_preserved() + { + // Authentic legacy shape: camelCase keys, no audience or boundary, enums as strings. + const long fireAtMs = 1_800_000_000_000L; // some arbitrary future timestamp + var reminderId = "legacy-no-trust"; + var legacyJson = $$""" + { + "id": "{{reminderId}}", + "title": "Legacy Check", + "schedule": { + "type": "OneShot", + "fireAtMs": {{fireAtMs}} + }, + "instructions": "Check the build status.", + "delivery": { + "kind": "None" + }, + "deliveryRequired": true, + "deliveryInstructions": "Post result to channel.", + "enabled": true, + "createdBy": "alice", + "createdAtMs": 1700000000000, + "updatedAtMs": 1700000000000 + } + """; + + var filePath = Path.Combine(_paths.RemindersDirectory, $"{Uri.EscapeDataString(reminderId)}.json"); + File.WriteAllText(filePath, legacyJson); + + var logger = new CapturingLogger(); + var store = new ReminderDefinitionStore(_paths, logger); + + // Rejected — not coerced to a substitute audience. + Assert.Null(store.Get(new ReminderId(reminderId))); + Assert.Empty(store.List()); + + // Preserved — a legacy doc is operator data, not corrupt JSON; the + // operator must be able to repair or remove it. + Assert.True(File.Exists(filePath), "Legacy reminder file must NOT be deleted."); + + // Loud — an error naming the document and the missing fields was logged. + Assert.NotEmpty(logger.Errors); + Assert.Contains(logger.Errors, e => e.Contains(reminderId) && e.Contains("audience")); + } + + /// + /// Positive control: a current document with explicit Audience and Boundary round-trips + /// correctly through a fresh store (Save then re-read). + /// + [Fact] + public void Current_reminder_with_trust_fields_roundtrips_exact_values() + { + var store = new ReminderDefinitionStore(_paths); + var now = TimeProvider.System.GetUtcNow(); + var id = "roundtrip-trust"; + + store.Save(new ReminderDefinition + { + Id = id, + Title = "Round-trip check", + Instructions = "Do the thing.", + Delivery = new ReminderDelivery { Kind = DeliveryKind.None }, + Schedule = new ReminderSchedule + { + Type = ReminderScheduleType.OneShot, + FireAt = now.AddHours(1) + }, + Audience = TrustAudience.Personal, + Boundary = SecurityPolicyDefaults.PersonalBoundary, + Enabled = true, + CreatedBy = "bob", + CreatedAt = now, + UpdatedAt = now + }); + + // Re-open from a fresh store instance to exercise deserialization + var freshStore = new ReminderDefinitionStore(_paths); + var loaded = freshStore.Get(new ReminderId(id)); + + Assert.NotNull(loaded); + Assert.Equal(TrustAudience.Personal, loaded!.Audience); + Assert.Equal(SecurityPolicyDefaults.PersonalBoundary, loaded.Boundary); + Assert.Equal(id, loaded.Id); + Assert.Equal("Round-trip check", loaded.Title); + } + public void Dispose() { if (Directory.Exists(_basePath)) Directory.Delete(_basePath, recursive: true); } } + +/// +/// Capturing that records formatted messages by level. +/// Used to verify the store logs a loud error when it rejects a legacy document. +/// +internal sealed class CapturingLogger : ILogger +{ + public List Warnings { get; } = []; + public List Errors { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var message = formatter(state, exception); + if (logLevel >= LogLevel.Error) + Errors.Add(message); + else if (logLevel == LogLevel.Warning) + Warnings.Add(message); + } +} diff --git a/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs b/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs index 722204838..9a7404a77 100644 --- a/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs @@ -254,6 +254,7 @@ private static ReminderDefinition CreateDefinition(string id) FireAt = now.AddHours(1) }, Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.TeamBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now, @@ -305,6 +306,7 @@ private sealed class ScriptedSessionPipeline( Func> outputFactory) : ISessionPipeline { public SessionPipelineOptions? CapturedOptions { get; private set; } + public ChannelInput? CapturedInput { get; private set; } public Task CreateAsync( SessionId sessionId, @@ -316,13 +318,13 @@ public Task CreateAsync( var killSwitch = KillSwitches.Shared($"scripted-{sessionId.Value}"); - var input = Sink.Ignore() + var captureInputSink = Sink.ForEach(ci => CapturedInput = ci) .MapMaterializedValue(_ => NotUsed.Instance); var output = Source.From(outputFactory(sessionId).ToList()) .Via(killSwitch.Flow()); - return Task.FromResult(new MaterializedSession(input, output, killSwitch)); + return Task.FromResult(new MaterializedSession(captureInputSink, output, killSwitch)); } public Task SendFeedbackAsync(IWithSessionId feedback, CancellationToken ct = default) => @@ -351,57 +353,62 @@ public async Task Execution_uses_definition_audience_when_set() await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(pipeline.CapturedOptions); - Assert.Equal(TrustAudience.Personal, pipeline.CapturedOptions!.DefaultAudience); + Assert.NotNull(pipeline.CapturedInput); + Assert.Equal(TrustAudience.Personal, pipeline.CapturedInput!.Audience); } + // Note: Execution_fails_when_definition_audience_missing was removed in issue #994. + // Audience is now required TrustAudience (non-nullable), so the missing-audience + // failure path no longer exists in ReminderExecutionActor. The type system enforces + // that every ReminderDefinition carries an explicit Audience at construction time. + [Fact] - public async Task Execution_fails_when_definition_audience_missing() + public async Task Execution_uses_stored_audience_directly() { var pipeline = new ScriptedSessionPipeline(sessionId => [ new TurnCompleted { SessionId = sessionId, TurnNumber = 1 } ]); - var definition = CreateDefinition("audience-fallback") with + var definition = CreateDefinition("audience-team-default") with { DeliveryInstructions = string.Empty, - Audience = null + Audience = TrustAudience.Team }; var probe = CreateTestProbe(); Sys.ActorOf( Props.Create(() => new ParentProxy(probe.Ref, definition, pipeline, _historyStore)), - "exec-audience-fallback"); + "exec-audience-team-default"); - var completed = await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); - Assert.False(completed.Success); - Assert.Contains("missing a persisted execution audience", completed.ErrorMessage); - Assert.Null(pipeline.CapturedOptions); + Assert.NotNull(pipeline.CapturedInput); + Assert.Equal(TrustAudience.Team, pipeline.CapturedInput!.Audience); } [Fact] - public async Task Execution_uses_stored_audience_directly() + public async Task Execution_uses_persisted_boundary_for_non_current_session_reminders() { var pipeline = new ScriptedSessionPipeline(sessionId => [ new TurnCompleted { SessionId = sessionId, TurnNumber = 1 } ]); - var definition = CreateDefinition("audience-team-default") with + var definition = CreateDefinition("boundary-preserved") with { - DeliveryInstructions = string.Empty, - Audience = TrustAudience.Team + Boundary = SecurityPolicyDefaults.PublicBoundary, + Delivery = new ReminderDelivery { Kind = DeliveryKind.None }, + DeliveryInstructions = string.Empty }; var probe = CreateTestProbe(); Sys.ActorOf( Props.Create(() => new ParentProxy(probe.Ref, definition, pipeline, _historyStore)), - "exec-audience-team-default"); + "exec-boundary-preserved"); await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(pipeline.CapturedOptions); - Assert.Equal(TrustAudience.Team, pipeline.CapturedOptions!.DefaultAudience); + Assert.NotNull(pipeline.CapturedInput); + Assert.Equal(SecurityPolicyDefaults.PublicBoundary, pipeline.CapturedInput!.Boundary); } // ── History integration tests ───────────────────────────────────────────── diff --git a/src/Netclaw.Actors.Tests/Reminders/ReminderManagerActorTests.cs b/src/Netclaw.Actors.Tests/Reminders/ReminderManagerActorTests.cs index 3723b83c8..23411000c 100644 --- a/src/Netclaw.Actors.Tests/Reminders/ReminderManagerActorTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/ReminderManagerActorTests.cs @@ -201,6 +201,8 @@ public async Task Reconcile_deletes_zombie_oneshot_reminders() Type = ReminderScheduleType.OneShot, FireAt = now.AddHours(-1) }, + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now.AddHours(-2), @@ -235,7 +237,8 @@ public async Task Save_authorizes_requested_audience_against_source_authority( var manager = await GetManagerAsync(); var definition = CreateDefinition($"audience-{requestedAudience}-{sourceAudience}", "Check audience") with { - Audience = requestedAudience + Audience = requestedAudience, + Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(requestedAudience) }; var response = await manager.Ask( @@ -294,12 +297,17 @@ public async Task Save_rejects_expiration_for_oneshot_reminders() } [Fact] - public async Task Save_omitted_audience_persists_source_audience() + public async Task Save_explicit_audience_within_source_authority_is_persisted() { + // Audience is now required non-nullable on ReminderDefinition (#994 type-stiffening). + // The definition specifies an explicit audience; the manager persists it when it does + // not exceed the source authority. Here the definition requests Public and source + // authority is Team — Public <= Team, so it should succeed and Public is stored. var manager = await GetManagerAsync(); - var definition = CreateDefinition("inherit-source", "Check inheritance") with + var definition = CreateDefinition("explicit-audience", "Check explicit audience") with { - Audience = null + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary }; var response = await manager.Ask( @@ -313,7 +321,104 @@ public async Task Save_omitted_audience_persists_source_audience() var saved = _definitionStore.Get(response.Id); Assert.NotNull(saved); - Assert.Equal(TrustAudience.Team, saved!.Audience); + Assert.Equal(TrustAudience.Public, saved!.Audience); + } + + [Fact] + public async Task Save_rejects_boundary_that_exceeds_requested_audience() + { + var manager = await GetManagerAsync(); + var definition = CreateDefinition("public-boundary-mismatch", "Check mismatch") with + { + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PersonalBoundary + }; + + var response = await manager.Ask( + new SaveReminderCommand( + definition, + Authorization: new ReminderAudienceAuthorizationContext(TrustAudience.Personal, "test")), + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); + + Assert.False(response.Success); + Assert.Equal(ReminderSaveError.Validation, response.Error); + Assert.Contains("not allowed for audience 'public'", response.ErrorMessage); + } + + [Fact] + public async Task Save_allows_narrower_boundary_than_requested_audience() + { + var manager = await GetManagerAsync(); + var definition = CreateDefinition("narrow-boundary", "Check narrow boundary") with + { + Audience = TrustAudience.Personal, + Boundary = SecurityPolicyDefaults.PublicBoundary + }; + + var response = await manager.Ask( + new SaveReminderCommand( + definition, + Authorization: new ReminderAudienceAuthorizationContext(TrustAudience.Personal, "test")), + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); + + Assert.True(response.Success); + + var saved = _definitionStore.Get(response.Id); + Assert.NotNull(saved); + Assert.Equal(SecurityPolicyDefaults.PublicBoundary, saved!.Boundary); + } + + [Fact] + public async Task Startup_emits_alert_for_legacy_reminder_missing_trust_fields() + { + const string reminderId = "legacy-reminder-alert"; + var now = TimeProvider.System.GetUtcNow(); + var paths = new NetclawPaths(_basePath); + paths.EnsureDirectoriesExist(); + var filePath = Path.Combine(paths.RemindersDirectory, $"{Uri.EscapeDataString(reminderId)}.json"); + File.WriteAllText(filePath, $$""" + { + "id": "{{reminderId}}", + "title": "Legacy Reminder", + "instructions": "Check status", + "delivery": { "kind": "None" }, + "schedule": { "type": "OneShot", "fireAtMs": {{now.AddHours(1).ToUnixTimeMilliseconds()}} }, + "enabled": true, + "createdBy": "test", + "createdAtMs": {{now.ToUnixTimeMilliseconds()}}, + "updatedAtMs": {{now.ToUnixTimeMilliseconds()}} + } + """); + + var store = new ReminderDefinitionStore(paths); + var sink = new TestNotificationSink(); + var pipeline = new SessionPipeline( + Sys, + new RequiredActor(ActorRegistry.For(Sys)), + new NetclawPaths(Path.Combine(Path.GetTempPath(), $"netclaw-test-{Guid.NewGuid():N}"))); + var defaults = new EffectivePolicyDefaults( + DeploymentPosture.Team, TrustAudience.Team, ShellExecutionMode.Off, false); + + Sys.ActorOf( + Props.Create(() => new ReminderManagerActor( + pipeline, + defaults, + new SchedulingConfig(), + TimeProvider.System, + store, + new ReminderHistoryStore(paths), + sink)), + "legacy-reminder-alert-manager"); + + await AwaitAssertAsync(() => + { + Assert.Contains(sink.Alerts, alert => + alert.Category == AlertType.ReminderSchemaDropped + && alert.Summary.Contains(reminderId, StringComparison.Ordinal)); + return Task.CompletedTask; + }, duration: TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); } /// @@ -363,6 +468,7 @@ public async Task Mode_B_reminder_dispatches_to_resolved_gateway_and_completes_o FireAt = now.AddHours(1) }, Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.SlackWorkspaceBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now, @@ -438,6 +544,7 @@ public async Task Mode_B_discord_reminder_dispatches_to_resolved_gateway_and_com FireAt = now.AddHours(1) }, Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now, @@ -584,6 +691,8 @@ public async Task Reconcile_disables_expired_recurring_reminders() FireAt = now.AddMinutes(30) }, ExpiresAt = now.AddHours(-1), + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now.AddDays(-1), @@ -627,6 +736,8 @@ public async Task Expired_reminder_disabled_on_fire_without_executing() FireAt = now }, ExpiresAt = now.AddSeconds(-1), + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now.AddDays(-1), @@ -707,6 +818,7 @@ await AwaitAssertAsync(async () => }, ExpiresAt = now.AddMilliseconds(200), Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.TeamBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now, @@ -925,6 +1037,8 @@ private static ReminderDefinition CreateDefinition(string name, string instructi Type = ReminderScheduleType.OneShot, FireAt = now.AddHours(1) }, + Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.TeamBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now, @@ -958,6 +1072,7 @@ private static ReminderDefinition CreateCurrentSessionDefinition( FireAt = now.AddMinutes(5) }, Audience = TrustAudience.Team, + Boundary = SecurityPolicyDefaults.TeamBoundary, Enabled = true, CreatedBy = "test", CreatedAt = now, diff --git a/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs b/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs index 8e0052fd7..3561fd9f3 100644 --- a/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs @@ -212,7 +212,7 @@ public async Task Mode_B_self_targeting_persists_session_and_origin_channel_type var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); var context = new ToolExecutionContext("C0123ABC/1234567890.123456", null) { - Audience = "team", + Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.SlackWorkspaceBoundary, ChannelType = "slack" }; @@ -258,7 +258,7 @@ public async Task Mode_B_discord_self_targeting_persists_session_and_origin_chan var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); var context = new ToolExecutionContext("129847561203948576/130111223344556677", null) { - Audience = "team", + Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "discord" }; @@ -300,7 +300,7 @@ public async Task Mode_B_rejected_for_unsupported_origin_channel_type() var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); var context = new ToolExecutionContext("webhook/delivery-1", null) { - Audience = "personal", + Audience = TrustAudience.Personal, ChannelType = "webhook" }; @@ -327,7 +327,7 @@ public async Task Mode_B_rejected_when_channel_type_missing_from_context() // Session id present but ChannelType is null — pre-v0.16 context // shape or an unusual caller. Fail loud, do not silently persist a // headless reminder that would drop on the floor at fire time. - var context = new ToolExecutionContext("C0123ABC/1234567890.123456", null); + var context = new ToolExecutionContext("C0123ABC/1234567890.123456", null) { Audience = TrustAudience.Personal }; var result = await tool.ExecuteAsync(new Dictionary { @@ -367,7 +367,9 @@ public async Task Headless_reminder_with_no_session_and_no_target_persists_with_ Assert.Null(cmd.Definition.Delivery.SessionId); Assert.Null(cmd.Definition.Delivery.OriginChannelType); Assert.Null(cmd.Definition.Delivery.Address); - Assert.Null(cmd.Definition.Boundary); + // Boundary is now required non-nullable (#994): when no source context is present, + // the tool fills it with the fail-closed PublicBoundary default. + Assert.Equal(SecurityPolicyDefaults.PublicBoundary, cmd.Definition.Boundary); probe.Reply(new ReminderSavedResponse( new ReminderId(cmd.Definition.Id), @@ -419,7 +421,7 @@ public async Task Sets_audience_when_provided() var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); var context = new ToolExecutionContext("slack/thread-1", null) { - Audience = "personal", + Audience = TrustAudience.Personal, ChannelType = "slack" }; @@ -451,6 +453,46 @@ public async Task Sets_audience_when_provided() await execution; } + [Fact] + public async Task Downscoped_audience_rewrites_boundary_to_requested_audience_scope() + { + var probe = CreateTestProbe(); + var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); + var context = new ToolExecutionContext("signalr/thread-1", null) + { + Audience = TrustAudience.Personal, + Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, + ChannelType = "signalr" + }; + + var execution = Task.Run(async () => + { + var result = await tool.ExecuteAsync(new Dictionary + { + ["Id"] = "downscope-boundary", + ["Name"] = "downscope-boundary", + ["Prompt"] = "check status", + ["ScheduleType"] = "once", + ["Schedule"] = "30m", + ["Audience"] = "public", + ["DeliveryKind"] = "none" + }, context); + return result; + }); + + var cmd = await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(TrustAudience.Public, cmd.Definition.Audience); + Assert.Equal(SecurityPolicyDefaults.PublicBoundary, cmd.Definition.Boundary); + + probe.Reply(new ReminderSavedResponse( + new ReminderId(cmd.Definition.Id), + cmd.Definition.Title, + Success: true, + NextFire: _timeProvider.GetUtcNow().AddMinutes(30))); + + await execution; + } + [Fact] public async Task Rejects_invalid_audience() { @@ -480,7 +522,7 @@ public async Task Omitted_audience_inherits_source_audience() var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); var context = new ToolExecutionContext("slack/thread-1", null) { - Audience = "team", + Audience = TrustAudience.Team, ChannelType = "slack" }; @@ -499,7 +541,9 @@ public async Task Omitted_audience_inherits_source_audience() }); var cmd = await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); - Assert.Null(cmd.Definition.Audience); + // Audience is now required non-nullable (#994): when not specified in tool args, + // the tool fills it from the source context audience before sending the command. + Assert.Equal(TrustAudience.Team, cmd.Definition.Audience); Assert.Equal(TrustAudience.Team, cmd.Authorization?.SourceAudience); probe.Reply(new ReminderSavedResponse( @@ -511,30 +555,9 @@ public async Task Omitted_audience_inherits_source_audience() await execution; } - [Fact] - public async Task Rejects_invalid_source_audience_context() - { - var probe = CreateTestProbe(); - var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); - var context = new ToolExecutionContext("slack/thread-1", null) - { - Audience = "superadmin", - ChannelType = "slack" - }; - - var result = await tool.ExecuteAsync(new Dictionary - { - ["Id"] = "bad-source-audience", - ["Name"] = "bad-source-audience", - ["Prompt"] = "Test", - ["ScheduleType"] = "once", - ["Schedule"] = "1h", - ["DeliveryKind"] = "current_session" - }, context, TestContext.Current.CancellationToken); - - Assert.Contains("Invalid source audience", result); - await probe.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); - } + // Rejects_invalid_source_audience_context was deleted: source audience is now a parsed + // TrustAudience? on ToolExecutionContext — wire-string parse failure is rejected upstream, + // so there is no "invalid source audience" path reachable inside SetReminderTool. [Fact] public async Task Manager_validation_failure_returns_error_prefix() @@ -543,7 +566,7 @@ public async Task Manager_validation_failure_returns_error_prefix() var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); var context = new ToolExecutionContext("slack/thread-1", null) { - Audience = "team", + Audience = TrustAudience.Team, ChannelType = "slack" }; @@ -585,7 +608,7 @@ public async Task Manager_validation_failure_returns_error_prefix_for_discord_so var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); var context = new ToolExecutionContext("129847561203948576/130111223344556677", null) { - Audience = "public", + Audience = TrustAudience.Public, ChannelType = "discord" }; @@ -748,7 +771,7 @@ public async Task Mode_B_session_reentry_skips_resolver() var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig(), [resolver]); var context = new ToolExecutionContext("C0123ABC/1234567890.123456", null) { - Audience = "team", + Audience = TrustAudience.Team, ChannelType = "slack" }; diff --git a/src/Netclaw.Actors.Tests/Sessions/BackgroundJobSessionStateTests.cs b/src/Netclaw.Actors.Tests/Sessions/BackgroundJobSessionStateTests.cs index 05a6dc66d..d69c80119 100644 --- a/src/Netclaw.Actors.Tests/Sessions/BackgroundJobSessionStateTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/BackgroundJobSessionStateTests.cs @@ -50,7 +50,9 @@ public void TurnRecorded_WithSourceBackgroundJobId_DedupAndRemovesActive() JobId = "abc123", Command = "make build", Rationale = "building project", - StartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + StartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary }; var state = SessionState.Empty.TrackBackgroundJob(jobKey, info); @@ -81,7 +83,9 @@ public void Compaction_Preserves_ActiveJobsAndDedupSet() JobId = "def456", Command = "make test", Rationale = "running tests", - StartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + StartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary }; var state = SessionState.Empty.TrackBackgroundJob(jobKey, info); diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs index bf94282d6..b2d9fc19a 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs @@ -135,10 +135,8 @@ await sessionManager.Ask(new JoinSession Audience = TrustAudience.Public, Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(TrustAudience.Public), Principal = PrincipalClassification.VerifiedAutomation, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Public, SourceKind = "issues" }, ReceivedAt = _timeProvider.GetUtcNow() @@ -188,10 +186,8 @@ await sessionManager.Ask(new SendUserMessage Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(TrustAudience.Team), Principal = PrincipalClassification.TrustedInternal, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Trusted, SourceKind = "slack" }, ReceivedAt = _timeProvider.GetUtcNow() @@ -1747,10 +1743,8 @@ await sessionManager.Ask(new SendUserMessage Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(TrustAudience.Personal), Principal = PrincipalClassification.VerifiedAutomation, - Provenance = new SourceProvenance + Provenance = new SourceProvenance(TransportAuthenticity.LocalProcess, PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.LocalProcess, - PayloadTaint = PayloadTaint.Trusted, SourceKind = "reminder" }, ReceivedAt = _timeProvider.GetUtcNow(), diff --git a/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs b/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs index 8b233c239..b8e3c204e 100644 --- a/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs @@ -7,11 +7,13 @@ using Akka.Hosting; using Akka.Hosting.TestKit; using Microsoft.Extensions.AI; +using Netclaw.Actors.Channels; using Netclaw.Actors.Jobs; using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; using Netclaw.Actors.Sessions.Pipelines; using Netclaw.Actors.Tools; +using Netclaw.Configuration; using Netclaw.Tools; using Xunit; @@ -87,7 +89,7 @@ public async Task ExplicitBackground_RoutesShellToBackgroundManager() await SessionToolExecutionPipeline.ExecuteToolsAsync( executor, toolCalls, new SessionId("test/background"), - source: null, + source: TestMessageSource(), auditLogger: null, timeProvider: TimeProvider.System, sessionDir: Path.GetTempPath(), @@ -135,7 +137,7 @@ public async Task ExplicitBackground_PreservesWorkingDirectory() await SessionToolExecutionPipeline.ExecuteToolsAsync( executor, toolCalls, new SessionId("test/background-dir"), - source: null, + source: TestMessageSource(), auditLogger: null, timeProvider: TimeProvider.System, sessionDir: Path.GetTempPath(), @@ -241,6 +243,19 @@ await jobManagerProbe.ExpectNoMsgAsync( cancellationToken: TestContext.Current.CancellationToken); } + // Background-job submission now requires a trust context — source cannot be null. + // This factory produces a minimal Personal-audience source for tests that route + // to the background job manager and don't need to assert on trust-context values. + private static MessageSource TestMessageSource() => new() + { + ChannelType = ChannelType.Tui, + SenderId = "test-user", + Audience = TrustAudience.Personal, + Boundary = SecurityPolicyDefaults.PersonalBoundary, + Principal = PrincipalClassification.TrustedInternal, + Provenance = new SourceProvenance(TransportAuthenticity.LocalProcess, PayloadTaint.Trusted) + }; + private sealed class EchoExecutor : IToolExecutor { public Task AuthorizeAsync(FunctionCallContent toolCall, ToolExecutionContext? context = null, CancellationToken ct = default) diff --git a/src/Netclaw.Actors.Tests/Sessions/Pipelines/SessionRecallManagerTests.cs b/src/Netclaw.Actors.Tests/Sessions/Pipelines/SessionRecallManagerTests.cs index 58fabcf9a..6bbee1ec6 100644 --- a/src/Netclaw.Actors.Tests/Sessions/Pipelines/SessionRecallManagerTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/Pipelines/SessionRecallManagerTests.cs @@ -23,7 +23,9 @@ public void ResolveForTurn_ReturnsEmptyForPublicAudience() ChannelType = ChannelType.Slack, SenderId = "U123", Audience = TrustAudience.Public, - Boundary = SecurityPolicyDefaults.PublicBoundary + Boundary = SecurityPolicyDefaults.PublicBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) }; var state = SessionState.Empty.AddUserMessage("Tell me a secret"); @@ -48,7 +50,9 @@ public void ResolveForTurn_ReturnsEmptyWhenMemoryDisabled() ChannelType = ChannelType.Tui, SenderId = "local-user", Audience = TrustAudience.Personal, - Boundary = SecurityPolicyDefaults.PersonalBoundary + Boundary = SecurityPolicyDefaults.PersonalBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) }; var state = SessionState.Empty.AddUserMessage("Search for memories"); @@ -74,7 +78,9 @@ public void ResolveForTurn_InvokesCoordinatorForPersonalAudience() ChannelType = ChannelType.Tui, SenderId = "local-user", Audience = TrustAudience.Personal, - Boundary = SecurityPolicyDefaults.PersonalBoundary + Boundary = SecurityPolicyDefaults.PersonalBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Public) }; var state = SessionState.Empty.AddUserMessage("What do you remember about the project?"); diff --git a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs index 2aa8767c4..9550708db 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs @@ -288,7 +288,8 @@ await sessionManager.Ask(new JoinSession await sessionManager.Ask(new SendUserMessage { SessionId = sessionId, - Content = "/ops-route check daemon health" + Content = "/ops-route check daemon health", + Source = BuildPersonalSource() }, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); var text = await ExpectTextOutputAsync(subscriber, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -505,7 +506,7 @@ await sessionManager.Ask(new SendUserMessage Assert.Equal(2, _clientProvider.Compaction.CallCount); Assert.NotNull(_recordingFileReadTool); Assert.True(_recordingFileReadTool!.WasCalled); - Assert.Equal(TrustAudience.Team.ToWireValue(), _recordingFileReadTool.LastContext?.Audience); + Assert.Equal(TrustAudience.Team, _recordingFileReadTool.LastContext?.Audience); Assert.Equal(source.Boundary, _recordingFileReadTool.LastContext?.Boundary); } @@ -517,6 +518,8 @@ private static MessageSource BuildPersonalSource() SenderId = "test-user", Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.ResolveBoundaryFromChannelType(ChannelType.Tui.ToWireValue(), TrustAudience.Personal), + Principal = PrincipalClassification.Operator, + Provenance = new SourceProvenance(TransportAuthenticity.LocalProcess, PayloadTaint.Trusted), ReceivedAt = DateTimeOffset.UtcNow }; } @@ -529,6 +532,8 @@ private static MessageSource BuildReminderSource(string? reminderId = null) SenderId = "reminder-executor", Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.ResolveBoundaryFromChannelType(ChannelType.Reminder.ToWireValue(), TrustAudience.Team), + Principal = PrincipalClassification.VerifiedAutomation, + Provenance = new SourceProvenance(TransportAuthenticity.LocalProcess, PayloadTaint.Trusted), ReceivedAt = DateTimeOffset.UtcNow, ReminderId = reminderId }; diff --git a/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentToolTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentToolTests.cs index be7f3eaba..62a5caf6e 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentToolTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentToolTests.cs @@ -14,7 +14,7 @@ namespace Netclaw.Actors.Tests.SubAgents; public sealed class SpawnAgentToolTests : IDisposable { private static readonly ToolExecutionContext PersonalCtx = - new(null, null) { Audience = TrustAudience.Personal.ToWireValue() }; + new(null, null) { Audience = TrustAudience.Personal }; private readonly DisposableTempDir _dir = new(); private readonly NetclawPaths _paths; @@ -43,7 +43,7 @@ public async Task ExecuteAsync_ReturnsGenericDenialForPublicAudience() Visibility = SubAgentVisibility.UserFacing }); var tool = new SpawnAgentTool(registry, spawner: null!, _paths); - var publicCtx = new ToolExecutionContext(null, null) { Audience = TrustAudience.Public.ToWireValue() }; + var publicCtx = new ToolExecutionContext(null, null) { Audience = TrustAudience.Public }; var result = await tool.ExecuteAsync(new Dictionary { @@ -77,7 +77,8 @@ public async Task ExecuteAsync_DefaultsToPublicWhenAudienceUnparseable() { var registry = new SubAgentDefinitionRegistry(); var tool = new SpawnAgentTool(registry, spawner: null!, _paths); - var badCtx = new ToolExecutionContext(null, null) { Audience = "superadmin" }; + // Audience is non-nullable; Public is the minimum-privilege audience, equivalent to the old null/unset default. + var badCtx = new ToolExecutionContext(null, null) { Audience = TrustAudience.Public }; var result = await tool.ExecuteAsync(new Dictionary { diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 095f964bd..da30f1f0d 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -47,7 +47,7 @@ public async Task Text_response_returns_success_result() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Say hello", Timeout = TimeSpan.FromSeconds(5) }, + new RunSubAgent { Task = "Say hello", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -56,6 +56,25 @@ public async Task Text_response_returns_success_result() Assert.Empty(result.Findings); } + [Fact] + public async Task Spawn_without_audience_fails_fast_with_unsuccessful_result() + { + var fakeClient = new FakeChatClient(); + var definition = CreateDefinition(); + var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); + + // A RunSubAgent with no audience must not run — the sub-agent must reply + // with an unsuccessful result immediately, not crash and make the caller + // wait out the Ask timeout. A generous Ask timeout would still elapse if + // the actor merely threw; this asserts the prompt failure reply. + var result = await agent.Ask( + new RunSubAgent { Task = "Do the thing", Timeout = TimeSpan.FromSeconds(5), Audience = null }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains("audience", result.Output, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Tool_call_executes_and_continues() { @@ -73,7 +92,7 @@ public async Task Tool_call_executes_and_continues() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Greet the user", Timeout = TimeSpan.FromSeconds(5) }, + new RunSubAgent { Task = "Greet the user", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -104,7 +123,8 @@ public async Task Tool_execution_inherits_parent_session_and_project_directories Task = "Inspect the inherited paths.", Timeout = TimeSpan.FromSeconds(5), ParentSessionDirectory = "/tmp/netclaw/sessions/abc", - ParentProjectDirectory = "/home/user/workspaces/netclaw" + ParentProjectDirectory = "/home/user/workspaces/netclaw", + Audience = TrustAudience.Personal, }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -131,7 +151,8 @@ public async Task Tool_execution_with_no_parent_project_directory_passes_null_th { Task = "Inspect inherited paths.", Timeout = TimeSpan.FromSeconds(5), - ParentSessionDirectory = "/tmp/netclaw/sessions/xyz" + ParentSessionDirectory = "/tmp/netclaw/sessions/xyz", + Audience = TrustAudience.Personal, }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -158,7 +179,8 @@ public async Task Each_spawn_snapshots_its_own_parent_project_directory() { Task = "First run.", Timeout = TimeSpan.FromSeconds(5), - ParentProjectDirectory = "/home/user/workspaces/project-a" + ParentProjectDirectory = "/home/user/workspaces/project-a", + Audience = TrustAudience.Personal, }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(firstResult.Success); @@ -176,7 +198,8 @@ public async Task Each_spawn_snapshots_its_own_parent_project_directory() { Task = "Second run after parent project switch.", Timeout = TimeSpan.FromSeconds(5), - ParentProjectDirectory = "/home/user/workspaces/project-b" + ParentProjectDirectory = "/home/user/workspaces/project-b", + Audience = TrustAudience.Personal, }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(secondResult.Success); @@ -192,7 +215,7 @@ public async Task System_prompt_includes_inherited_project_instructions_when_pre var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Do the thing.", Timeout = TimeSpan.FromSeconds(5) }, + new RunSubAgent { Task = "Do the thing.", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -210,7 +233,7 @@ public async Task System_prompt_omits_project_section_when_no_instructions_inher var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Do the thing.", Timeout = TimeSpan.FromSeconds(5) }, + new RunSubAgent { Task = "Do the thing.", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -252,7 +275,7 @@ public async Task Approval_gated_tool_is_denied_inside_subagent() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient, policy, approvalService: null)); var result = await agent.Ask( - new RunSubAgent { Task = "Try the shell tool", Timeout = TimeSpan.FromSeconds(5) }, + new RunSubAgent { Task = "Try the shell tool", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -301,7 +324,8 @@ public async Task Approve_once_does_not_leak_between_subagent_tool_calls() { Task = "Run the same approval-gated tool twice", Timeout = TimeSpan.FromSeconds(5), - ApprovalBridge = approvalBridge + ApprovalBridge = approvalBridge, + Audience = TrustAudience.Personal, }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -327,7 +351,7 @@ public async Task Max_iterations_forces_text_response() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Loop forever", Timeout = TimeSpan.FromSeconds(10) }, + new RunSubAgent { Task = "Loop forever", Timeout = TimeSpan.FromSeconds(10) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); // After 10 tool iterations, forces a no-tools call which returns text @@ -347,7 +371,7 @@ public async Task Timeout_returns_failure() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Slow task", Timeout = TimeSpan.FromMilliseconds(500) }, + new RunSubAgent { Task = "Slow task", Timeout = TimeSpan.FromMilliseconds(500) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.False(result.Success); @@ -363,7 +387,7 @@ public async Task LLM_failure_returns_failure() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, throwingClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Fail", Timeout = TimeSpan.FromSeconds(5) }, + new RunSubAgent { Task = "Fail", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.False(result.Success); @@ -379,7 +403,7 @@ public async Task Actor_stops_after_completion() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); Watch(agent); - agent.Tell(new RunSubAgent { Task = "Done", Timeout = TimeSpan.FromSeconds(5) }); + agent.Tell(new RunSubAgent { Task = "Done", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }); // SubAgentResult arrives before Terminated — drain it first await ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -427,13 +451,13 @@ public async Task Tool_execution_uses_session_scope_for_mcp_invocation() Task = "Open example.com", Timeout = TimeSpan.FromSeconds(5), SessionScopeId = "session/subagent-scope", - Audience = TrustAudience.Team.ToWireValue() + Audience = TrustAudience.Team }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal("session/subagent-scope", invoker.SessionId); - Assert.Equal(TrustAudience.Team.ToWireValue(), invoker.Audience); + Assert.Equal(TrustAudience.Team, invoker.Audience); Assert.Equal("browser_playwright", invoker.ServerName); Assert.Equal("navigate_page", invoker.ToolName); } @@ -449,7 +473,7 @@ public async Task Long_text_response_does_not_emit_findings_by_default() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Summarize research", Timeout = TimeSpan.FromSeconds(5) }, + new RunSubAgent { Task = "Summarize research", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -467,7 +491,7 @@ public async Task Long_text_response_emits_findings_when_enabled() var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); var result = await agent.Ask( - new RunSubAgent { Task = "Summarize research", Timeout = TimeSpan.FromSeconds(5) }, + new RunSubAgent { Task = "Summarize research", Timeout = TimeSpan.FromSeconds(5) , Audience = TrustAudience.Personal }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -491,7 +515,8 @@ public async Task RuntimeContext_is_prefixed_onto_first_user_message() { Task = "Summarize the recent commits.", RuntimeContext = "Workspace is netclaw on branch feature/foo.", - Timeout = TimeSpan.FromSeconds(5) + Timeout = TimeSpan.FromSeconds(5), + Audience = TrustAudience.Personal, }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -520,7 +545,8 @@ public async Task Null_RuntimeContext_leaves_first_user_message_as_raw_task() new RunSubAgent { Task = "Do the thing.", - Timeout = TimeSpan.FromSeconds(5) + Timeout = TimeSpan.FromSeconds(5), + Audience = TrustAudience.Personal, }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -644,7 +670,7 @@ internal sealed class RecordingMcpToolInvoker(string result) : IMcpToolInvoker public string? ServerName { get; private set; } public string? ToolName { get; private set; } public string? SessionId { get; private set; } - public string? Audience { get; private set; } + public TrustAudience? Audience { get; private set; } public Task InvokeAsync( string serverName, diff --git a/src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs b/src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs index c3fcf1300..9095d2275 100644 --- a/src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/AttachFileToolTests.cs @@ -27,7 +27,7 @@ public async Task Valid_file_within_session_directory_succeeds() var filePath = Path.Combine(_dir.Path, "report.png"); await File.WriteAllBytesAsync(filePath, [0x89, 0x50, 0x4E, 0x47], TestContext.Current.CancellationToken); - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", filePath); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -46,7 +46,7 @@ public async Task Path_traversal_attempt_is_rejected() try { - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", outsidePath); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -63,7 +63,7 @@ public async Task Path_traversal_attempt_is_rejected() [Fact] public async Task Dotdot_traversal_is_rejected() { - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", Path.Combine(_dir.Path, "..", "..", "etc", "passwd")); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -76,7 +76,7 @@ public async Task Dotdot_traversal_is_rejected() public async Task Missing_file_returns_error() { var filePath = Path.Combine(_dir.Path, "nonexistent.png"); - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", filePath); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -88,7 +88,7 @@ public async Task Missing_file_returns_error() [Fact] public async Task Empty_path_returns_error() { - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", ""); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -99,7 +99,7 @@ public async Task Empty_path_returns_error() [Fact] public async Task No_session_directory_returns_error() { - var context = new ToolExecutionContext("test-session", null); + var context = new ToolExecutionContext("test-session", null) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", "/tmp/anything.png"); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -114,7 +114,7 @@ public async Task Display_name_is_used_when_provided() var filePath = Path.Combine(_dir.Path, "abc123.png"); await File.WriteAllBytesAsync(filePath, [0x89, 0x50, 0x4E, 0x47], TestContext.Current.CancellationToken); - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", filePath, "DisplayName", "My Custom Report.png"); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -130,7 +130,7 @@ public async Task Successful_attach_populates_file_attachments_on_context() var filePath = Path.Combine(_dir.Path, "chart.png"); await File.WriteAllBytesAsync(filePath, [0x89, 0x50, 0x4E, 0x47], TestContext.Current.CancellationToken); - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", filePath); await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -144,7 +144,7 @@ public async Task Successful_attach_populates_file_attachments_on_context() [Fact] public async Task Failed_attach_does_not_populate_file_attachments() { - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", Path.Combine(_dir.Path, "nonexistent.png")); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -161,7 +161,7 @@ public async Task Prefix_collision_path_is_rejected() var outsideFile = Path.Combine(outsideDir, "secret.txt"); await File.WriteAllTextAsync(outsideFile, "sensitive", TestContext.Current.CancellationToken); - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", outsideFile); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -183,7 +183,7 @@ public async Task Symlink_to_outside_file_is_rejected() { File.CreateSymbolicLink(symlinkPath, outsideFile); - var context = new ToolExecutionContext("test-session", _dir.Path); + var context = new ToolExecutionContext("test-session", _dir.Path) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Path", symlinkPath); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -219,7 +219,7 @@ public async Task File_from_sibling_session_directory_is_copied_and_attached() var context = new ToolExecutionContext("signalr/thread-1", currentSessionDir) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -248,7 +248,7 @@ public async Task Public_context_cannot_attach_file_outside_session_directory() var context = new ToolExecutionContext("slack/thread-1", sessionDir) { - Audience = TrustAudience.Public.ToWireValue(), + Audience = TrustAudience.Public, Boundary = SecurityPolicyDefaults.PublicBoundary, ChannelType = "slack" }; @@ -280,7 +280,7 @@ public async Task Symlink_from_sibling_session_to_outside_root_is_rejected() var context = new ToolExecutionContext("signalr/thread-1", currentSessionDir) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index d48228fe4..a0e7d0085 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -76,7 +76,7 @@ public async Task Routes_shell_execute() var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -96,7 +96,7 @@ public async Task Routes_file_read_missing_file() var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", Path.GetTempPath()) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -115,7 +115,7 @@ public async Task Shell_execute_is_denied_outside_personal_context() var context = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", null) { - Audience = TrustAudience.Team.ToWireValue(), + Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "slack" }; @@ -150,7 +150,7 @@ public async Task Shell_execute_is_denied_when_missing_from_personal_audience_pr var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -185,7 +185,7 @@ public async Task Shell_execute_is_denied_when_shell_mode_is_off_even_in_persona var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -203,7 +203,7 @@ public async Task Shell_execute_is_allowed_in_personal_context() var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -229,7 +229,7 @@ public async Task File_read_is_denied_outside_session_directory_in_public_contex var context = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", sessionDir) { - Audience = TrustAudience.Public.ToWireValue(), + Audience = TrustAudience.Public, Boundary = SecurityPolicyDefaults.PublicBoundary, ChannelType = "slack" }; @@ -260,7 +260,7 @@ public async Task File_write_is_denied_outside_session_directory_in_public_conte var context = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", sessionDir) { - Audience = TrustAudience.Public.ToWireValue(), + Audience = TrustAudience.Public, Boundary = SecurityPolicyDefaults.PublicBoundary, ChannelType = "slack" }; @@ -291,7 +291,7 @@ public async Task Routes_file_write() var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", sessionDir) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -339,7 +339,7 @@ public void Team_profile_hides_shell_and_write_tools() var teamContext = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", Path.GetTempPath()) { - Audience = TrustAudience.Team.ToWireValue(), + Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.TeamBoundary, ChannelType = "slack" }; @@ -377,7 +377,7 @@ public async Task Mcp_tool_is_denied_when_server_not_allowed_for_audience() var toolCall = new FunctionCallContent("call-mcp-deny", "memorizer/search_memories", ToolInput.Empty()); var context = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", null) { - Audience = TrustAudience.Team.ToWireValue(), + Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.TeamBoundary, ChannelType = "slack" }; @@ -427,7 +427,7 @@ public async Task One_time_approval_allows_immediate_retry_only() var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr", SupportsInteractiveApproval = true @@ -488,7 +488,7 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns( var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr", SupportsInteractiveApproval = true @@ -545,7 +545,7 @@ public async Task One_time_approval_bypasses_policy_for_path_aware_file_patterns var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr", SupportsInteractiveApproval = true @@ -615,7 +615,7 @@ public async Task One_time_approval_uses_filtered_unapproved_patterns_on_retry() var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-filtered", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr", SupportsInteractiveApproval = true @@ -700,7 +700,7 @@ public async Task Session_approval_allows_same_session_but_not_different_session var firstContext = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr", SupportsInteractiveApproval = true @@ -708,7 +708,7 @@ public async Task Session_approval_allows_same_session_but_not_different_session var secondContext = new Netclaw.Tools.ToolExecutionContext("signalr/thread-2", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr", SupportsInteractiveApproval = true diff --git a/src/Netclaw.Actors.Tests/Tools/FileEditToolTests.cs b/src/Netclaw.Actors.Tests/Tools/FileEditToolTests.cs index d0d39f599..610dd3d08 100644 --- a/src/Netclaw.Actors.Tests/Tools/FileEditToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/FileEditToolTests.cs @@ -156,7 +156,7 @@ public async Task Public_context_cannot_edit_outside_session_directory() private ToolExecutionContext CreatePersonalContext() => new("signalr/thread-1", _sessionDir) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -164,7 +164,7 @@ private ToolExecutionContext CreatePersonalContext() private ToolExecutionContext CreatePublicContext() => new("slack/thread-1", _sessionDir) { - Audience = TrustAudience.Public.ToWireValue(), + Audience = TrustAudience.Public, Boundary = SecurityPolicyDefaults.PublicBoundary, ChannelType = "slack" }; diff --git a/src/Netclaw.Actors.Tests/Tools/FileReadToolTests.cs b/src/Netclaw.Actors.Tests/Tools/FileReadToolTests.cs index 96aeb01a6..6c087eeae 100644 --- a/src/Netclaw.Actors.Tests/Tools/FileReadToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/FileReadToolTests.cs @@ -302,7 +302,7 @@ public async Task Literal_global_read_root_works_without_netclaw_paths() private ToolExecutionContext CreatePersonalContext() => new("signalr/thread-1", _sessionDir) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -310,7 +310,7 @@ private ToolExecutionContext CreatePersonalContext() private ToolExecutionContext CreateTeamContext() => new("slack/thread-1", _sessionDir) { - Audience = TrustAudience.Team.ToWireValue(), + Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.TeamBoundary, ChannelType = "slack" }; @@ -318,7 +318,7 @@ private ToolExecutionContext CreateTeamContext() private ToolExecutionContext CreatePublicContext() => new("slack/thread-1", _sessionDir) { - Audience = TrustAudience.Public.ToWireValue(), + Audience = TrustAudience.Public, Boundary = SecurityPolicyDefaults.PublicBoundary, ChannelType = "slack" }; diff --git a/src/Netclaw.Actors.Tests/Tools/FileWriteToolTests.cs b/src/Netclaw.Actors.Tests/Tools/FileWriteToolTests.cs index c5f36cad5..e0587e7a8 100644 --- a/src/Netclaw.Actors.Tests/Tools/FileWriteToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/FileWriteToolTests.cs @@ -138,7 +138,7 @@ public async Task Public_context_cannot_write_outside_session_directory() private ToolExecutionContext CreatePersonalContext() => new("signalr/thread-1", _sessionDir) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr" }; @@ -146,7 +146,7 @@ private ToolExecutionContext CreatePersonalContext() private ToolExecutionContext CreatePublicContext() => new("slack/thread-1", _sessionDir) { - Audience = TrustAudience.Public.ToWireValue(), + Audience = TrustAudience.Public, Boundary = SecurityPolicyDefaults.PublicBoundary, ChannelType = "slack" }; diff --git a/src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs b/src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs index 1d803f202..a734146c5 100644 --- a/src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/McpToolAdapterTests.cs @@ -7,6 +7,7 @@ using System.Text.Json; using Microsoft.Extensions.AI; using Netclaw.Actors.Tools; +using Netclaw.Configuration; using Netclaw.Tools; using Xunit; @@ -102,7 +103,7 @@ public async Task ExecuteAsync_WithContext_UsesInvokerWhenConfigured() var invoker = new RecordingMcpToolInvoker("scoped-result"); var adapter = new McpToolAdapter(fakeTool, "browser_playwright", "navigate_page", invoker: invoker); - var context = new ToolExecutionContext("chan/thread", null); + var context = new ToolExecutionContext("chan/thread", null) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Url", "https://example.com"); var result = await adapter.ExecuteAsync(args, context, CancellationToken.None); @@ -124,7 +125,7 @@ public async Task ExecuteAsync_WithContext_InvokerFailure_ReturnsError() }; var adapter = new McpToolAdapter(fakeTool, "browser_playwright", "navigate_page", invoker: invoker); - var context = new ToolExecutionContext("chan/thread", null); + var context = new ToolExecutionContext("chan/thread", null) { Audience = TrustAudience.Personal }; var result = await adapter.ExecuteAsync(ToolInput.Empty(), context, CancellationToken.None); Assert.StartsWith("Error:", result); @@ -665,7 +666,7 @@ public async Task McpToolAdapter_StripsMetaFields_BeforeMcpInvocation() var fakeTool = AIFunctionFactory.Create((Func)FakeFunc, "search_memories"); var adapter = new McpToolAdapter(fakeTool, "memorizer", "search_memories", invoker: invoker); - var context = new ToolExecutionContext("chan/thread", null); + var context = new ToolExecutionContext("chan/thread", null) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("query", "Akka.NET", "_rationale", "looking up docs", "_timeout_seconds", 30); await adapter.ExecuteAsync(args, context, CancellationToken.None); diff --git a/src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs b/src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs index 7bc38d28e..7238ff2a7 100644 --- a/src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs @@ -391,7 +391,7 @@ private static ToolExecutionContext CreateExecutionContext(TrustAudience audienc { return new ToolExecutionContext("slack/thread-1", null) { - Audience = audience.ToWireValue(), + Audience = audience, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "slack" }; diff --git a/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs b/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs index 9cf6bd295..5bae7b735 100644 --- a/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs @@ -98,7 +98,7 @@ public async Task ApprovedOnce_on_messy_command_satisfies_one_time_bypass() var context = new ToolExecutionContext("signalr/thread-1", null) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "signalr", SupportsInteractiveApproval = true diff --git a/src/Netclaw.Actors.Tests/Tools/PublicAudienceFileAccessPolicyTests.cs b/src/Netclaw.Actors.Tests/Tools/PublicAudienceFileAccessPolicyTests.cs index 2c21720b3..411665e81 100644 --- a/src/Netclaw.Actors.Tests/Tools/PublicAudienceFileAccessPolicyTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/PublicAudienceFileAccessPolicyTests.cs @@ -131,7 +131,7 @@ public void Public_audience_filesystem_mode_none_error_is_sanitized() private ToolExecutionContext CreateContext(TrustAudience audience) => new("test/session-1", _sessionDir) { - Audience = audience.ToWireValue(), + Audience = audience, Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(audience), ChannelType = audience == TrustAudience.Personal ? "signalr" : "slack" }; diff --git a/src/Netclaw.Actors.Tests/Tools/SchedulingToolAudienceTests.cs b/src/Netclaw.Actors.Tests/Tools/SchedulingToolAudienceTests.cs index ffa725493..3b3397807 100644 --- a/src/Netclaw.Actors.Tests/Tools/SchedulingToolAudienceTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/SchedulingToolAudienceTests.cs @@ -89,7 +89,7 @@ private static FakeNetclawTool CreateFakeTool(string name, string grantCategory) private static ToolExecutionContext CreateContext(TrustAudience audience) => new ToolExecutionContext("slack/thread-1", null) { - Audience = audience.ToWireValue(), + Audience = audience, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "slack" }; diff --git a/src/Netclaw.Actors.Tests/Tools/ScopedFileAccessPolicyHomeExpansionTests.cs b/src/Netclaw.Actors.Tests/Tools/ScopedFileAccessPolicyHomeExpansionTests.cs index ea80f6961..6041c1fcf 100644 --- a/src/Netclaw.Actors.Tests/Tools/ScopedFileAccessPolicyHomeExpansionTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ScopedFileAccessPolicyHomeExpansionTests.cs @@ -94,7 +94,7 @@ private static ToolConfig BuildPersonalWriteRootsConfig(string configuredRoot) private ToolExecutionContext CreateContext(TrustAudience audience) => new("personal/test-session", _sessionDir) { - Audience = audience.ToWireValue(), + Audience = audience, Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(audience), ChannelType = "signalr" }; diff --git a/src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs b/src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs index 753d48abd..87f139ac8 100644 --- a/src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs @@ -62,14 +62,14 @@ private static SafeVerbList VerbList(params string[] verbs) private ToolExecutionContext PersonalContext(string? projectDir = null, string? sessionDir = null) => new("session-1", sessionDir ?? _sessionDir) { - Audience = TrustAudience.Personal.ToWireValue(), + Audience = TrustAudience.Personal, ProjectDirectory = projectDir }; private ToolExecutionContext PublicContext(string? projectDir = null) => new("session-1", _sessionDir) { - Audience = TrustAudience.Public.ToWireValue(), + Audience = TrustAudience.Public, ProjectDirectory = projectDir }; diff --git a/src/Netclaw.Actors.Tests/Tools/SearchToolsToolTests.cs b/src/Netclaw.Actors.Tests/Tools/SearchToolsToolTests.cs index 7b12453d9..245a2c195 100644 --- a/src/Netclaw.Actors.Tests/Tools/SearchToolsToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/SearchToolsToolTests.cs @@ -213,7 +213,7 @@ public async Task Search_AllowsMemorySafeMcpTools_InTeamContext() var context = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", null) { - Audience = TrustAudience.Team.ToWireValue(), + Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "slack" }; @@ -248,7 +248,7 @@ public async Task Search_HidesMcpServer_WhenAudienceProfileDoesNotAllowServer() var context = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", null) { - Audience = TrustAudience.Team.ToWireValue(), + Audience = TrustAudience.Team, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "slack" }; diff --git a/src/Netclaw.Actors.Tests/Tools/SetWorkingDirectoryAudienceTests.cs b/src/Netclaw.Actors.Tests/Tools/SetWorkingDirectoryAudienceTests.cs index 7be920874..3604a8be5 100644 --- a/src/Netclaw.Actors.Tests/Tools/SetWorkingDirectoryAudienceTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/SetWorkingDirectoryAudienceTests.cs @@ -66,7 +66,7 @@ private static FakeNetclawTool CreateFakeTool() private static ToolExecutionContext CreateContext(TrustAudience audience) => new ToolExecutionContext("slack/thread-1", null) { - Audience = audience.ToWireValue(), + Audience = audience, Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, ChannelType = "slack" }; diff --git a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs index 1b914a2a0..3d59dd821 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs @@ -63,6 +63,7 @@ public async Task Requested_timeout_overrides_default_timeout() var args = ToolInput.Create("Command", "sleep 2"); var context = new ToolExecutionContext("test/thread", Path.GetTempPath()) { + Audience = TrustAudience.Personal, RequestedTimeoutSeconds = 3 }; @@ -123,7 +124,7 @@ public async Task Cwd_falls_back_to_project_directory_when_no_explicit_arg() Directory.CreateDirectory(sessionDir); try { - var context = new ToolExecutionContext("session-1", sessionDir) { ProjectDirectory = projectDir }; + var context = new ToolExecutionContext("session-1", sessionDir) { Audience = TrustAudience.Personal, ProjectDirectory = projectDir }; var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -146,7 +147,7 @@ public async Task Cwd_falls_back_to_session_directory_when_project_directory_nul Directory.CreateDirectory(sessionDir); try { - var context = new ToolExecutionContext("session-1", sessionDir); + var context = new ToolExecutionContext("session-1", sessionDir) { Audience = TrustAudience.Personal }; // ProjectDirectory not set var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); @@ -173,7 +174,7 @@ public async Task Cwd_explicit_arg_overrides_project_and_session_directories() Directory.CreateDirectory(sessionDir); try { - var context = new ToolExecutionContext("session-1", sessionDir) { ProjectDirectory = projectDir }; + var context = new ToolExecutionContext("session-1", sessionDir) { Audience = TrustAudience.Personal, ProjectDirectory = projectDir }; var args = ToolInput.Create( "Command", OperatingSystem.IsWindows() ? "cd" : "pwd", "WorkingDirectory", explicitDir); @@ -203,7 +204,7 @@ public async Task Cwd_does_not_inherit_daemon_process_directory() // assert the resolved cwd is the session dir, not whatever // Environment.CurrentDirectory happens to be — proving the // ProcessStartInfo default-fall-through is gone. - var context = new ToolExecutionContext("session-1", sessionDir); + var context = new ToolExecutionContext("session-1", sessionDir) { Audience = TrustAudience.Personal }; var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); diff --git a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs index b058b4bc6..9a66c3531 100644 --- a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs @@ -29,7 +29,7 @@ public class SkillToolTests : IDisposable /// Personal audience context for tests — skill tools require non-Public audience. /// private static readonly Netclaw.Tools.ToolExecutionContext PersonalCtx = - new(null, null) { Audience = TrustAudience.Personal.ToWireValue() }; + new(null, null) { Audience = TrustAudience.Personal }; public SkillToolTests() { @@ -61,7 +61,7 @@ Secret instructions. """); ScanSkills(); - var publicCtx = new Netclaw.Tools.ToolExecutionContext(null, null) { Audience = TrustAudience.Public.ToWireValue() }; + var publicCtx = new Netclaw.Tools.ToolExecutionContext(null, null) { Audience = TrustAudience.Public }; var tool = new SkillLoadTool(_registry, new NoOpSkillContentScanner()); var result = await tool.ExecuteAsync( ToolInput.Create("Name", "secret-skill"), publicCtx, TestContext.Current.CancellationToken); @@ -107,7 +107,8 @@ public async Task SkillLoad_DefaultsToPublicWhenAudienceUnparseable() """); ScanSkills(); - var badCtx = new Netclaw.Tools.ToolExecutionContext(null, null) { Audience = "superadmin" }; + // Audience is non-nullable; Public is the minimum-privilege audience, equivalent to the old null/unset default. + var badCtx = new Netclaw.Tools.ToolExecutionContext(null, null) { Audience = TrustAudience.Public }; var tool = new SkillLoadTool(_registry, new NoOpSkillContentScanner()); var result = await tool.ExecuteAsync( ToolInput.Create("Name", "guarded-skill"), badCtx, TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index 9461ffb6c..1aba3c7aa 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -37,7 +37,7 @@ private static ToolAccessPolicy CreatePolicy(ToolApprovalMode shellApprovalMode) } private static ToolExecutionContext PersonalContext(bool supportsApproval = true, string sessionId = "signalr/thread-1") => - new(sessionId, null) { Audience = "personal", SupportsInteractiveApproval = supportsApproval }; + new(sessionId, null) { Audience = TrustAudience.Personal, SupportsInteractiveApproval = supportsApproval }; private static INetclawTool ShellTool() { diff --git a/src/Netclaw.Actors/Channels/ChannelInput.cs b/src/Netclaw.Actors/Channels/ChannelInput.cs index 5a61f71dd..a47c69fd9 100644 --- a/src/Netclaw.Actors/Channels/ChannelInput.cs +++ b/src/Netclaw.Actors/Channels/ChannelInput.cs @@ -40,27 +40,28 @@ public sealed record AdoptedContextEntry public string? MessageId { get; init; } /// - /// Optional source audience hint carried from the inbound adapter. - /// When omitted, the channel pipeline applies strict defaults. + /// Source audience for this message. The inbound adapter resolves this + /// explicitly — the channel pipeline never synthesizes a default. /// - public TrustAudience? Audience { get; init; } + public required TrustAudience Audience { get; init; } /// - /// Optional trust boundary hint carried from the inbound adapter. - /// When omitted, the channel pipeline applies adapter defaults. + /// Trust boundary for this message. The inbound adapter supplies this + /// explicitly — the channel pipeline never synthesizes a default. /// - public string? Boundary { get; init; } + public required string Boundary { get; init; } /// - /// Optional principal classification for the sender. - /// When omitted, the channel pipeline applies strict defaults. + /// Principal classification for the sender. The inbound adapter supplies + /// this explicitly — the channel pipeline never synthesizes a default. /// - public PrincipalClassification? Principal { get; init; } + public required PrincipalClassification Principal { get; init; } /// - /// Provenance markers that distinguish transport verification from content taint. + /// Provenance markers that distinguish transport verification from content + /// taint. The inbound adapter supplies this explicitly. /// - public SourceProvenance? Provenance { get; init; } + public required SourceProvenance Provenance { get; init; } /// /// Message content. Supports text (), diff --git a/src/Netclaw.Actors/Channels/ChannelPipeline.cs b/src/Netclaw.Actors/Channels/ChannelPipeline.cs index 85f5bd8fd..9c3081952 100644 --- a/src/Netclaw.Actors/Channels/ChannelPipeline.cs +++ b/src/Netclaw.Actors/Channels/ChannelPipeline.cs @@ -26,26 +26,6 @@ public sealed record SessionPipelineOptions /// public required ChannelType ChannelType { get; init; } - /// - /// Strict-default audience used when inbound adapters do not provide one. - /// - public TrustAudience DefaultAudience { get; init; } = TrustAudience.Public; - - /// - /// Adapter-owned default trust boundary used when inbound adapters do not provide one. - /// - public string DefaultBoundary { get; init; } = string.Empty; - - /// - /// Strict-default principal classification used when inbound adapters do not provide one. - /// - public PrincipalClassification DefaultPrincipal { get; init; } = PrincipalClassification.UntrustedExternal; - - /// - /// Strict-default provenance used when inbound adapters do not provide one. - /// - public SourceProvenance DefaultProvenance { get; init; } = SourceProvenance.StrictDefault(); - /// /// Which output categories the channel wants to receive. /// @@ -74,10 +54,10 @@ public static MessageSource Create(ChannelInput input, SessionPipelineOptions op ChannelId = input.ChannelId, MessageId = input.MessageId, TurnId = turnId, - Audience = input.Audience ?? options.DefaultAudience, - Boundary = SecurityPolicyDefaults.ResolveBoundary(input.Boundary ?? options.DefaultBoundary, options.ChannelType.ToWireValue(), input.Audience ?? options.DefaultAudience), - Principal = input.Principal ?? options.DefaultPrincipal, - Provenance = input.Provenance ?? options.DefaultProvenance, + Audience = input.Audience, + Boundary = input.Boundary, + Principal = input.Principal, + Provenance = input.Provenance, ReceivedAt = input.ReceivedAt, ExecutableText = input.ExecutableText ?? textContent, HasThirdPartyAdoptedContext = input.HasThirdPartyAdoptedContext, diff --git a/src/Netclaw.Actors/Channels/MessageSource.cs b/src/Netclaw.Actors/Channels/MessageSource.cs index 6462f5cc1..50fa5c2d4 100644 --- a/src/Netclaw.Actors/Channels/MessageSource.cs +++ b/src/Netclaw.Actors/Channels/MessageSource.cs @@ -54,23 +54,23 @@ public sealed record AdoptedContextEntry( /// Effective source audience attached to the inbound message before any runtime /// trust-context derivation occurs. /// - public TrustAudience Audience { get; init; } = TrustAudience.Public; + public required TrustAudience Audience { get; init; } /// /// Runtime-owned security boundary used to partition durable memory and other /// reusable state across trust domains. /// - public string Boundary { get; init; } = SecurityPolicyDefaults.PublicBoundary; + public required string Boundary { get; init; } /// /// Principal classification hint for the inbound sender. /// - public PrincipalClassification Principal { get; init; } = PrincipalClassification.UntrustedExternal; + public required PrincipalClassification Principal { get; init; } /// /// Provenance markers used to separate transport authenticity from payload taint. /// - public SourceProvenance Provenance { get; init; } = SourceProvenance.StrictDefault(); + public required SourceProvenance Provenance { get; init; } /// /// When the message was received by the channel. diff --git a/src/Netclaw.Actors/Channels/SourceProvenance.cs b/src/Netclaw.Actors/Channels/SourceProvenance.cs index 4cb11ad21..320cfa539 100644 --- a/src/Netclaw.Actors/Channels/SourceProvenance.cs +++ b/src/Netclaw.Actors/Channels/SourceProvenance.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -9,14 +9,13 @@ namespace Netclaw.Actors.Channels; /// /// Wire-safe provenance markers used to separate transport authenticity from -/// payload trust. +/// payload trust. Both trust-bearing fields are positional and mandatory — +/// there is no permissive sentinel default a forgetful caller can inherit. /// -public sealed record SourceProvenance : IWireType +public sealed record SourceProvenance( + TransportAuthenticity TransportAuthenticity, + PayloadTaint PayloadTaint) : IWireType { - public TransportAuthenticity TransportAuthenticity { get; init; } = TransportAuthenticity.Unknown; - - public PayloadTaint PayloadTaint { get; init; } = PayloadTaint.Unknown; - /// /// Optional scope identifier such as repository, environment, or tenant. /// @@ -26,10 +25,4 @@ public sealed record SourceProvenance : IWireType /// Optional source object identifier such as a webhook event type. /// public string? SourceKind { get; init; } - - public static SourceProvenance StrictDefault() => new() - { - TransportAuthenticity = TransportAuthenticity.Unverified, - PayloadTaint = PayloadTaint.Public - }; } diff --git a/src/Netclaw.Actors/Channels/TrustContextDeriver.cs b/src/Netclaw.Actors/Channels/TrustContextDeriver.cs index bf78ad6a7..d4ad5a9a9 100644 --- a/src/Netclaw.Actors/Channels/TrustContextDeriver.cs +++ b/src/Netclaw.Actors/Channels/TrustContextDeriver.cs @@ -47,7 +47,10 @@ public EffectiveTrustContext Derive(MessageSource? source, WorkingContextOverrid var sourceAudience = source?.Audience ?? _defaults.Audience; var boundary = source?.Boundary ?? SecurityPolicyDefaults.ResolveBoundaryFromAudience(sourceAudience); var principal = source?.Principal ?? PrincipalClassification.UntrustedExternal; - var provenance = source?.Provenance ?? SourceProvenance.StrictDefault(); + // Fail-closed conservative provenance when the turn has no source at all + // (Unverified transport, Public taint) — the most restrictive markers. + var provenance = source?.Provenance + ?? new SourceProvenance(TransportAuthenticity.Unverified, PayloadTaint.Public); var effectiveAudience = Narrowest(_defaults.Audience, sourceAudience); var downgradeReason = (string?)null; diff --git a/src/Netclaw.Actors/Jobs/ActiveJobInfo.cs b/src/Netclaw.Actors/Jobs/ActiveJobInfo.cs index 535b51030..2a3d83aa9 100644 --- a/src/Netclaw.Actors/Jobs/ActiveJobInfo.cs +++ b/src/Netclaw.Actors/Jobs/ActiveJobInfo.cs @@ -21,7 +21,7 @@ public sealed record ActiveJobInfo public required long StartedAtMs { get; init; } - public TrustAudience Audience { get; init; } = TrustAudience.Personal; + public required TrustAudience Audience { get; init; } - public string Boundary { get; init; } = SecurityPolicyDefaults.PersonalBoundary; + public required string Boundary { get; init; } } diff --git a/src/Netclaw.Actors/Jobs/BackgroundJobDefinitionStore.cs b/src/Netclaw.Actors/Jobs/BackgroundJobDefinitionStore.cs index aedac59b4..4652ea616 100644 --- a/src/Netclaw.Actors/Jobs/BackgroundJobDefinitionStore.cs +++ b/src/Netclaw.Actors/Jobs/BackgroundJobDefinitionStore.cs @@ -5,6 +5,9 @@ // ----------------------------------------------------------------------- using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Actors.Persistence; using Netclaw.Configuration; namespace Netclaw.Actors.Jobs; @@ -23,13 +26,54 @@ public sealed class BackgroundJobDefinitionStore private readonly string _directory; private readonly object _sync = new(); + private readonly Dictionary _rejectedLegacyDefinitions = + new(StringComparer.Ordinal); + private readonly ILogger _logger; - public BackgroundJobDefinitionStore(NetclawPaths paths) + public BackgroundJobDefinitionStore(NetclawPaths paths, ILogger? logger = null) { _directory = paths.JobsDirectory; + _logger = logger ?? NullLogger.Instance; Directory.CreateDirectory(_directory); } + private BackgroundJobDefinition? Deserialize(string text, string path) + { + // A pre-#994 job document with no persisted trust context cannot be run + // safely — its trust tier is unknown. Reject it loudly rather than + // coerce a substitute audience. + var missing = LegacyTrustFieldGuard.MissingTrustFields(text); + if (missing.Count > 0) + { + RecordRejectedLegacyDefinition(path, $"missing trust field(s): {string.Join(", ", missing)}"); + _logger.LogError( + "Background job document {Path} predates issue #994 and is missing required " + + "trust field(s): {MissingFields}. The job will not be loaded — a job with no " + + "persisted audience cannot be run safely. Recreate the job or remove the file.", + path, string.Join(", ", missing)); + return null; + } + + return JsonSerializer.Deserialize(text, JsonOptions); + } + + /// + /// Returns and clears background job definitions rejected because they + /// predate the required trust-field schema. + /// + public IReadOnlyList ConsumeRejectedLegacyDefinitions() + { + lock (_sync) + { + if (_rejectedLegacyDefinitions.Count == 0) + return []; + + var snapshot = _rejectedLegacyDefinitions.Values.ToArray(); + _rejectedLegacyDefinitions.Clear(); + return snapshot; + } + } + public BackgroundJobDefinition? Get(BackgroundJobId id) { lock (_sync) @@ -41,7 +85,7 @@ public BackgroundJobDefinitionStore(NetclawPaths paths) try { var text = File.ReadAllText(path); - return JsonSerializer.Deserialize(text, JsonOptions); + return Deserialize(text, path); } catch { @@ -63,7 +107,7 @@ public IReadOnlyList List() try { var text = File.ReadAllText(file); - var def = JsonSerializer.Deserialize(text, JsonOptions); + var def = Deserialize(text, file); if (def is not null && !string.IsNullOrWhiteSpace(def.Id)) list.Add(def); } @@ -125,4 +169,28 @@ private string GetPath(BackgroundJobId id) var encoded = Uri.EscapeDataString(id.Value); return Path.Combine(_directory, $"{encoded}.json"); } + + private void RecordRejectedLegacyDefinition(string path, string reason) + { + var jobId = DecodeJobIdFromPath(path); + _rejectedLegacyDefinitions[jobId] = new RejectedLegacyBackgroundJobDefinition(jobId, reason); + } + + private static string DecodeJobIdFromPath(string path) + { + var encoded = Path.GetFileNameWithoutExtension(path); + if (string.IsNullOrWhiteSpace(encoded)) + return "unknown"; + + try + { + return Uri.UnescapeDataString(encoded); + } + catch + { + return encoded; + } + } } + +public sealed record RejectedLegacyBackgroundJobDefinition(string JobId, string Reason); diff --git a/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs b/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs index 628f7f4ed..5f321f26a 100644 --- a/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs +++ b/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs @@ -29,7 +29,9 @@ public sealed class BackgroundJobManagerActor : ReceiveActor private readonly BackgroundJobDefinitionStore _store; private readonly TimeProvider _timeProvider; + private readonly IOperationalNotificationSink _notificationSink; private readonly ILoggingAdapter _log; + private bool _startupSchemaAlertsEmitted; private readonly HashSet _activeJobIds = []; private readonly Queue _deferredQueue = new(); @@ -37,10 +39,12 @@ public sealed class BackgroundJobManagerActor : ReceiveActor public BackgroundJobManagerActor( BackgroundJobDefinitionStore store, - TimeProvider timeProvider) + TimeProvider timeProvider, + IOperationalNotificationSink? notificationSink = null) { _store = store; _timeProvider = timeProvider; + _notificationSink = notificationSink ?? NullNotificationSink.Instance; _log = Context.GetLogger(); ReceiveAsync(HandleStartAsync); @@ -213,6 +217,12 @@ private void HandleQuery(QueryBackgroundJob query) private void HandleReconcile() { var persisted = _store.List(); + if (!_startupSchemaAlertsEmitted) + { + EmitRejectedLegacyDefinitionAlerts(); + _startupSchemaAlertsEmitted = true; + } + var reconciled = 0; foreach (var def in persisted) @@ -239,6 +249,32 @@ private void HandleReconcile() _log.Info("Background job startup reconciliation: marked {0} orphaned job(s) as lost", reconciled); } + private void EmitRejectedLegacyDefinitionAlerts() + { + var rejected = _store.ConsumeRejectedLegacyDefinitions(); + if (rejected.Count == 0) + return; + + var rejectedIds = string.Join(", ", rejected.Select(x => x.JobId)); + _notificationSink.Emit(OperationalAlert.Create( + _timeProvider, + "background-job.schema.legacy_rejected", + AlertType.BackgroundJobSchemaDropped, + $"Rejected {rejected.Count} legacy background job definition(s) missing trust fields during startup. Repair or recreate job IDs: {rejectedIds}.", + AlertSeverity.Warning, + source: "startup", + context: new Dictionary + { + ["rejectedCount"] = rejected.Count.ToString(), + ["rejectedIds"] = rejectedIds + })); + + _log.Warning( + "Rejected {0} legacy background job definition(s) missing trust fields during startup: {1}", + rejected.Count, + rejectedIds); + } + private void SpawnExecution(BackgroundJobDefinition definition) { var running = definition with { Status = BackgroundJobStatus.Running }; @@ -284,10 +320,10 @@ private void DeliverResultToSession(BackgroundJobCompleted completed, Background Audience = def.Audience, Boundary = def.Boundary, Principal = PrincipalClassification.VerifiedAutomation, - Provenance = new SourceProvenance + Provenance = new SourceProvenance( + TransportAuthenticity.LocalProcess, + PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.LocalProcess, - PayloadTaint = PayloadTaint.Trusted, SourceKind = SourceKind }, ReceivedAt = _timeProvider.GetUtcNow(), diff --git a/src/Netclaw.Actors/Jobs/BackgroundJobProtocol.cs b/src/Netclaw.Actors/Jobs/BackgroundJobProtocol.cs index ea89d9545..4476edd08 100644 --- a/src/Netclaw.Actors/Jobs/BackgroundJobProtocol.cs +++ b/src/Netclaw.Actors/Jobs/BackgroundJobProtocol.cs @@ -118,8 +118,8 @@ public sealed record BackgroundJobDefinition public long? CompletedAtMs { get; init; } [JsonConverter(typeof(JsonStringEnumConverter))] - public TrustAudience Audience { get; init; } = TrustAudience.Personal; - public string Boundary { get; init; } = SecurityPolicyDefaults.PersonalBoundary; + public required TrustAudience Audience { get; init; } + public required string Boundary { get; init; } [JsonConverter(typeof(JsonStringEnumConverter))] public Channels.ChannelType OriginChannelType { get; init; } diff --git a/src/Netclaw.Actors/Jobs/CheckBackgroundJobTool.cs b/src/Netclaw.Actors/Jobs/CheckBackgroundJobTool.cs index a8b0da951..533dd409c 100644 --- a/src/Netclaw.Actors/Jobs/CheckBackgroundJobTool.cs +++ b/src/Netclaw.Actors/Jobs/CheckBackgroundJobTool.cs @@ -42,10 +42,10 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon var jobId = new BackgroundJobId(args.JobId); var sessionId = context.SessionId ?? ""; - var audience = TrustAudience.Personal; - if (!string.IsNullOrEmpty(context.Audience)) - Enum.TryParse(context.Audience, true, out audience); - var boundary = context.Boundary ?? SecurityPolicyDefaults.PersonalBoundary; + var audience = context.Audience; + // Boundary, unlike Audience, is still nullable on the context — fall + // closed to the public boundary when it is absent. + var boundary = context.Boundary ?? SecurityPolicyDefaults.PublicBoundary; if (args.Cancel) { diff --git a/src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs b/src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs index f75ac94cd..67b60c5d1 100644 --- a/src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs +++ b/src/Netclaw.Actors/Memory/MemoryCurationPipeline.cs @@ -400,7 +400,9 @@ private static bool TryMatchProjectStatement( ? "Project Constraint" : "Project Fact"; - var stmtAudience = MemoryPolicyScopeResolver.ResolveAudience(audience, sessionId: null); + TrustAudience? parsedAudience = + SecurityPolicyDefaults.TryParseAudience(audience, out var a) ? a : null; + var stmtAudience = MemoryPolicyScopeResolver.ResolveAudience(parsedAudience, sessionId: null); candidate = new MemoryCheckpointCandidate( Kind: MemoryKind.Document, MemoryClass: MemoryClass.DurableFact, diff --git a/src/Netclaw.Actors/Memory/MemoryPolicyScopeResolver.cs b/src/Netclaw.Actors/Memory/MemoryPolicyScopeResolver.cs index 6e33b61bf..6d0272925 100644 --- a/src/Netclaw.Actors/Memory/MemoryPolicyScopeResolver.cs +++ b/src/Netclaw.Actors/Memory/MemoryPolicyScopeResolver.cs @@ -9,7 +9,7 @@ namespace Netclaw.Actors.Memory; internal static class MemoryPolicyScopeResolver { - public static TrustAudience ResolveAudience(string? configuredAudience, string? sessionId) + public static TrustAudience ResolveAudience(TrustAudience? configuredAudience, string? sessionId) => SecurityPolicyDefaults.ResolveAudienceWithFallback(configuredAudience, sessionId); // Boundary is stored for future cross-trust-boundary federation but is diff --git a/src/Netclaw.Actors/Persistence/LegacyTrustFieldGuard.cs b/src/Netclaw.Actors/Persistence/LegacyTrustFieldGuard.cs new file mode 100644 index 000000000..43b6b03b8 --- /dev/null +++ b/src/Netclaw.Actors/Persistence/LegacyTrustFieldGuard.cs @@ -0,0 +1,51 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json.Nodes; + +namespace Netclaw.Actors.Persistence; + +/// +/// Detects legacy persisted job/reminder JSON documents that predate the +/// type-system-stiffening change (issue #994), which made Audience and +/// Boundary required. +/// +/// A pre-#994 document either omits these keys entirely or carries an explicit +/// null. Such a document is rejected at load — it is NOT coerced to a +/// substituted audience. A job or reminder with no persisted trust context +/// cannot be run safely: the trust tier it should execute under is unknown, and +/// these features are typically disabled at the most-restrictive audience, so +/// substituting one would either escalate privilege or fabricate a nonsensical +/// state. The store fails the document loudly instead. +/// +internal static class LegacyTrustFieldGuard +{ + private const string AudienceKey = "audience"; + private const string BoundaryKey = "boundary"; + + /// + /// Returns the trust-field keys that are absent or explicitly null on the + /// document, or an empty list when the document carries both (a current + /// document) or cannot be parsed as a JSON object (left to the caller's + /// normal parse-error handling). + /// + public static IReadOnlyList MissingTrustFields(string json) + { + if (JsonNode.Parse(json) is not JsonObject root) + return []; + + var missing = new List(2); + if (IsAbsentOrNull(root, AudienceKey)) + missing.Add(AudienceKey); + if (IsAbsentOrNull(root, BoundaryKey)) + missing.Add(BoundaryKey); + return missing; + } + + // Web-serialized documents use camelCase keys; an older document could also + // carry an explicit null where the field used to be nullable. + private static bool IsAbsentOrNull(JsonObject root, string key) + => !root.TryGetPropertyValue(key, out var value) || value is null; +} diff --git a/src/Netclaw.Actors/Reminders/ReminderDefinitionStore.cs b/src/Netclaw.Actors/Reminders/ReminderDefinitionStore.cs index 98b8c61ef..710af734b 100644 --- a/src/Netclaw.Actors/Reminders/ReminderDefinitionStore.cs +++ b/src/Netclaw.Actors/Reminders/ReminderDefinitionStore.cs @@ -5,6 +5,9 @@ // ----------------------------------------------------------------------- using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Actors.Persistence; using Netclaw.Configuration; namespace Netclaw.Actors.Reminders; @@ -27,10 +30,14 @@ public sealed class ReminderDefinitionStore private readonly string _directory; private readonly object _sync = new(); private readonly List _droppedInvalidDefinitions = []; + private readonly Dictionary _rejectedLegacyDefinitions = + new(StringComparer.Ordinal); + private readonly ILogger _logger; - public ReminderDefinitionStore(NetclawPaths paths) + public ReminderDefinitionStore(NetclawPaths paths, ILogger? logger = null) { _directory = paths.RemindersDirectory; + _logger = logger ?? NullLogger.Instance; Directory.CreateDirectory(_directory); PruneInvalidDefinitions(); } @@ -51,6 +58,23 @@ public IReadOnlyList ConsumeDroppedInvalidDefi } } + /// + /// Returns and clears reminder definitions rejected because they predate the + /// required trust-field schema. + /// + public IReadOnlyList ConsumeRejectedLegacyDefinitions() + { + lock (_sync) + { + if (_rejectedLegacyDefinitions.Count == 0) + return []; + + var snapshot = _rejectedLegacyDefinitions.Values.ToArray(); + _rejectedLegacyDefinitions.Clear(); + return snapshot; + } + } + public bool Exists(ReminderId id) { lock (_sync) @@ -177,11 +201,35 @@ private static string DecodeReminderIdFromPath(string path) } } - private static ReadResult TryReadDefinition(string path) + private void RecordRejectedLegacyDefinition(string path, string reason) + { + var reminderId = DecodeReminderIdFromPath(path); + _rejectedLegacyDefinitions[reminderId] = new RejectedLegacyReminderDefinition(reminderId, reason); + } + + private ReadResult TryReadDefinition(string path) { try { var text = File.ReadAllText(path); + // A pre-#994 reminder document with no persisted trust context cannot + // be run safely. Reject it loudly without coercing a substitute + // audience, and keep the file — it is operator-authored data, not + // corrupt JSON, so the operator can repair or remove it. + var missingTrustFields = LegacyTrustFieldGuard.MissingTrustFields(text); + if (missingTrustFields.Count > 0) + { + var fields = string.Join(", ", missingTrustFields); + _logger.LogError( + "Reminder document {Path} predates issue #994 and is missing required " + + "trust field(s): {MissingFields}. The reminder will not be loaded or " + + "scheduled — a reminder with no persisted audience cannot be run safely. " + + "Recreate the reminder or remove the file.", + path, fields); + RecordRejectedLegacyDefinition(path, $"missing trust field(s): {fields}"); + return new ReadResult(null, $"missing trust field(s): {fields}", ShouldDelete: false); + } + var definition = JsonSerializer.Deserialize(text, JsonOptions); if (definition is null || string.IsNullOrWhiteSpace(definition.Id)) { @@ -211,3 +259,4 @@ private sealed record ReadResult(ReminderDefinition? Definition, string? ErrorMe } public sealed record DroppedInvalidReminderDefinition(string ReminderId, string Reason); +public sealed record RejectedLegacyReminderDefinition(string ReminderId, string Reason); diff --git a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs index c4307e399..f7e297e91 100644 --- a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs +++ b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs @@ -117,8 +117,8 @@ private async Task InitializeAsync() : new SessionId($"reminder/{_definition.Id}/{_timeProvider.GetUtcNow().ToUnixTimeMilliseconds()}"); _sessionIdValue = sessionId.Value; - if (_definition.Audience is not { } audience) - throw new InvalidOperationException($"Reminder '{_definition.Id}' is missing a persisted execution audience."); + var audience = _definition.Audience; + var boundary = GetPersistedBoundaryOrThrow(); _log.Info( $"ReminderExecution Initialized: execution_id={_executionId} reminder_id={_definition.Id} session_id={sessionId.Value} audience={audience} source=stored-definition"); @@ -130,15 +130,6 @@ private async Task InitializeAsync() new SessionPipelineOptions { ChannelType = Channels.ChannelType.Reminder, - DefaultAudience = audience, - DefaultBoundary = SecurityPolicyDefaults.LocalDaemonBoundary, - DefaultPrincipal = PrincipalClassification.VerifiedAutomation, - DefaultProvenance = new SourceProvenance - { - TransportAuthenticity = TransportAuthenticity.LocalProcess, - PayloadTaint = PayloadTaint.Trusted, - SourceKind = "reminder" - }, Filter = OutputFilter.TextStreaming | OutputFilter.ToolCalls }, output => self.Tell(new ExecutionOutput(output))); @@ -149,6 +140,15 @@ await inputQueue.OfferAsync(new ChannelInput { SenderId = "reminder-system", ChannelId = _definition.Delivery.Address, + Audience = audience, + Boundary = boundary, + Principal = PrincipalClassification.VerifiedAutomation, + Provenance = new SourceProvenance( + TransportAuthenticity.LocalProcess, + PayloadTaint.Trusted) + { + SourceKind = "reminder" + }, Contents = [new TextContent(prompt)], ReceivedAt = _timeProvider.GetUtcNow() }); @@ -179,8 +179,8 @@ private async Task InitializeCurrentSessionAsync() _sessionIdValue = sessionId.Value; var originChannelType = _definition.Delivery.OriginChannelType!.Value; - if (_definition.Audience is not { } audience) - throw new InvalidOperationException($"Reminder '{_definition.Id}' is missing a persisted execution audience."); + var audience = _definition.Audience; + var boundary = GetPersistedBoundaryOrThrow(); var reminderDeliveryKey = $"{_definition.Id}:{_dispatchedAt.ToUnixTimeMilliseconds()}"; @@ -195,16 +195,12 @@ private async Task InitializeCurrentSessionAsync() MessageId = reminderDeliveryKey, TurnId = reminderDeliveryKey, Audience = audience, - Boundary = _definition.Boundary - ?? SecurityPolicyDefaults.ResolveBoundary( - boundary: null, - channelType: originChannelType.ToWireValue(), - audience: audience), + Boundary = boundary, Principal = PrincipalClassification.VerifiedAutomation, - Provenance = new SourceProvenance + Provenance = new SourceProvenance( + TransportAuthenticity.LocalProcess, + PayloadTaint.Trusted) { - TransportAuthenticity = TransportAuthenticity.LocalProcess, - PayloadTaint = PayloadTaint.Trusted, SourceKind = "reminder" }, ReceivedAt = _dispatchedAt, @@ -345,6 +341,17 @@ private async Task TryAckEnvelopeAsync() }; } + private string GetPersistedBoundaryOrThrow() + { + if (!SecurityPolicyDefaults.TryNormalizeBoundary(_definition.Boundary, out var normalizedBoundary)) + { + throw new InvalidOperationException( + $"Reminder '{_definition.Id}' has invalid persisted trust boundary '{_definition.Boundary}'."); + } + + return normalizedBoundary; + } + private static string BuildPrompt(ReminderDefinition definition) { var deliverySection = definition.Delivery.Kind switch diff --git a/src/Netclaw.Actors/Reminders/ReminderManagerActor.cs b/src/Netclaw.Actors/Reminders/ReminderManagerActor.cs index 8c576b944..dfda66975 100644 --- a/src/Netclaw.Actors/Reminders/ReminderManagerActor.cs +++ b/src/Netclaw.Actors/Reminders/ReminderManagerActor.cs @@ -97,6 +97,7 @@ protected override void PreStart() } EmitDroppedInvalidDefinitionAlerts(); + EmitRejectedLegacyDefinitionAlerts(); Self.Tell(ReconcileReminders.Instance); } @@ -124,6 +125,32 @@ private void EmitDroppedInvalidDefinitionAlerts() _log.Warning("Dropped {0} invalid reminder definition(s) during startup: {1}", dropped.Count, droppedIds); } + private void EmitRejectedLegacyDefinitionAlerts() + { + var rejected = _definitionStore.ConsumeRejectedLegacyDefinitions(); + if (rejected.Count == 0) + return; + + var rejectedIds = string.Join(", ", rejected.Select(x => x.ReminderId)); + _notificationSink.Emit(OperationalAlert.Create( + _timeProvider, + "reminder.schema.legacy_rejected", + AlertType.ReminderSchemaDropped, + $"Rejected {rejected.Count} legacy reminder definition(s) missing trust fields during startup. Repair or recreate reminder IDs: {rejectedIds}.", + AlertSeverity.Warning, + source: "startup", + context: new Dictionary + { + ["rejectedCount"] = rejected.Count.ToString(), + ["rejectedIds"] = rejectedIds + })); + + _log.Warning( + "Rejected {0} legacy reminder definition(s) missing trust fields during startup: {1}", + rejected.Count, + rejectedIds); + } + private async Task HandleSaveAsync(SaveReminderCommand cmd) { var replyTo = Sender; @@ -199,17 +226,23 @@ static ReminderSavedResponse ValidationFailure(ReminderId id, string title, stri return; } - var effectiveAudience = authorization.EffectiveAudience ?? TrustAudience.Public; - var effectiveBoundary = ResolveReminderBoundary( - cmd.Definition.Boundary, - cmd.Definition.Delivery.OriginChannelType, - effectiveAudience); + // Non-null on the success path — IsSuccess was checked above, and a + // successful ReminderAudienceAuthorizationResult always carries an audience. + var effectiveAudience = authorization.EffectiveAudience!.Value; + var boundaryValidation = ValidateRequestedBoundary(cmd.Definition.Boundary, effectiveAudience); + if (!boundaryValidation.IsSuccess) + { + replyTo.Tell(ValidationFailure(id, title, boundaryValidation.ErrorMessage!)); + return; + } + + var effectiveBoundary = boundaryValidation.NormalizedBoundary!; var normalized = cmd.Definition with { Id = id.Value, Title = title, - Audience = authorization.EffectiveAudience, + Audience = effectiveAudience, Boundary = effectiveBoundary, CreatedBy = string.IsNullOrWhiteSpace(cmd.Definition.CreatedBy) ? "system" @@ -289,7 +322,7 @@ static ReminderSavedResponse ValidationFailure(ReminderId id, string title, stri } private static ReminderAudienceAuthorizationResult ValidateRequestedAudience( - TrustAudience? requestedAudience, + TrustAudience requestedAudience, ReminderAudienceAuthorizationContext? authorization) { if (authorization?.SourceAudience is not { } sourceAudience) @@ -298,7 +331,7 @@ private static ReminderAudienceAuthorizationResult ValidateRequestedAudience( "Reminder audience authorization context is required."); } - var effectiveAudience = requestedAudience ?? sourceAudience; + var effectiveAudience = requestedAudience; if (effectiveAudience > sourceAudience) { var sourceDescription = string.IsNullOrWhiteSpace(authorization.SourceDescription) @@ -312,13 +345,26 @@ private static ReminderAudienceAuthorizationResult ValidateRequestedAudience( return ReminderAudienceAuthorizationResult.Success(effectiveAudience); } - private static string ResolveReminderBoundary( + private static ReminderBoundaryValidationResult ValidateRequestedBoundary( string? requestedBoundary, - ChannelType? originChannelType, TrustAudience effectiveAudience) { - var channelType = (originChannelType ?? ChannelType.Reminder).ToWireValue(); - return SecurityPolicyDefaults.ResolveBoundary(requestedBoundary, channelType, effectiveAudience); + if (string.IsNullOrWhiteSpace(requestedBoundary)) + return ReminderBoundaryValidationResult.Fail("Reminder boundary is required."); + + if (!SecurityPolicyDefaults.TryNormalizeBoundary(requestedBoundary, out var normalizedBoundary)) + { + return ReminderBoundaryValidationResult.Fail( + $"Reminder boundary '{requestedBoundary}' is not a recognized trust boundary."); + } + + if (!SecurityPolicyDefaults.IsBoundaryCompatibleWithAudience(normalizedBoundary, effectiveAudience)) + { + return ReminderBoundaryValidationResult.Fail( + $"Reminder boundary '{normalizedBoundary}' is not allowed for audience '{effectiveAudience.ToWireValue()}'."); + } + + return ReminderBoundaryValidationResult.Success(normalizedBoundary); } private async Task HandleCancelAsync(CancelReminderCommand cmd) @@ -960,6 +1006,15 @@ public static ReminderAudienceAuthorizationResult Fail(string errorMessage) => new(false, null, errorMessage); } + private sealed record ReminderBoundaryValidationResult(bool IsSuccess, string? NormalizedBoundary, string? ErrorMessage) : INoSerializationVerificationNeeded + { + public static ReminderBoundaryValidationResult Success(string normalizedBoundary) + => new(true, normalizedBoundary, null); + + public static ReminderBoundaryValidationResult Fail(string errorMessage) + => new(false, null, errorMessage); + } + internal sealed record ReconcileReminders : INoSerializationVerificationNeeded { public static readonly ReconcileReminders Instance = new(); diff --git a/src/Netclaw.Actors/Reminders/ReminderProtocol.cs b/src/Netclaw.Actors/Reminders/ReminderProtocol.cs index eee8f922e..3c63193eb 100644 --- a/src/Netclaw.Actors/Reminders/ReminderProtocol.cs +++ b/src/Netclaw.Actors/Reminders/ReminderProtocol.cs @@ -185,17 +185,18 @@ public sealed record ReminderDefinition /// /// Persisted execution audience for this reminder. /// Conversational and tool-created reminders inherit the creating - /// session/channel audience when omitted at mint time. Reminder save paths - /// fail closed if they cannot resolve or authorize this audience. + /// session/channel audience at mint time. Legacy documents missing this + /// field are rejected at load and are never scheduled. /// - public TrustAudience? Audience { get; init; } + public required TrustAudience Audience { get; init; } /// /// Persisted execution boundary for this reminder. - /// For Mode B reminders this should mirror the creating session's - /// effective trust boundary so reminder re-entry does not widen scope. + /// For Mode B reminders this mirrors the creating session's effective trust + /// boundary so reminder re-entry does not widen scope. Legacy documents + /// missing this field are rejected at load and are never scheduled. /// - public string? Boundary { get; init; } + public required string Boundary { get; init; } public string CreatedBy { get; init; } = "system"; public long CreatedAtMs { get; set; } diff --git a/src/Netclaw.Actors/Reminders/SetReminderTool.cs b/src/Netclaw.Actors/Reminders/SetReminderTool.cs index 14cf0323f..fa3981f2a 100644 --- a/src/Netclaw.Actors/Reminders/SetReminderTool.cs +++ b/src/Netclaw.Actors/Reminders/SetReminderTool.cs @@ -212,21 +212,26 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon audience = parsedAudience; } - TrustAudience? sourceAudience = null; - if (!string.IsNullOrWhiteSpace(context.Audience)) - { - if (!SecurityPolicyDefaults.TryParseAudience(context.Audience, out var parsedSourceAudience)) - return $"Error: Invalid source audience '{context.Audience}' in tool execution context."; - - sourceAudience = parsedSourceAudience; - } + // Audience is already parsed on the execution context — no wire-string + // parse, no parse-failure fallback. + var sourceAudience = context.Audience; + var effectiveRequestedAudience = audience ?? sourceAudience; string? boundary = null; if (!string.IsNullOrWhiteSpace(context.Boundary)) boundary = context.Boundary.Trim(); - if (string.IsNullOrWhiteSpace(boundary) && sourceAudience is { } resolvedSourceAudience) - boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(resolvedSourceAudience); + // When a reminder explicitly downscopes its audience, it must not carry + // over the creating session's broader boundary. Recompute the boundary + // from the requested audience instead. + if (audience is { } explicitAudience && explicitAudience != sourceAudience) + { + boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(explicitAudience); + } + else if (string.IsNullOrWhiteSpace(boundary)) + { + boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(effectiveRequestedAudience); + } DateTimeOffset? expiresAt = null; if (!string.IsNullOrWhiteSpace(args.ExpiresIn)) @@ -249,8 +254,11 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon Delivery = delivery, DeliveryRequired = args.DeliveryRequired, DeliveryInstructions = args.DeliveryInstructions, - Audience = audience, - Boundary = boundary, + // Draft trust context — ReminderManagerActor re-resolves and + // re-authorizes Audience/Boundary before persisting. Prefer the + // explicitly requested audience, then the creating session's. + Audience = effectiveRequestedAudience, + Boundary = boundary ?? SecurityPolicyDefaults.PublicBoundary, Enabled = true, ExpiresAt = expiresAt, CreatedBy = "llm-tool", diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 57218dc50..589c4167f 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -2787,7 +2787,8 @@ private async Task ExecuteRoutedSkillAsync( { var context = new ToolExecutionContext(_sessionId.Value, GetSessionDirectory()) { - Audience = _currentTurnSource is null ? null : _currentTurnSource.Audience.ToWireValue(), + // No active turn source carries no trust context — fall closed. + Audience = _currentTurnSource?.Audience ?? TrustAudience.Public, Boundary = _currentTurnSource?.Boundary, ChannelType = _currentTurnSource is null ? null : _currentTurnSource.ChannelType.ToWireValue(), ProjectDirectory = _state.WorkingContext.ProjectDirectory, diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index 551ca5b38..fe43b95c9 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -568,17 +568,24 @@ private static async Task RouteToBackgroundJobAsync( return new ToolCallResult(message, [], [], []); } + // A background job inherits the submitting turn's trust context. There is + // no safe default — defaulting a missing source to Personal would silently + // escalate the job's audience. A null source here is a programming error. + if (source is null) + throw new InvalidOperationException( + "Background-job submission requires a turn source; trust context cannot be defaulted."); + var startCmd = new StartBackgroundJob { Command = command, WorkingDirectory = workingDirectory, SessionId = sessionId, Rationale = meta.Rationale ?? "background shell execution", - Audience = source?.Audience ?? TrustAudience.Personal, - Boundary = source?.Boundary ?? SecurityPolicyDefaults.PersonalBoundary, - OriginChannelType = source?.ChannelType ?? ChannelType.Tui, + Audience = source.Audience, + Boundary = source.Boundary, + OriginChannelType = source.ChannelType, TimeoutSeconds = timeoutSeconds, - SenderId = source?.SenderId + SenderId = source.SenderId }; try @@ -638,8 +645,13 @@ private static ToolExecutionContext BuildToolExecutionContext( Func> spawnChildActor, string? projectDirectory) { - var context = new ToolExecutionContext(sessionId.Value, sessionDir); - context.Audience = source is null ? null : source.Audience.ToWireValue(); + // A turn with no source carries no trust context — fall closed to the + // most-restrictive audience. The default is resolved once, here, so every + // downstream tool gate reads a guaranteed audience. + var context = new ToolExecutionContext(sessionId.Value, sessionDir) + { + Audience = source?.Audience ?? TrustAudience.Public, + }; context.Boundary = source?.Boundary; context.ChannelType = source is null ? null : source.ChannelType.ToWireValue(); context.SupportsInteractiveApproval = source?.ChannelType.SupportsInteractiveApproval(); diff --git a/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs b/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs index 93258e9cf..4c9f70c72 100644 --- a/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs +++ b/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs @@ -56,7 +56,7 @@ protected override Task ExecuteAsync(Params args, CancellationToken ct) protected override async Task ExecuteAsync(Params args, ToolExecutionContext context, CancellationToken ct) { // Defense-in-depth: block subagent spawning for Public audience or when subagent subsystem is disabled - var audience = SecurityPolicyDefaults.ParseAudienceOrPublic(context.Audience); + var audience = context.Audience; if (audience == TrustAudience.Public || !_subAgentConfig.Enabled) return "Error: This tool is not available."; diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index f4547b143..80fd82fac 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -104,8 +104,28 @@ private void Idle() var scopeId = !string.IsNullOrWhiteSpace(msg.SessionScopeId) ? msg.SessionScopeId! : $"subagent/{_definition.Name}/{Guid.NewGuid():N}"; - _toolExecutionContext = new ToolExecutionContext(scopeId, msg.ParentSessionDirectory); - _toolExecutionContext.Audience = msg.Audience ?? TrustAudience.Personal.ToWireValue(); + // A sub-agent inherits the spawning session's audience. A spawn with no + // audience is a programming error — defaulting to Personal would + // silently grant the sub-agent broader trust than its parent. Fail the + // run immediately with a result so the caller fails fast, rather than + // throwing (which crashes the actor and makes the caller wait out the + // Ask timeout). + if (msg.Audience is not { } subAgentAudience) + { + _log.Error( + "SubAgent [{AgentName}] spawn rejected: RunSubAgent carried no trust audience.", + _definition.Name); + Complete( + success: false, + "Sub-agent spawn failed: no trust audience was provided. A sub-agent " + + "must inherit the spawning session's audience."); + return; + } + + _toolExecutionContext = new ToolExecutionContext(scopeId, msg.ParentSessionDirectory) + { + Audience = subAgentAudience, + }; _toolExecutionContext.Boundary = msg.Boundary; _toolExecutionContext.ChannelType = msg.ChannelType; _toolExecutionContext.ProjectDirectory = msg.ParentProjectDirectory; diff --git a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs index 7affe9703..3cf124b3b 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Akka.Actor; using Microsoft.Extensions.AI; +using Netclaw.Configuration; using Netclaw.Tools; namespace Netclaw.Actors.SubAgents; @@ -71,7 +72,12 @@ public sealed record RunSubAgent : INoSerializationVerificationNeeded /// public string? SessionScopeId { get; init; } - public string? Audience { get; init; } + /// + /// Trust audience inherited from the spawning session. A parsed + /// — the sub-agent actor rejects a spawn with no + /// audience rather than defaulting it. + /// + public TrustAudience? Audience { get; init; } public string? Boundary { get; init; } diff --git a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs index c681b1596..620fbdb7c 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs @@ -254,7 +254,6 @@ private static string AppendSystemPromptOverlay(string basePrompt, string? overl if (string.IsNullOrWhiteSpace(context.ProjectDirectory)) return null; - var audience = SecurityPolicyDefaults.ParseAudienceOrPublic(context.Audience); - return _promptProvider.GetProjectInstructions(audience, context.ProjectDirectory); + return _promptProvider.GetProjectInstructions(context.Audience, context.ProjectDirectory); } } diff --git a/src/Netclaw.Actors/Tools/SkillLoadTool.cs b/src/Netclaw.Actors/Tools/SkillLoadTool.cs index f8577a3ea..ac320751f 100644 --- a/src/Netclaw.Actors/Tools/SkillLoadTool.cs +++ b/src/Netclaw.Actors/Tools/SkillLoadTool.cs @@ -68,7 +68,7 @@ protected override async Task ExecuteAsync(Params args, CancellationToke protected override async Task ExecuteAsync(Params args, ToolExecutionContext context, CancellationToken ct) { // Defense-in-depth: block skill loading for Public audience or when skills subsystem is disabled - var audience = SecurityPolicyDefaults.ParseAudienceOrPublic(context.Audience); + var audience = context.Audience; if (audience == TrustAudience.Public || !_skillSyncConfig.Enabled) return "Error: This tool is not available."; diff --git a/src/Netclaw.Actors/Tools/SkillReadResourceTool.cs b/src/Netclaw.Actors/Tools/SkillReadResourceTool.cs index 765f87343..306754d09 100644 --- a/src/Netclaw.Actors/Tools/SkillReadResourceTool.cs +++ b/src/Netclaw.Actors/Tools/SkillReadResourceTool.cs @@ -52,7 +52,7 @@ protected override async Task ExecuteAsync(Params args, CancellationToke protected override async Task ExecuteAsync(Params args, ToolExecutionContext context, CancellationToken ct) { // Defense-in-depth: block skill resource reading for Public audience or when skills subsystem is disabled - var audience = SecurityPolicyDefaults.ParseAudienceOrPublic(context.Audience); + var audience = context.Audience; if (audience == TrustAudience.Public || !_skillSyncConfig.Enabled) return "Error: This tool is not available."; diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 1ca0f9b35..64efd5ead 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -550,7 +550,7 @@ private static TrustAudience ResolveAudience(ToolExecutionContext? context) => SecurityPolicyDefaults.ResolveAudienceWithFallback(context?.Audience, context?.SessionId); private static ToolExecutionContext CreateContext(TrustAudience audience) - => new(null, null) { Audience = audience.ToWireValue() }; + => new(null, null) { Audience = audience }; private static bool IsShellTool(ToolRegistration registration) => registration.GrantCategory == "shell" || IsShellTool(registration.Tool); diff --git a/src/Netclaw.Actors/Tools/ToolRegistry.cs b/src/Netclaw.Actors/Tools/ToolRegistry.cs index c868bcfc7..55a10cf42 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistry.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistry.cs @@ -272,7 +272,7 @@ private static IReadOnlyList GetMcpServerSummaries(IReadOnlyLi } private static ToolExecutionContext CreateContext(TrustAudience audience) - => new(null, null) { Audience = audience.ToWireValue() }; + => new(null, null) { Audience = audience }; private static string DescribeServerCapability(string serverName, IReadOnlyList tools) { diff --git a/src/Netclaw.Channels.Discord/DiscordAclPolicy.cs b/src/Netclaw.Channels.Discord/DiscordAclPolicy.cs index 082d704c4..cb7214589 100644 --- a/src/Netclaw.Channels.Discord/DiscordAclPolicy.cs +++ b/src/Netclaw.Channels.Discord/DiscordAclPolicy.cs @@ -45,10 +45,10 @@ public static ChannelAclDecision EvaluateInbound( return ChannelAclDecision.Allow( audience, principal, - new SourceProvenance + new SourceProvenance( + TransportAuthenticity.Verified, + PayloadTaint.Public) { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Public, SourceKind = "discord", SourceScope = message.ChannelId.Value }); diff --git a/src/Netclaw.Channels.Discord/DiscordChannel.cs b/src/Netclaw.Channels.Discord/DiscordChannel.cs index 290146cdf..725248348 100644 --- a/src/Netclaw.Channels.Discord/DiscordChannel.cs +++ b/src/Netclaw.Channels.Discord/DiscordChannel.cs @@ -59,7 +59,11 @@ public DiscordChannel( _gatewayClient = gatewayClient; _replyClient = replyClient; _contentScanner = contentScanner; - _promptInjectionDetector = promptInjectionDetector ?? new NullPromptInjectionDetector(); + // Fail loud rather than substituting a no-op detector — a no-op reports + // every input as safe, silently disabling injection scanning. A null + // here means broken DI wiring. + _promptInjectionDetector = promptInjectionDetector + ?? throw new ArgumentNullException(nameof(promptInjectionDetector)); _httpClientFactory = httpClientFactory; _threadHistoryFetcher = threadHistoryFetcher; _notificationSink = notificationSink; diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 6ca29ffaf..c9445c733 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -78,7 +78,13 @@ public DiscordSessionBindingActor( _threadOrMessageId = threadOrMessageId; _rootMessageId = rootMessageId; _dependencies = dependencies; - _promptInjectionDetector = dependencies.PromptInjectionDetector ?? new NullPromptInjectionDetector(); + // Fail loud rather than substituting a no-op detector — a no-op reports + // every input as safe, silently disabling injection scanning. A null + // here means broken gateway wiring. + _promptInjectionDetector = dependencies.PromptInjectionDetector + ?? throw new InvalidOperationException( + "DiscordGatewayDependencies.PromptInjectionDetector is not wired; " + + "prompt-injection scanning cannot be silently disabled."); _log = Context.GetLogger() .WithContext("Adapter", "discord") @@ -130,16 +136,6 @@ protected override void PostStop() private SessionPipelineOptions BuildOptions() => new() { ChannelType = ChannelType.Discord, - DefaultAudience = TrustAudience.Team, - DefaultBoundary = SecurityPolicyDefaults.TrustedInstanceBoundary, - DefaultPrincipal = PrincipalClassification.UntrustedExternal, - DefaultProvenance = new SourceProvenance - { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Public, - SourceKind = "discord", - SourceScope = _channelId.Value - }, Filter = OutputFilter.Text | OutputFilter.Files }; diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs b/src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs index 28c9bb7cc..b760ed377 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs @@ -224,11 +224,12 @@ public async Task> FetchThreadHistoryAsync( ChannelId = channelId.Value, MessageId = message.MessageId, Audience = audience, + Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, Principal = PrincipalClassification.UntrustedExternal, - Provenance = new SourceProvenance + Provenance = new SourceProvenance( + TransportAuthenticity.Verified, + PayloadTaint.Public) { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Public, SourceKind = "discord", SourceScope = threadChannelId.ToString() }, diff --git a/src/Netclaw.Channels.Slack/SlackAclPolicy.cs b/src/Netclaw.Channels.Slack/SlackAclPolicy.cs index a94b63991..d541f6674 100644 --- a/src/Netclaw.Channels.Slack/SlackAclPolicy.cs +++ b/src/Netclaw.Channels.Slack/SlackAclPolicy.cs @@ -51,10 +51,10 @@ public static ChannelAclDecision EvaluateInbound( return ChannelAclDecision.Allow( audience, principal, - new SourceProvenance + new SourceProvenance( + TransportAuthenticity.Verified, + PayloadTaint.Public) { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Public, SourceKind = "slack", SourceScope = message.ChannelId.Value }); diff --git a/src/Netclaw.Channels.Slack/SlackChannel.cs b/src/Netclaw.Channels.Slack/SlackChannel.cs index b93053ebe..6539b3488 100644 --- a/src/Netclaw.Channels.Slack/SlackChannel.cs +++ b/src/Netclaw.Channels.Slack/SlackChannel.cs @@ -74,7 +74,11 @@ public SlackChannel( _replyClient = replyClient; _ingressGate = ingressGate; _contentScanner = contentScanner; - _promptInjectionDetector = promptInjectionDetector ?? new NullPromptInjectionDetector(); + // Fail loud rather than substituting a no-op detector — a no-op reports + // every input as safe, silently disabling injection scanning. A null + // here means broken DI wiring. + _promptInjectionDetector = promptInjectionDetector + ?? throw new ArgumentNullException(nameof(promptInjectionDetector)); _httpClientFactory = httpClientFactory; _notificationSink = notificationSink; _timeProvider = timeProvider; diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index 63f8e41cd..97238ee1e 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -65,7 +65,13 @@ public SlackThreadBindingActor( _channelId = channelId; _threadTs = threadTs; _dependencies = dependencies; - _promptInjectionDetector = dependencies.PromptInjectionDetector ?? new NullPromptInjectionDetector(); + // Fail loud rather than substituting a no-op detector — a no-op reports + // every input as safe, silently disabling injection scanning. A null + // here means broken gateway wiring. + _promptInjectionDetector = dependencies.PromptInjectionDetector + ?? throw new InvalidOperationException( + "SlackGatewayDependencies.PromptInjectionDetector is not wired; " + + "prompt-injection scanning cannot be silently disabled."); _handle = new SessionPipelineHandle(dependencies.Pipeline, Context.GetLogger(), "slack-thread"); _log = Context.GetLogger() .WithContext("Adapter", "slack") @@ -693,15 +699,6 @@ public sealed record Rejected(string UserFacingReason) : AttachmentIngestResult; private SessionPipelineOptions BuildOptions() => new() { ChannelType = Actors.Channels.ChannelType.Slack, - DefaultAudience = TrustAudience.Public, - DefaultBoundary = SecurityPolicyDefaults.SlackWorkspaceBoundary, - DefaultPrincipal = PrincipalClassification.UntrustedExternal, - DefaultProvenance = new SourceProvenance - { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = PayloadTaint.Public, - SourceKind = "slack" - }, Filter = OutputFilter.Text | OutputFilter.Files }; @@ -739,6 +736,7 @@ private InboundBuildResult BuildInputForInbound( ChannelId = _channelId.Value, MessageId = triggeringMessage.EventId.Value, Audience = triggeringMessage.Audience, + Boundary = SecurityPolicyDefaults.SlackWorkspaceBoundary, Principal = triggeringMessage.Principal, Provenance = triggeringMessage.Provenance, Contents = liveContents, diff --git a/src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs b/src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs index 10473c475..d204abd87 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs @@ -263,6 +263,15 @@ private async Task> FetchRepliesAsync( ChannelId = channelId.Value, MessageId = $"{channelId.Value}:{message.Ts ?? string.Empty}", Audience = audience, + Boundary = SecurityPolicyDefaults.SlackWorkspaceBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance( + TransportAuthenticity.Verified, + PayloadTaint.Public) + { + SourceKind = "slack", + SourceScope = channelId.Value + }, Contents = contents, ReceivedAt = receivedAt }; diff --git a/src/Netclaw.Channels/IAclDecision.cs b/src/Netclaw.Channels/IAclDecision.cs index 2dd3d72df..400c02e1b 100644 --- a/src/Netclaw.Channels/IAclDecision.cs +++ b/src/Netclaw.Channels/IAclDecision.cs @@ -29,7 +29,10 @@ public sealed record ChannelAclDecision( reason, TrustAudience.Public, PrincipalClassification.UntrustedExternal, - SourceProvenance.StrictDefault()); + // Fail-closed conservative provenance on the deny path: Unverified + // transport, Public taint. A denied decision never grants access, so + // these markers are a sentinel, not a trust input. + new SourceProvenance(TransportAuthenticity.Unverified, PayloadTaint.Public)); public static ChannelAclDecision Allow( TrustAudience audience, diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientReconnectIntegrationTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientReconnectIntegrationTests.cs index c8226c0f8..a1e909361 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientReconnectIntegrationTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientReconnectIntegrationTests.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Netclaw.Actors.Channels; @@ -63,12 +62,7 @@ public async Task EnsureSession_reattaches_same_session_after_transport_disconne try { - await client.SendAsync(new ChannelInput - { - SenderId = "test", - Contents = [new TextContent("drop")], - ReceivedAt = DateTimeOffset.UtcNow - }, TestContext.Current.CancellationToken); + await client.SendAsync("drop", TestContext.Current.CancellationToken); } catch (Exception ex) { @@ -81,12 +75,7 @@ await client.SendAsync(new ChannelInput var ensured = await client.EnsureSessionAsync(ChannelType.Tui, TestContext.Current.CancellationToken); Assert.Equal(sessionId, ensured); - await client.SendAsync(new ChannelInput - { - SenderId = "test", - Contents = [new TextContent("after")], - ReceivedAt = DateTimeOffset.UtcNow - }, TestContext.Current.CancellationToken); + await client.SendAsync("after", TestContext.Current.CancellationToken); await WaitFor(reconnectedOutput.Task, TimeSpan.FromSeconds(5)); } @@ -146,12 +135,7 @@ public async Task EnsureSession_recreates_session_after_server_restart() }); await client.CreateSessionAsync(ChannelType.Tui, TestContext.Current.CancellationToken); - await client.SendAsync(new ChannelInput - { - SenderId = "test", - Contents = [new TextContent("first")], - ReceivedAt = DateTimeOffset.UtcNow - }, TestContext.Current.CancellationToken); + await client.SendAsync("first", TestContext.Current.CancellationToken); await WaitFor(firstResponseReceived.Task, TimeSpan.FromSeconds(5)); @@ -173,12 +157,7 @@ await client.SendAsync(new ChannelInput await WaitFor(reconnectedAfterRestart.Task, TimeSpan.FromSeconds(15)); await client.EnsureSessionAsync(ChannelType.Tui, TestContext.Current.CancellationToken); - await client.SendAsync(new ChannelInput - { - SenderId = "test", - Contents = [new TextContent("second")], - ReceivedAt = DateTimeOffset.UtcNow - }, TestContext.Current.CancellationToken); + await client.SendAsync("second", TestContext.Current.CancellationToken); await WaitFor(secondResponseReceived.Task, TimeSpan.FromSeconds(10)); diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index 0f1d38cec..3da756ed2 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -48,12 +48,7 @@ public async Task ResumeSessionAsync_reattaches_to_existing_session_via_EnsureSe Assert.Equal(originalSessionId, resumedSessionId); // Verify the session is functional — can send and receive messages - await client2.SendAsync(new Netclaw.Actors.Channels.ChannelInput - { - SenderId = "test", - Contents = [new Microsoft.Extensions.AI.TextContent("hello-resumed")], - ReceivedAt = DateTimeOffset.UtcNow - }, TestContext.Current.CancellationToken); + await client2.SendAsync("hello-resumed", TestContext.Current.CancellationToken); await outputReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); } diff --git a/src/Netclaw.Cli/Daemon/DaemonClient.cs b/src/Netclaw.Cli/Daemon/DaemonClient.cs index ef843eef7..14d66273a 100644 --- a/src/Netclaw.Cli/Daemon/DaemonClient.cs +++ b/src/Netclaw.Cli/Daemon/DaemonClient.cs @@ -257,9 +257,10 @@ public async Task ResumeSessionAsync( return await EnsureSessionInternalAsync(channelType, cancellationToken); } - public async Task SendAsync(ChannelInput input, CancellationToken cancellationToken = default) + public async Task SendAsync(string text, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(input); + if (string.IsNullOrWhiteSpace(text)) + throw new InvalidOperationException("Only non-empty text messages are currently supported."); // Hold the session gate while reading _sessionId to prevent reading // a stale value during a concurrent EnsureSessionInternalAsync call @@ -279,10 +280,9 @@ public async Task SendAsync(ChannelInput input, CancellationToken cancellationTo _sessionGate.Release(); } - var text = input.Contents.OfType().Select(x => x.Text).FirstOrDefault(); - if (string.IsNullOrWhiteSpace(text)) - throw new InvalidOperationException("Only non-empty text messages are currently supported."); - + // The daemon derives the session's trust context server-side from the + // authenticated SignalR principal — the client only supplies message + // text, never trust fields. await _connection.InvokeCoreAsync( "SendMessage", [sessionId, text], diff --git a/src/Netclaw.Cli/HeadlessChannel.cs b/src/Netclaw.Cli/HeadlessChannel.cs index cea90896c..89a74386a 100644 --- a/src/Netclaw.Cli/HeadlessChannel.cs +++ b/src/Netclaw.Cli/HeadlessChannel.cs @@ -143,12 +143,7 @@ private async Task RunHeadlessAsync(CancellationToken stopping) _promptSentTicks = Stopwatch.GetTimestamp(); - await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput - { - SenderId = "local-user", - Contents = [new TextContent(_prompt)], - ReceivedAt = _timeProvider.GetUtcNow() - }, stopping); + await _daemonClient.SendAsync(_prompt, stopping); _logger.LogInformation("Headless session started: {SessionId} (log: {LogPath})", sessionId, logPath); diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index 2ca700f81..66ebbf11d 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -168,12 +168,7 @@ public async Task SubmitAsync(string text) { await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); - await _daemonClient.SendAsync(new ChannelInput - { - SenderId = "local-user", - Contents = [new TextContent(text)], - ReceivedAt = _timeProvider.GetUtcNow() - }); + await _daemonClient.SendAsync(text); } catch (Exception ex) { @@ -328,12 +323,7 @@ private async Task EnsureSessionAndFlushAsync() while (_pendingMessages.Count > 0) { var pending = _pendingMessages.Dequeue(); - await _daemonClient.SendAsync(new ChannelInput - { - SenderId = "local-user", - Contents = [new TextContent(pending)], - ReceivedAt = _timeProvider.GetUtcNow() - }); + await _daemonClient.SendAsync(pending); } // Auto-send hidden trigger message (e.g., onboarding interview prompt). @@ -345,12 +335,7 @@ await _daemonClient.SendAsync(new ChannelInput IsGenerating.Value = true; StatusMessage.Value = "Generating..."; RequestRedraw(); - await _daemonClient.SendAsync(new ChannelInput - { - SenderId = "system-init", - Contents = [new TextContent(trigger)], - ReceivedAt = _timeProvider.GetUtcNow() - }); + await _daemonClient.SendAsync(trigger); return; } diff --git a/src/Netclaw.Configuration/OperationalAlert.cs b/src/Netclaw.Configuration/OperationalAlert.cs index 223547896..7495c3f25 100644 --- a/src/Netclaw.Configuration/OperationalAlert.cs +++ b/src/Netclaw.Configuration/OperationalAlert.cs @@ -30,6 +30,7 @@ public enum AlertType ReminderExecutionFailed, ReminderAutoDisabled, ReminderSchemaDropped, + BackgroundJobSchemaDropped, WebhookReceived, WebhookRouteInvalid, DaemonStarted, diff --git a/src/Netclaw.Configuration/TrustContextPolicy.cs b/src/Netclaw.Configuration/TrustContextPolicy.cs index cce5f44ed..4c0b6fc0d 100644 --- a/src/Netclaw.Configuration/TrustContextPolicy.cs +++ b/src/Netclaw.Configuration/TrustContextPolicy.cs @@ -127,13 +127,6 @@ public static class SecurityPolicyDefaults _ => throw new ArgumentOutOfRangeException(nameof(audience), audience, null) }; - /// - /// Parses audience from wire format, defaulting to on failure. - /// Use in defense-in-depth tool gates where unparseable input must deny access. - /// - public static TrustAudience ParseAudienceOrPublic(string? wire) - => TryParseAudience(wire, out var a) ? a : TrustAudience.Public; - public static bool TryParseAudience(string? wire, out TrustAudience audience) { if (string.Equals(wire, "public", StringComparison.OrdinalIgnoreCase)) @@ -224,15 +217,13 @@ public static TrustAudience ResolveAudienceFromSessionId(string? sessionId) /// /// Resolves the effective audience for a tool invocation, preferring the - /// explicit wire value when it parses - /// and falling back to otherwise. - /// Centralizes the pattern previously copied across the scoped-policy / - /// dispatching / audience-profile helpers in Netclaw.Actors.Tools. + /// explicit parsed when present and + /// falling back to only when no + /// audience was supplied at all. There is no wire-string parsing here — the + /// audience is parsed once, upstream, when the execution context is built. /// - public static TrustAudience ResolveAudienceWithFallback(string? configuredAudience, string? sessionId) - => TryParseAudience(configuredAudience, out var parsed) - ? parsed - : ResolveAudienceFromSessionId(sessionId); + public static TrustAudience ResolveAudienceWithFallback(TrustAudience? configuredAudience, string? sessionId) + => configuredAudience ?? ResolveAudienceFromSessionId(sessionId); public static string ResolveBoundaryFromAudience(TrustAudience audience) => audience switch { @@ -242,6 +233,70 @@ public static TrustAudience ResolveAudienceWithFallback(string? configuredAudien _ => PublicBoundary }; + /// + /// Canonicalizes a known trust boundary string. Returns false when the + /// boundary is blank or not one of Netclaw's supported persisted values. + /// + public static bool TryNormalizeBoundary(string? boundary, out string normalizedBoundary) + { + normalizedBoundary = PublicBoundary; + if (string.IsNullOrWhiteSpace(boundary)) + return false; + + var trimmed = boundary.Trim(); + if (string.Equals(trimmed, PublicBoundary, StringComparison.OrdinalIgnoreCase)) + { + normalizedBoundary = PublicBoundary; + return true; + } + + if (string.Equals(trimmed, TeamBoundary, StringComparison.OrdinalIgnoreCase)) + { + normalizedBoundary = TeamBoundary; + return true; + } + + if (string.Equals(trimmed, PersonalBoundary, StringComparison.OrdinalIgnoreCase)) + { + normalizedBoundary = PersonalBoundary; + return true; + } + + if (string.Equals(trimmed, TrustedInstanceBoundary, StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmed, SlackWorkspaceBoundary, StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmed, LocalDaemonBoundary, StringComparison.OrdinalIgnoreCase)) + { + normalizedBoundary = TrustedInstanceBoundary; + return true; + } + + return false; + } + + /// + /// Returns true when a boundary is a supported canonical value whose scope + /// does not exceed the supplied audience. Narrower boundaries are allowed: + /// for example, a Personal audience may persist a Public boundary. + /// + public static bool IsBoundaryCompatibleWithAudience(string boundary, TrustAudience audience) + { + if (!TryNormalizeBoundary(boundary, out var normalizedBoundary)) + return false; + + if (string.Equals(normalizedBoundary, TrustedInstanceBoundary, StringComparison.Ordinal)) + return audience is TrustAudience.Team or TrustAudience.Personal; + + var boundaryAudience = normalizedBoundary switch + { + PublicBoundary => TrustAudience.Public, + TeamBoundary => TrustAudience.Team, + PersonalBoundary => TrustAudience.Personal, + _ => throw new ArgumentOutOfRangeException(nameof(boundary), boundary, null) + }; + + return boundaryAudience <= audience; + } + public static EffectivePolicyDefaults Resolve(SecurityPolicyConfig? config) { var strictDefaults = config?.StrictDefaults ?? true; diff --git a/src/Netclaw.Daemon.Tests/Gateway/SignalRMessageExtractorTests.cs b/src/Netclaw.Daemon.Tests/Gateway/SignalRMessageExtractorTests.cs index a87b81bd6..202045318 100644 --- a/src/Netclaw.Daemon.Tests/Gateway/SignalRMessageExtractorTests.cs +++ b/src/Netclaw.Daemon.Tests/Gateway/SignalRMessageExtractorTests.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Netclaw.Actors.Channels; using Netclaw.Actors.Protocol; +using Netclaw.Configuration; using Netclaw.Daemon.Gateway; using Xunit; @@ -22,7 +23,11 @@ public sealed class SignalRMessageExtractorTests private static readonly MessageSource EmptySource = new() { ChannelType = ChannelType.SignalR, - SenderId = "test" + SenderId = "test", + Audience = TrustAudience.Public, + Boundary = SecurityPolicyDefaults.PublicBoundary, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance(TransportAuthenticity.Unverified, PayloadTaint.Public) }; [Fact] diff --git a/src/Netclaw.Daemon.Tests/Lifecycle/LifecycleEndpointRouteBuilderExtensionsTests.cs b/src/Netclaw.Daemon.Tests/Lifecycle/LifecycleEndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 000000000..620df1d70 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Lifecycle/LifecycleEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,134 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Configuration; +using Netclaw.Daemon.Lifecycle; +using Netclaw.Daemon.Security; +using Netclaw.Daemon.Services; +using Xunit; + +namespace Netclaw.Daemon.Tests.Lifecycle; + +/// +/// Real integration tests for the lifecycle endpoints registered by +/// . +/// +/// The test host calls the actual extension method — no handler reimplementation. +/// +public sealed class LifecycleEndpointRouteBuilderExtensionsTests : IAsyncDisposable +{ + private readonly TrackingNotificationSink _sink = new(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + // ─── App factory ─────────────────────────────────────────────────────────── + + private async Task CreateAppAsync(bool spoofLoopback) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddNetclawAuthSchemes(new DaemonConfig()); + builder.Services.AddAuthorization(); + builder.Services.AddLogging(); + builder.Services.AddSingleton(_sink); + builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); + + var app = builder.Build(); + + if (spoofLoopback) + { + app.Use(async (ctx, next) => + { + ctx.Connection.RemoteIpAddress = IPAddress.Loopback; + await next(ctx); + }); + } + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapLifecycleEndpoints(); + + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + // ─── POST /api/lifecycle/shutdown ───────────────────────────────────────── + + [Fact] + public async Task Shutdown_returns_401_for_unauthenticated_request() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: false); + var client = app.GetTestClient(); + + var response = await client.PostAsync("/api/lifecycle/shutdown?reason=test", null, ct); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Empty(_sink.Emitted); + } + + [Fact] + public async Task Shutdown_returns_400_when_reason_is_missing() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.PostAsync("/api/lifecycle/shutdown", null, ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.Contains("reason", body.GetProperty("error").GetString()); + Assert.Empty(_sink.Emitted); + } + + [Fact] + public async Task Shutdown_returns_200_with_reason_echo_and_invokes_notifier() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + const string shutdownReason = "cli-stop"; + var response = await client.PostAsync($"/api/lifecycle/shutdown?reason={shutdownReason}", null, ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.Equal(shutdownReason, body.GetProperty("reason").GetString()); + Assert.True(body.TryGetProperty("pid", out _)); + + // Verify NotifyShutdown was invoked via the notification sink + Assert.Single(_sink.Emitted); + var alert = _sink.Emitted[0]; + Assert.Equal("daemon.stopping", alert.Type); + Assert.NotNull(alert.Context); + Assert.Equal(shutdownReason, alert.Context["reason"]); + } + + // ─── Notification sink ──────────────────────────────────────────────────── + + /// + /// Records all emitted operational alerts so tests can assert that + /// was invoked. + /// + private sealed class TrackingNotificationSink : IOperationalNotificationSink + { + private readonly List _emitted = []; + public IReadOnlyList Emitted => _emitted; + + public void Emit(OperationalAlert alert) => _emitted.Add(alert); + } +} diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 000000000..27ce28cb0 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Mcp/McpEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,433 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Netclaw.Actors.Tools; +using Netclaw.Configuration; +using Netclaw.Daemon.Mcp; +using Netclaw.Daemon.Security; +using Netclaw.Providers.OAuth; +using Netclaw.Tests.Utilities; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Daemon.Tests.Mcp; + +/// +/// Real integration tests for the MCP endpoints registered by +/// . +/// +/// The test host calls the actual extension method — no handler reimplementation. +/// +public sealed class McpEndpointRouteBuilderExtensionsTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + + public void Dispose() => _dir.Dispose(); + + // ─── App factory ─────────────────────────────────────────────────────────── + + /// + /// Creates a test host wired with real . + /// Constructs a real over a + /// so tests can exercise OAuth state transitions without a live server. + /// + private async Task CreateAppAsync( + bool spoofLoopback, + Func? discoveryHandler = null, + Func? tokenHandler = null, + Dictionary? mcpServers = null, + IMcpReconnectable? reconnectable = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddNetclawAuthSchemes(new DaemonConfig()); + builder.Services.AddAuthorization(); + builder.Services.AddLogging(); + + var paths = new NetclawPaths(_dir.Path); + paths.EnsureDirectoriesExist(); + + var servers = mcpServers ?? []; + + // Construct a real McpOAuthService over fake HTTP handlers + var discoveryClient = new HttpClient( + new FakeHttpMessageHandler(discoveryHandler ?? DefaultDiscoveryHandler)); + var pkceService = new OAuthPkceService( + new HttpClient(new FakeHttpMessageHandler(tokenHandler ?? DefaultTokenHandler))); + var oauthService = new McpOAuthService( + discoveryClient, + paths, + TimeProvider.System, + NullLogger.Instance, + pkceService, + NullNotificationSink.Instance); + + // Minimal McpClientManager with empty state + var toolRegistry = new ToolRegistry(); + var mcpManager = new McpClientManager( + servers, + toolRegistry, + new ToolConfig(), + oauthService, + NullNotificationSink.Instance, + TimeProvider.System, + NullLogger.Instance); + + builder.Services.AddSingleton(oauthService); + builder.Services.AddSingleton(mcpManager); + builder.Services.AddSingleton(mcpManager); + builder.Services.AddSingleton(servers); + builder.Services.AddSingleton(reconnectable ?? new NoOpReconnectable()); + builder.Services.AddSingleton>(NullLogger.Instance); + + var app = builder.Build(); + + if (spoofLoopback) + { + app.Use(async (ctx, next) => + { + ctx.Connection.RemoteIpAddress = IPAddress.Loopback; + await next(ctx); + }); + } + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcpEndpoints(); + + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + // ─── Auth gates — five .RequireAuthorization() endpoints → 401 ──────────── + + [Theory] + [InlineData("POST", "/api/mcp/oauth/start/test-server")] + [InlineData("GET", "/api/mcp/statuses")] + [InlineData("GET", "/api/mcp/tools/test-server")] + [InlineData("GET", "/api/mcp/oauth/status/test-server")] + [InlineData("GET", "/api/mcp/oauth/status-by-state/some-state")] + public async Task RequiresAuthorization_returns_401_for_unauthenticated_request(string method, string path) + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: false); + var client = app.GetTestClient(); + + var request = new HttpRequestMessage(new HttpMethod(method), path); + var response = await client.SendAsync(request, ct); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + // ─── GET /api/mcp/oauth/callback (.AllowAnonymous) ──────────────────────── + + [Fact] + public async Task Callback_returns_failure_html_when_code_and_state_are_missing() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: false); // no auth needed + var client = app.GetTestClient(); + + // No Authorization header — proves AllowAnonymous is wired + var response = await client.GetAsync("/api/mcp/oauth/callback", ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + var html = await response.Content.ReadAsStringAsync(ct); + Assert.Contains("Authorization failed", html); + Assert.Contains("Missing code or state parameter", html); + } + + [Fact] + public async Task Callback_returns_failure_html_for_unknown_state() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: false); + var client = app.GetTestClient(); + + // Unknown state triggers CompleteAuthorizationAsync to throw InvalidOperationException + var response = await client.GetAsync("/api/mcp/oauth/callback?code=abc&state=unknown-state", ct); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + var html = await response.Content.ReadAsStringAsync(ct); + Assert.Contains("Authorization failed", html); + } + + [Fact] + public async Task Callback_is_reachable_anonymously() + { + // This test ensures AllowAnonymous is actually wired — the anonymous + // request reaches the handler rather than being rejected with 401. + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: false); + var client = app.GetTestClient(); + // Deliberately no Authorization header + + var response = await client.GetAsync("/api/mcp/oauth/callback", ct); + + // Any non-401 proves the endpoint was reached + Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + // ─── POST /api/mcp/oauth/start/{name} ───────────────────────────────────── + + [Fact] + public async Task OauthStart_returns_404_for_unknown_server_name() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.PostAsync("/api/mcp/oauth/start/nonexistent", null, ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.Contains("nonexistent", body.GetProperty("error").GetString()); + } + + [Fact] + public async Task OauthStart_returns_400_when_server_has_no_url() + { + var ct = TestContext.Current.CancellationToken; + var servers = new Dictionary + { + ["stdio-server"] = new McpServerEntry { Transport = "stdio", Command = "mcp-server" } + }; + + await using var app = await CreateAppAsync(spoofLoopback: true, mcpServers: servers); + var client = app.GetTestClient(); + + var response = await client.PostAsync("/api/mcp/oauth/start/stdio-server", null, ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.Contains("no URL", body.GetProperty("error").GetString()); + } + + // ─── Trivial GETs — authenticated returns 200 with expected shape ────────── + + [Fact] + public async Task GetStatuses_returns_200_with_empty_dictionary_when_no_servers_configured() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.GetAsync("/api/mcp/statuses", ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + // No servers registered → empty object + Assert.Equal(JsonValueKind.Object, body.ValueKind); + Assert.Empty(body.EnumerateObject()); + } + + [Fact] + public async Task GetTools_returns_200_with_empty_array_for_unknown_server() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.GetAsync("/api/mcp/tools/no-such-server", ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.Equal(JsonValueKind.Array, body.ValueKind); + Assert.Equal(0, body.GetArrayLength()); + } + + [Fact] + public async Task GetOauthStatus_returns_200_with_status_field_for_known_server() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.GetAsync("/api/mcp/oauth/status/any-server", ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.True(body.TryGetProperty("status", out _)); + } + + [Fact] + public async Task GetOauthStatusByState_returns_200_with_status_field_for_any_state() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.GetAsync("/api/mcp/oauth/status-by-state/some-arbitrary-state", ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.True(body.TryGetProperty("status", out _)); + } + + // ─── Happy-path: oauth/start then callback ───────────────────────────────── + + [Fact] + public async Task OauthStart_returns_200_with_authorizationUrl_and_state() + { + var ct = TestContext.Current.CancellationToken; + var servers = new Dictionary + { + ["test-mcp"] = new McpServerEntry + { + Transport = "http", + Url = "https://mcp.example.com", + Enabled = true, + OAuthClientId = "test-client" + } + }; + + await using var app = await CreateAppAsync(spoofLoopback: true, mcpServers: servers); + var client = app.GetTestClient(); + + var response = await client.PostAsync("/api/mcp/oauth/start/test-mcp", null, ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.True(body.TryGetProperty("authorizationUrl", out var urlProp)); + Assert.True(body.TryGetProperty("state", out var stateProp)); + Assert.False(string.IsNullOrWhiteSpace(urlProp.GetString())); + Assert.False(string.IsNullOrWhiteSpace(stateProp.GetString())); + } + + [Fact] + public async Task Callback_happy_path_returns_success_html_and_triggers_reconnect() + { + var ct = TestContext.Current.CancellationToken; + var servers = new Dictionary + { + ["test-mcp"] = new McpServerEntry + { + Transport = "http", + Url = "https://mcp.example.com", + Enabled = true, + OAuthClientId = "test-client" + } + }; + + var reconnectable = new TrackingReconnectable(); + + await using var app = await CreateAppAsync( + spoofLoopback: true, + mcpServers: servers, + reconnectable: reconnectable); + var client = app.GetTestClient(); + + // Start the OAuth flow to get a valid state token + var startResponse = await client.PostAsync("/api/mcp/oauth/start/test-mcp", null, ct); + Assert.Equal(HttpStatusCode.OK, startResponse.StatusCode); + + var startBody = await startResponse.Content.ReadFromJsonAsync(ct); + var state = startBody.GetProperty("state").GetString()!; + + // Complete via callback — no Authorization header (AllowAnonymous) + var callbackResponse = await client.GetAsync( + $"/api/mcp/oauth/callback?code=test-code&state={state}", ct); + + Assert.Equal(HttpStatusCode.OK, callbackResponse.StatusCode); + var html = await callbackResponse.Content.ReadAsStringAsync(ct); + Assert.Contains("Authorization complete", html); + + // Wait until the fire-and-forget reconnect task signals completion. + // TrackingReconnectable.ReconnectCalled is set before TCS is signalled so + // the assertion below is race-free. + await reconnectable.ReconnectCalledTask.WaitAsync(ct); + Assert.True(reconnectable.WasReconnectCalled, "TryReconnectAsync should have been called post-OAuth"); + } + + // ─── Default fake HTTP handlers ─────────────────────────────────────────── + + private static HttpResponseMessage DefaultDiscoveryHandler(HttpRequestMessage request) + { + var uri = request.RequestUri!.ToString(); + return uri switch + { + "https://mcp.example.com/" or "https://mcp.example.com" => + new HttpResponseMessage(HttpStatusCode.Unauthorized), + "https://mcp.example.com/.well-known/oauth-protected-resource" => + JsonResponse(new + { + authorization_servers = new[] { "https://auth.example.com" }, + resource = "https://mcp.example.com/resource" + }), + "https://auth.example.com/.well-known/oauth-authorization-server" => + JsonResponse(new + { + authorization_endpoint = "https://auth.example.com/authorize", + token_endpoint = "https://auth.example.com/token" + }), + _ => new HttpResponseMessage(HttpStatusCode.NotFound) + }; + } + + private static HttpResponseMessage DefaultTokenHandler(HttpRequestMessage request) => + JsonResponse(new + { + access_token = "test-access-token", + refresh_token = "test-refresh-token", + expires_in = 3600 + }); + + private static HttpResponseMessage JsonResponse(object body, HttpStatusCode status = HttpStatusCode.OK) => + new(status) + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + // ─── Fakes ──────────────────────────────────────────────────────────────── + + private sealed class NoOpReconnectable : IMcpReconnectable + { + public IReadOnlyDictionary GetServerStatuses() => + new Dictionary(); + + public Task TryReconnectAsync(McpServerName serverName, CancellationToken ct = default) => + Task.FromResult(false); + } + + private sealed class TrackingReconnectable : IMcpReconnectable + { + private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool WasReconnectCalled { get; private set; } + + /// + /// Completes when has been called. + /// Use this instead of Task.Delay to synchronize with the fire-and-forget reconnect. + /// + public Task ReconnectCalledTask => _tcs.Task; + + public IReadOnlyDictionary GetServerStatuses() => + new Dictionary(); + + public Task TryReconnectAsync(McpServerName serverName, CancellationToken ct = default) + { + WasReconnectCalled = true; + _tcs.TrySetResult(); + return Task.FromResult(true); + } + } +} diff --git a/src/Netclaw.Daemon.Tests/Reminder/ReminderEndpointAuthorizationTests.cs b/src/Netclaw.Daemon.Tests/Reminder/ReminderEndpointAuthorizationTests.cs index db2c5ae2b..5d870413e 100644 --- a/src/Netclaw.Daemon.Tests/Reminder/ReminderEndpointAuthorizationTests.cs +++ b/src/Netclaw.Daemon.Tests/Reminder/ReminderEndpointAuthorizationTests.cs @@ -1,33 +1,43 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- using System.Net; using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Encodings.Web; using System.Text.Json; +using Akka.Actor; +using Akka.Hosting; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using Netclaw.Actors.Channels; +using Netclaw.Actors.Hosting; using Netclaw.Actors.Reminders; using Netclaw.Configuration; +using Netclaw.Daemon.Reminders; using Netclaw.Daemon.Security; using Netclaw.Tests.Utilities; using Xunit; namespace Netclaw.Daemon.Tests.Reminder; -public sealed class ReminderEndpointAuthorizationTests : IDisposable +public sealed class ReminderEndpointAuthorizationTests : IAsyncDisposable { private readonly DisposableTempDir _dir = new(); private readonly FakeTimeProvider _timeProvider; private readonly ReminderDefinitionStore _definitionStore; private readonly ReminderHistoryStore _historyStore; + private readonly ActorSystem _actorSystem; + private readonly TestReminderActor _testActor; + private readonly IActorRef _actorRef; public ReminderEndpointAuthorizationTests() { @@ -37,141 +47,227 @@ public ReminderEndpointAuthorizationTests() paths.EnsureDirectoriesExist(); _definitionStore = new ReminderDefinitionStore(paths); _historyStore = new ReminderHistoryStore(paths); + + _actorSystem = ActorSystem.Create($"reminder-endpoint-tests-{Guid.NewGuid():N}"); + _testActor = new TestReminderActor(); + _actorRef = _actorSystem.ActorOf(Props.Create(() => new RecordingReminderActor(_testActor))); } - public void Dispose() + public async ValueTask DisposeAsync() { + await _actorSystem.Terminate(); _dir.Dispose(); } - private async Task CreateAppAsync(bool spoofLoopback) + // ── Test case 1: regression — authenticated non-Operator is rejected with 403 ── + + [Fact] + public async Task NonOperator_POST_reminders_returns_403_and_actor_receives_no_command() { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); + await using var app = await CreateAppAsync(spoofLoopback: false, addNonOperatorScheme: true); + var client = app.GetTestClient(); + client.DefaultRequestHeaders.Add(NonOperatorAuthHandler.HeaderName, NonOperatorAuthHandler.HeaderValue); - builder.Services.AddSingleton(_definitionStore); - builder.Services.AddSingleton(_historyStore); - builder.Services.AddSingleton(TimeProvider.System); - builder.Services.AddSingleton(new EffectivePolicyDefaults( - DeploymentPosture.Team, - TrustAudience.Team, - ShellExecutionMode.Off, - UsedStrictFallback: false)); - builder.Services.AddSingleton(); - builder.Services.AddNetclawAuthSchemes(new DaemonConfig()); - builder.Services.AddAuthorization(); + var response = await client.PostAsJsonAsync("/api/reminders", new + { + id = "regression-non-operator", + name = "regression-non-operator", + prompt = "check status", + scheduleType = "once", + schedule = "30m" + }, TestContext.Current.CancellationToken); - var app = builder.Build(); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + // The 403 guard fires before any actor interaction + Assert.Empty(_testActor.ReceivedMessages); + } - if (spoofLoopback) + // ── Test case 2: golden path — Operator POST creates a reminder ── + + [Fact] + public async Task Operator_POST_reminders_succeeds_and_actor_receives_SaveReminderCommand_with_source_audience() + { + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/reminders", new { - app.Use(async (ctx, next) => - { - ctx.Connection.RemoteIpAddress = IPAddress.Loopback; - await next(ctx); - }); - } + id = "golden-path-create", + name = "golden-path-create", + prompt = "check status", + scheduleType = "once", + schedule = "30m", + deliveryKind = "none" + }, TestContext.Current.CancellationToken); - app.UseAuthentication(); - app.UseAuthorization(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var saveCmd = _testActor.ReceivedMessages.OfType().FirstOrDefault(); + Assert.NotNull(saveCmd); + Assert.NotNull(saveCmd.Authorization?.SourceAudience); + } - var reminders = app.MapGroup("/api/reminders").RequireAuthorization(); + // ── Test case 3: unauthenticated POST → 401 ── - reminders.MapPost("", async ( - ReminderCreateRequest request, - ReminderDefinitionStore definitionStore, - ClaimsPrincipalMapper mapper, - HttpContext httpContext, - CancellationToken ct) => + [Fact] + public async Task Unauthenticated_POST_reminders_returns_401() + { + await using var app = await CreateAppAsync(spoofLoopback: false); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/reminders", new { - var identity = mapper.Map(httpContext.User); - if (identity.Principal is not PrincipalClassification.Operator) - return Results.BadRequest(new { error = "Reminder audience authorization context is required." }); + id = "unauthenticated-create", + name = "unauthenticated-create", + prompt = "check status", + scheduleType = "once", + schedule = "30m" + }, TestContext.Current.CancellationToken); - var parsedAudience = default(TrustAudience); - if (!string.IsNullOrWhiteSpace(request.Audience) - && !SecurityPolicyDefaults.TryParseAudience(request.Audience, out parsedAudience)) - { - return Results.BadRequest(new { error = $"Error: Invalid audience '{request.Audience}'. Use 'personal', 'team', or 'public'." }); - } + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Empty(_testActor.ReceivedMessages); + } - var effectiveAudience = string.IsNullOrWhiteSpace(request.Audience) - ? TrustAudience.Personal - : parsedAudience; + // ── Test case 4: POST with invalid audience value → 400, no command dispatched ── - var now = _timeProvider.GetUtcNow(); - var definition = new ReminderDefinition - { - Id = request.Id, - Title = request.Name, - Instructions = request.Prompt, - Delivery = new ReminderDelivery { Kind = DeliveryKind.None }, - DeliveryInstructions = request.NotifyInstructions ?? "Reply in thread.", - Schedule = new ReminderSchedule - { - Type = ReminderScheduleType.OneShot, - FireAt = now.AddMinutes(30) - }, - Audience = effectiveAudience, - Enabled = true, - CreatedBy = "test", - CreatedAt = now, - UpdatedAt = now - }; - - definitionStore.Save(definition); - return Results.Ok(new { message = $"Reminder '{request.Name}' scheduled." }); - }); - - reminders.MapPost("/import", async ( - ReminderImportRequest request, - ReminderDefinitionStore definitionStore, - ClaimsPrincipalMapper mapper, - HttpContext httpContext, - CancellationToken ct) => + [Fact] + public async Task POST_reminders_with_invalid_audience_returns_400_and_no_command_dispatched() + { + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/reminders", new { - if (request.Definition is null) - return Results.BadRequest(new { error = "Reminder definition is required." }); + id = "invalid-audience", + name = "invalid-audience", + prompt = "check status", + scheduleType = "once", + schedule = "30m", + deliveryKind = "none", + audience = "superuser" + }, TestContext.Current.CancellationToken); - var identity = mapper.Map(httpContext.User); - if (identity.Principal is not PrincipalClassification.Operator) - { - return Results.BadRequest(new - { - error = "Reminder audience authorization context is required.", - code = ReminderSaveError.Validation.ToString(), - id = request.Definition.Id - }); - } + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Contains("Invalid audience", body.GetProperty("error").GetString()); + // Audience is validated inside SetReminderTool — the actor receives the command + // only after the tool validates params. If the tool returns an error, there is no command. + // (The tool emits "Error: Invalid audience..." before Ask, so no SaveReminderCommand is sent.) + Assert.DoesNotContain(_testActor.ReceivedMessages, m => m is SaveReminderCommand { } cmd + && cmd.Definition.Id == "invalid-audience"); + } + + // ── Test case 5a: import as non-Operator → 400 with validation error ── + + [Fact] + public async Task NonOperator_POST_reminders_import_actor_returns_validation_error() + { + await using var app = await CreateAppAsync(spoofLoopback: false, addNonOperatorScheme: true); + var client = app.GetTestClient(); + client.DefaultRequestHeaders.Add(NonOperatorAuthHandler.HeaderName, NonOperatorAuthHandler.HeaderValue); - if (request.Definition.Audience is not { } audience) + var now = _timeProvider.GetUtcNow(); + var response = await client.PostAsJsonAsync("/api/reminders/import", new + { + definition = new { - return Results.BadRequest(new + id = "import-non-operator", + title = "import-non-operator", + instructions = "check status", + delivery = new { kind = 2 }, // None + deliveryInstructions = "reply", + schedule = new { - error = "Reminder definition must include a valid audience for import.", - code = ReminderSaveError.Validation.ToString(), - id = request.Definition.Id - }); + type = 0, + fireAtMs = now.AddMinutes(30).ToUnixTimeMilliseconds() + }, + audience = 0, // Personal + boundary = "personal", + enabled = true, + createdBy = "test", + createdAtMs = now.ToUnixTimeMilliseconds(), + updatedAtMs = now.ToUnixTimeMilliseconds() } + }, TestContext.Current.CancellationToken); + + // Non-Operator: authorization is null → actor receives SaveReminderCommand with null authorization + // The actor's validator logic in ReminderManagerActor will return a validation error + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - if (audience > TrustAudience.Personal) + // ── Test case 5b: import as Operator → success ── + + [Fact] + public async Task Operator_POST_reminders_import_succeeds() + { + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var now = _timeProvider.GetUtcNow(); + var response = await client.PostAsJsonAsync("/api/reminders/import", new + { + definition = new { - return Results.BadRequest(new + id = "import-operator", + title = "import-operator", + instructions = "check status", + delivery = new { kind = 2 }, // None + deliveryInstructions = "reply", + schedule = new { - error = $"Requested audience '{audience.ToWireValue()}' exceeds creator authority 'Operator/LocalProcess' (personal).", - code = ReminderSaveError.Validation.ToString(), - id = request.Definition.Id - }); + type = 0, + fireAtMs = now.AddMinutes(30).ToUnixTimeMilliseconds() + }, + audience = 0, // Personal + boundary = "personal", + enabled = true, + createdBy = "test", + createdAtMs = now.ToUnixTimeMilliseconds(), + updatedAtMs = now.ToUnixTimeMilliseconds() } + }, TestContext.Current.CancellationToken); - definitionStore.Save(request.Definition); - return Results.Ok(new { id = request.Definition.Id, message = $"Imported reminder '{request.Definition.Id}'." }); - }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var saveCmd = _testActor.ReceivedMessages.OfType().FirstOrDefault(); + Assert.NotNull(saveCmd); + Assert.Equal("import-operator", saveCmd.Definition.Id); + } - await app.StartAsync(); - return app; + // ── Test case 6: DELETE with ?permanent=true → DeleteReminderCommand; without → CancelReminderCommand ── + + [Fact] + public async Task DELETE_with_permanent_true_sends_DeleteReminderCommand() + { + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.DeleteAsync("/api/reminders/some-id?permanent=true", + TestContext.Current.CancellationToken); + + // NotFound because the test actor returns Found=false, but the command type should be DeleteReminderCommand + var deleteCmd = _testActor.ReceivedMessages.OfType().FirstOrDefault(); + Assert.NotNull(deleteCmd); + Assert.Equal("some-id", deleteCmd.Id.Value); + Assert.DoesNotContain(_testActor.ReceivedMessages, m => m is CancelReminderCommand); + } + + [Fact] + public async Task DELETE_without_permanent_sends_CancelReminderCommand() + { + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.DeleteAsync("/api/reminders/some-id", + TestContext.Current.CancellationToken); + + var cancelCmd = _testActor.ReceivedMessages.OfType().FirstOrDefault(); + Assert.NotNull(cancelCmd); + Assert.Equal("some-id", cancelCmd.Id.Value); + Assert.DoesNotContain(_testActor.ReceivedMessages, m => m is DeleteReminderCommand); } + // ── Re-expressed original tests against the real endpoints ── + [Fact] public async Task Create_persists_personal_audience_when_omitted_for_loopback_operator() { @@ -184,65 +280,65 @@ public async Task Create_persists_personal_audience_when_omitted_for_loopback_op name = "rest-create-inherit", prompt = "check status", scheduleType = "once", - schedule = "30m" + schedule = "30m", + deliveryKind = "none" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(TrustAudience.Personal, _definitionStore.Get(new ReminderId("rest-create-inherit"))!.Audience); + + var saveCmd = _testActor.ReceivedMessages.OfType().FirstOrDefault(); + Assert.NotNull(saveCmd); + // Operator loopback → authorization carries Personal audience + Assert.Equal(TrustAudience.Personal, saveCmd.Authorization?.SourceAudience); + Assert.Equal(SecurityPolicyDefaults.TrustedInstanceBoundary, saveCmd.Definition.Boundary); } [Fact] - public async Task Create_rejects_invalid_audience_without_persisting() + public async Task Create_downscoped_public_audience_rewrites_boundary_to_public() { await using var app = await CreateAppAsync(spoofLoopback: true); var client = app.GetTestClient(); var response = await client.PostAsJsonAsync("/api/reminders", new { - id = "rest-create-invalid", - name = "rest-create-invalid", + id = "rest-create-public-boundary", + name = "rest-create-public-boundary", prompt = "check status", scheduleType = "once", schedule = "30m", - audience = "superuser" + deliveryKind = "none", + audience = "public" }, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Contains("Invalid audience", body.GetProperty("error").GetString()); - Assert.Null(_definitionStore.Get(new ReminderId("rest-create-invalid"))); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var saveCmd = _testActor.ReceivedMessages.OfType() + .FirstOrDefault(x => x.Definition.Id == "rest-create-public-boundary"); + Assert.NotNull(saveCmd); + Assert.Equal(TrustAudience.Public, saveCmd.Definition.Audience); + Assert.Equal(SecurityPolicyDefaults.PublicBoundary, saveCmd.Definition.Boundary); } [Fact] - public async Task Import_rejects_missing_audience_without_persisting() + public async Task Create_rejects_invalid_audience_without_dispatching_command() { await using var app = await CreateAppAsync(spoofLoopback: true); var client = app.GetTestClient(); - var now = _timeProvider.GetUtcNow(); - var response = await client.PostAsJsonAsync("/api/reminders/import", new ReminderImportRequest( - new ReminderDefinition - { - Id = "rest-import-missing-audience", - Title = "rest-import-missing-audience", - Instructions = "check status", - Delivery = new ReminderDelivery { Kind = DeliveryKind.None }, - DeliveryInstructions = "reply", - Schedule = new ReminderSchedule - { - Type = ReminderScheduleType.OneShot, - FireAt = now.AddMinutes(30) - }, - Enabled = true, - CreatedBy = "test", - CreatedAt = now, - UpdatedAt = now - }), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/api/reminders", new + { + id = "rest-create-invalid", + name = "rest-create-invalid", + prompt = "check status", + scheduleType = "once", + schedule = "30m", + deliveryKind = "none", + audience = "superuser" + }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Contains("must include a valid audience", body.GetProperty("error").GetString()); - Assert.Null(_definitionStore.Get(new ReminderId("rest-import-missing-audience"))); + Assert.Contains("Invalid audience", body.GetProperty("error").GetString()); } [Fact] @@ -261,17 +357,243 @@ public async Task Create_requires_authenticated_authority_context() }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Null(_definitionStore.Get(new ReminderId("rest-create-unauthorized"))); + Assert.Empty(_testActor.ReceivedMessages); } - private sealed record ReminderCreateRequest( - string Id, - string Name, - string Prompt, - string ScheduleType, - string Schedule, - string? Audience = null, - string? NotifyInstructions = null); + [Fact] + public async Task Import_requires_authenticated_authority_context() + { + await using var app = await CreateAppAsync(spoofLoopback: false); + var client = app.GetTestClient(); + var now = _timeProvider.GetUtcNow(); - private sealed record ReminderImportRequest(ReminderDefinition Definition); + var response = await client.PostAsJsonAsync("/api/reminders/import", new + { + definition = new + { + id = "rest-import-unauthorized", + title = "rest-import-unauthorized", + instructions = "check status", + delivery = new { kind = 2 }, // None + deliveryInstructions = "reply", + schedule = new + { + type = 0, + fireAtMs = now.AddMinutes(30).ToUnixTimeMilliseconds() + }, + audience = 0, // Personal + boundary = "personal", + enabled = true, + createdBy = "test", + createdAtMs = now.ToUnixTimeMilliseconds(), + updatedAtMs = now.ToUnixTimeMilliseconds() + } + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Empty(_testActor.ReceivedMessages); + } + + // ── App factory ── + + private async Task CreateAppAsync( + bool spoofLoopback, + bool addNonOperatorScheme = false) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddSingleton(_definitionStore); + builder.Services.AddSingleton(_historyStore); + builder.Services.AddSingleton(_timeProvider); + builder.Services.AddSingleton(new SchedulingConfig { Enabled = true }); + builder.Services.AddSingleton(); + + // Wire the test actor as IRequiredActor + builder.Services.AddSingleton>( + new FakeRequiredActor(_actorRef)); + + if (addNonOperatorScheme) + { + // Non-Operator takes priority when the special header is present; + // fall back to Loopback scheme otherwise. + builder.Services + .AddAuthentication("TestAuthSelector") + .AddPolicyScheme("TestAuthSelector", "non-operator or loopback", options => + { + options.ForwardDefaultSelector = ctx => + ctx.Request.Headers.ContainsKey(NonOperatorAuthHandler.HeaderName) + ? NonOperatorAuthHandler.SchemeName + : LoopbackAuthenticationHandler.SchemeName; + }) + .AddScheme( + NonOperatorAuthHandler.SchemeName, _ => { }) + .AddScheme( + LoopbackAuthenticationHandler.SchemeName, _ => { }); + builder.Services.AddSingleton(new DaemonConfig()); + } + else + { + builder.Services.AddNetclawAuthSchemes(new DaemonConfig()); + } + + builder.Services.AddAuthorization(); + builder.Services.AddLogging(); + + var app = builder.Build(); + + if (spoofLoopback) + { + app.Use(async (ctx, next) => + { + ctx.Connection.RemoteIpAddress = IPAddress.Loopback; + await next(ctx); + }); + } + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapReminderEndpoints(); + + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + // ── Fakes and helpers ── + + /// + /// Mutable sink that records all messages the test actor receives. + /// Accessed from the test thread after awaiting the HTTP response, so no + /// concurrent access — the actor Tell completes before the endpoint returns. + /// + private sealed class TestReminderActor + { + private readonly List _received = []; + public IReadOnlyList ReceivedMessages => _received; + + public void Record(object message) => _received.Add(message); + } + + /// + /// ReceiveActor that records every command it handles, replies with + /// canned successes, and delegates recording to . + /// + private sealed class RecordingReminderActor : ReceiveActor + { + public RecordingReminderActor(TestReminderActor sink) + { + Receive(cmd => + { + sink.Record(cmd); + // Null authorization means non-Operator called import — reply with validation error + // so the endpoint returns 400 (mirrors what ReminderManagerActor would do). + if (cmd.Authorization?.SourceAudience is null) + { + Sender.Tell(new ReminderSavedResponse( + new ReminderId(cmd.Definition.Id), + cmd.Definition.Title, + Success: false, + NextFire: null, + Error: ReminderSaveError.Validation, + ErrorMessage: "Reminder audience authorization context is required.")); + } + else + { + Sender.Tell(new ReminderSavedResponse( + new ReminderId(cmd.Definition.Id), + cmd.Definition.Title, + Success: true, + NextFire: DateTimeOffset.UtcNow.AddMinutes(30), + Error: ReminderSaveError.None)); + } + }); + + Receive(cmd => + { + sink.Record(cmd); + Sender.Tell(new ReminderListResponse([])); + }); + + Receive(cmd => + { + sink.Record(cmd); + Sender.Tell(new ReminderDeletedResponse(cmd.Id, Found: false)); + }); + + Receive(cmd => + { + sink.Record(cmd); + Sender.Tell(new ReminderCancelledResponse(cmd.Id, Found: false)); + }); + + Receive(cmd => + { + sink.Record(cmd); + Sender.Tell(new GetReminderResponse(null)); + }); + + Receive(cmd => + { + sink.Record(cmd); + Sender.Tell(new ReminderStateResponse(cmd.Id, Found: false, Enabled: false)); + }); + + Receive(cmd => + { + sink.Record(cmd); + Sender.Tell(new ReminderStateResponse(cmd.Id, Found: false, Enabled: false)); + }); + } + } + + /// + /// Wraps a real as so it + /// can be injected into the test host's DI container. + /// + private sealed class FakeRequiredActor(IActorRef actorRef) : IRequiredActor + { + public IActorRef ActorRef => actorRef; + public Task GetAsync(CancellationToken cancellationToken = default) + => Task.FromResult(actorRef); + } + + /// + /// Test authentication handler that authenticates every request carrying the + /// X-Test-NonOperator header as an authenticated but non-Operator principal. + /// + /// The handler deliberately omits the netclaw:principal claim (used by + /// to detect Operator status). Without that + /// claim, falls back to + /// , which is not Operator, + /// so ResolveReminderAuthorizationContext returns null and the endpoint + /// returns 403. + /// + private sealed class NonOperatorAuthHandler : AuthenticationHandler + { + public const string SchemeName = "NonOperatorTest"; + public const string HeaderName = "X-Test-NonOperator"; + public const string HeaderValue = "ok"; + + public NonOperatorAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(HeaderName, out var value) || value != HeaderValue) + return Task.FromResult(AuthenticateResult.NoResult()); + + // Authenticated identity WITHOUT the netclaw:principal=Operator claim. + // ClaimsPrincipalMapper.Map() will return UntrustedExternal for this principal. + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.Name, "device-user")], + SchemeName); + var principal = new ClaimsPrincipal(identity); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName))); + } + } } diff --git a/src/Netclaw.Daemon.Tests/Reminder/ReminderTargetResolutionPathTests.cs b/src/Netclaw.Daemon.Tests/Reminder/ReminderTargetResolutionPathTests.cs index 1e3a12ef6..a41419c45 100644 --- a/src/Netclaw.Daemon.Tests/Reminder/ReminderTargetResolutionPathTests.cs +++ b/src/Netclaw.Daemon.Tests/Reminder/ReminderTargetResolutionPathTests.cs @@ -117,6 +117,7 @@ private SetReminderTool CreateTool(IActorRef reminderManager, IReminderTargetRes private static ToolExecutionContext BuildManualToolContext() => new(sessionId: null, sessionDirectory: null) { + Audience = TrustAudience.Personal, ChannelType = "manual" }; diff --git a/src/Netclaw.Daemon.Tests/Security/PairingCodeEndpointTests.cs b/src/Netclaw.Daemon.Tests/Security/PairingCodeEndpointTests.cs deleted file mode 100644 index 32f056c4a..000000000 --- a/src/Netclaw.Daemon.Tests/Security/PairingCodeEndpointTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Buffers.Text; -using System.Net; -using System.Net.Http.Json; -using System.Security.Cryptography; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Time.Testing; -using Netclaw.Configuration; -using Netclaw.Daemon.Security; -using Netclaw.Tests.Utilities; -using Xunit; - -namespace Netclaw.Daemon.Tests.Security; - -/// -/// Integration tests for the GET /api/pair/devices and -/// DELETE /api/pair/devices/{name} endpoints. -/// -/// Uses a minimal with the same auth pipeline as production, -/// verifying that the endpoints are reachable from authenticated (loopback) connections -/// and blocked from unauthenticated connections. -/// -public sealed class PairingCodeEndpointTests : IDisposable -{ - private readonly DisposableTempDir _dir = new(); - private readonly FakeTimeProvider _time; - private readonly DeviceRegistry _registry; - - public PairingCodeEndpointTests() - { - _time = new FakeTimeProvider(new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero)); - _registry = new DeviceRegistry(new NetclawPaths(_dir.Path), _time, NullLogger.Instance); - } - - public void Dispose() => _dir.Dispose(); - - private async Task CreateAppAsync(bool spoofLoopback = false) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - - builder.Services.AddSingleton(_registry); - builder.Services.AddNetclawAuthSchemes(new DaemonConfig()); - builder.Services.AddAuthorization(); - - var app = builder.Build(); - - if (spoofLoopback) - { - app.Use(async (ctx, next) => - { - ctx.Connection.RemoteIpAddress = IPAddress.Loopback; - await next(ctx); - }); - } - - app.UseAuthentication(); - app.UseAuthorization(); - - app.MapGet("/api/pair/devices", async (DeviceRegistry deviceRegistry, CancellationToken ct) => - { - var devices = await deviceRegistry.ListAsync(ct); - var sanitized = devices.Select(d => new PairedDeviceInfoDto(d.Name, d.CreatedAt, d.LastUsedAt)); - return Results.Ok(sanitized); - }).RequireAuthorization(); - - app.MapDelete("/api/pair/devices/{name}", async (string name, DeviceRegistry deviceRegistry, CancellationToken ct) => - { - var removed = await deviceRegistry.RemoveAsync(name, ct); - return removed - ? Results.NoContent() - : Results.NotFound(new { error = $"Device '{name}' not found." }); - }).RequireAuthorization(); - - await app.StartAsync(); - return app; - } - - private PairedDevice MakeDevice(string name) - { - return DeviceTestHelpers.MakeDevice(name, _time.GetUtcNow()).Device; - } - - [Fact] - public async Task List_devices_returns_401_for_unauthenticated_request() - { - await using var app = await CreateAppAsync(spoofLoopback: false); - var client = app.GetTestClient(); - - var response = await client.GetAsync("/api/pair/devices", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task List_devices_returns_empty_list_from_loopback_when_no_devices_registered() - { - await using var app = await CreateAppAsync(spoofLoopback: true); - var client = app.GetTestClient(); - - var response = await client.GetAsync("/api/pair/devices", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var devices = await response.Content.ReadFromJsonAsync>( - TestContext.Current.CancellationToken); - Assert.NotNull(devices); - Assert.Empty(devices); - } - - [Fact] - public async Task List_devices_returns_sanitized_device_list_from_loopback() - { - var ct = TestContext.Current.CancellationToken; - await _registry.AddAsync(MakeDevice("laptop"), ct); - await _registry.AddAsync(MakeDevice("phone"), ct); - - await using var app = await CreateAppAsync(spoofLoopback: true); - var client = app.GetTestClient(); - - var response = await client.GetAsync("/api/pair/devices", ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var devices = await response.Content.ReadFromJsonAsync>(ct); - Assert.NotNull(devices); - Assert.Equal(2, devices.Count); - Assert.Contains(devices, d => d.Name == "laptop"); - Assert.Contains(devices, d => d.Name == "phone"); - } - - [Fact] - public async Task Revoke_device_returns_401_for_unauthenticated_request() - { - var ct = TestContext.Current.CancellationToken; - await _registry.AddAsync(MakeDevice("laptop"), ct); - - await using var app = await CreateAppAsync(spoofLoopback: false); - var client = app.GetTestClient(); - - var response = await client.DeleteAsync("/api/pair/devices/laptop", ct); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Revoke_device_removes_it_and_returns_204_from_loopback() - { - var ct = TestContext.Current.CancellationToken; - await _registry.AddAsync(MakeDevice("laptop"), ct); - - await using var app = await CreateAppAsync(spoofLoopback: true); - var client = app.GetTestClient(); - - var response = await client.DeleteAsync("/api/pair/devices/laptop", ct); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - var remaining = await _registry.ListAsync(ct); - Assert.Empty(remaining); - } - - [Fact] - public async Task Revoke_device_returns_404_when_device_not_found() - { - await using var app = await CreateAppAsync(spoofLoopback: true); - var client = app.GetTestClient(); - - var response = await client.DeleteAsync( - "/api/pair/devices/nonexistent", TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } -} diff --git a/src/Netclaw.Daemon.Tests/Security/PairingEndpointRouteBuilderExtensionsTests.cs b/src/Netclaw.Daemon.Tests/Security/PairingEndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 000000000..fdc4ad92f --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Security/PairingEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,541 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Netclaw.Configuration; +using Netclaw.Daemon.Security; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Daemon.Tests.Security; + +/// +/// Real integration tests for the pairing endpoints registered by +/// . +/// +/// The test host calls the actual extension method — no handler reimplementation. +/// +public sealed class PairingEndpointRouteBuilderExtensionsTests : IAsyncDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly FakeTimeProvider _time; + private readonly DeviceRegistry _registry; + private readonly PairingCodeService _pairingCodeService; + private readonly PairingExchangeGuard _exchangeGuard; + + public PairingEndpointRouteBuilderExtensionsTests() + { + _time = new FakeTimeProvider(new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero)); + _registry = new DeviceRegistry(new NetclawPaths(_dir.Path), _time, NullLogger.Instance); + _pairingCodeService = new PairingCodeService(_time); + _exchangeGuard = new PairingExchangeGuard(_time); + } + + public ValueTask DisposeAsync() + { + _dir.Dispose(); + return ValueTask.CompletedTask; + } + + // ─── App factory ─────────────────────────────────────────────────────────── + + /// + /// Creates a test host wired with the real . + /// + /// The "pairing-exchange" rate-limiter is registered with a very high permit limit so that + /// the ASP.NET framework limiter never fires during tests — we test the guard lockout + /// () specifically, not the framework limiter. + /// + private async Task CreateAppAsync( + bool spoofLoopback = false, + IPAddress? remoteIp = null, + string[]? trustedProxies = null, + bool useRealRateLimiter = false) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddSingleton(_registry); + builder.Services.AddSingleton(_pairingCodeService); + builder.Services.AddSingleton(_exchangeGuard); + builder.Services.AddSingleton(_time); + builder.Services.AddNetclawAuthSchemes(new DaemonConfig()); + builder.Services.AddAuthorization(); + + // Most tests use a very high permit limit so the ASP.NET rate limiter never fires — + // the guard lockout under test is PairingExchangeGuard (Layer 1). Tests that + // specifically exercise the framework limiter pass useRealRateLimiter: true to get + // the production permit limit (5/min/IP). + builder.Services.AddRateLimiter(options => + { + options.AddPolicy("pairing-exchange", context => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = useRealRateLimiter ? 5 : 10_000, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0, + })); + options.RejectionStatusCode = 429; + }); + + var app = builder.Build(); + + if (spoofLoopback || remoteIp is not null) + { + var ip = remoteIp ?? IPAddress.Loopback; + app.Use(async (ctx, next) => + { + ctx.Connection.RemoteIpAddress = ip; + await next(ctx); + }); + } + + // Mirror the daemon's reverse-proxy wiring: when trusted proxies are configured, + // UseForwardedHeaders rewrites RemoteIpAddress to the X-Forwarded-For client IP + // (only when the direct peer is a known proxy). Must run after the direct-peer IP + // is set above and before the rate limiter / endpoint read RemoteIpAddress. + if (trustedProxies is not null) + { + var forwarded = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor, + ForwardLimit = 1, + }; + foreach (var proxy in trustedProxies) + forwarded.KnownProxies.Add(IPAddress.Parse(proxy)); + app.UseForwardedHeaders(forwarded); + } + + app.UseAuthentication(); + app.UseAuthorization(); + app.UseRateLimiter(); + app.MapPairingEndpoints(); + + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + // ─── POST /api/pair/exchange ─────────────────────────────────────────────── + + /// Test case 1: no pending code → 404 (endpoint hidden). + [Fact] + public async Task Exchange_returns_404_when_no_code_pending() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/pair/exchange", + new { code = "ABCD-EFGH", deviceName = "laptop" }, ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + /// + /// Test case 2: valid pending code, anonymous caller → 200 with token; + /// device is registered in DeviceRegistry. + /// Also proves .AllowAnonymous() is wired — no auth header supplied. + /// + [Fact] + public async Task Exchange_returns_200_with_token_and_registers_device_for_valid_code() + { + var ct = TestContext.Current.CancellationToken; + // Produce a known pending code by calling GenerateCode() directly on the service. + var (code, _) = _pairingCodeService.GenerateCode(); + + await using var app = await CreateAppAsync(); + var client = app.GetTestClient(); + // No Authorization header — proves AllowAnonymous is wired. + + var response = await client.PostAsJsonAsync("/api/pair/exchange", + new { code, deviceName = "my-laptop" }, ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(ct); + Assert.True(body.TryGetProperty("token", out var tokenProp)); + Assert.False(string.IsNullOrWhiteSpace(tokenProp.GetString())); + + var devices = await _registry.ListAsync(ct); + Assert.Single(devices); + Assert.Equal("my-laptop", devices[0].Name); + } + + /// + /// Test case 3: invalid code → 401; failure recorded on PairingExchangeGuard. + /// + [Fact] + public async Task Exchange_returns_401_for_invalid_code_and_records_guard_failure() + { + var ct = TestContext.Current.CancellationToken; + _pairingCodeService.GenerateCode(); // ensure a code is pending so the gate opens + var remoteIp = IPAddress.Parse("10.0.0.1"); + + await using var app = await CreateAppAsync(remoteIp: remoteIp); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/pair/exchange", + new { code = "ZZZZ-ZZZZ", deviceName = "laptop" }, ct); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + // Guard should have recorded exactly one failure for this IP. + // Drive to threshold – 1 more attempts, then the very next should be blocked. + for (var i = 1; i < PairingExchangeGuard.FailureThreshold; i++) + { + _pairingCodeService.GenerateCode(); + var r = await client.PostAsJsonAsync("/api/pair/exchange", + new { code = "ZZZZ-ZZZZ", deviceName = "laptop" }, ct); + Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode); + } + + // One more pending code, then the IP should now be blocked. + _pairingCodeService.GenerateCode(); + var blocked = await client.PostAsJsonAsync("/api/pair/exchange", + new { code = "ZZZZ-ZZZZ", deviceName = "laptop" }, ct); + Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); + } + + /// + /// Test case 4: guard-blocked IP → 429 with Retry-After header. + /// Pre-seeds the guard to the blocked state by driving FailureThreshold failures, + /// then verifies the next request (with a valid pending code) is still blocked. + /// + [Fact] + public async Task Exchange_returns_429_with_RetryAfter_when_guard_has_blocked_ip() + { + var ct = TestContext.Current.CancellationToken; + var remoteIp = IPAddress.Parse("10.0.0.2"); + + // Pre-block the IP by recording failures directly on the guard. + for (var i = 0; i < PairingExchangeGuard.FailureThreshold; i++) + _exchangeGuard.RecordFailure(remoteIp); + + // Even with a valid pending code, the guard blocks before any code check. + _pairingCodeService.GenerateCode(); + + await using var app = await CreateAppAsync(remoteIp: remoteIp); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/pair/exchange", + new { code = "ABCD-EFGH", deviceName = "laptop" }, ct); + + Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode); + Assert.True(response.Headers.TryGetValues("Retry-After", out var values)); + Assert.NotEmpty(values); + } + + /// Test case 5: missing code or deviceName → 400. + [Fact] + public async Task Exchange_returns_400_when_code_is_missing() + { + var ct = TestContext.Current.CancellationToken; + _pairingCodeService.GenerateCode(); + + await using var app = await CreateAppAsync(); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/pair/exchange", + new { code = "", deviceName = "laptop" }, ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Exchange_returns_400_when_device_name_is_missing() + { + var ct = TestContext.Current.CancellationToken; + var (code, _) = _pairingCodeService.GenerateCode(); + + await using var app = await CreateAppAsync(); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/pair/exchange", + new { code, deviceName = "" }, ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// Test case 6: duplicate device name → 409. + /// Seeds the registry with an existing device, then submits a valid code with the same name. + /// + [Fact] + public async Task Exchange_returns_409_for_duplicate_device_name() + { + var ct = TestContext.Current.CancellationToken; + var (_, existingDevice) = DeviceTestHelpers.MakeDevice("laptop", _time.GetUtcNow()); + await _registry.AddAsync(existingDevice, ct); + + var (code, _) = _pairingCodeService.GenerateCode(); + + await using var app = await CreateAppAsync(); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/pair/exchange", + new { code, deviceName = "Laptop" }, ct); // case-insensitive duplicate + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + /// Coverage from old tests: code already consumed → 404 on second attempt. + [Fact] + public async Task Exchange_returns_404_when_code_already_consumed() + { + var ct = TestContext.Current.CancellationToken; + var (code, _) = _pairingCodeService.GenerateCode(); + + await using var app = await CreateAppAsync(); + var client = app.GetTestClient(); + + var first = await client.PostAsJsonAsync("/api/pair/exchange", + new { code, deviceName = "laptop" }, ct); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + // Code is consumed; second attempt sees no pending code → 404. + var second = await client.PostAsJsonAsync("/api/pair/exchange", + new { code, deviceName = "phone" }, ct); + Assert.Equal(HttpStatusCode.NotFound, second.StatusCode); + } + + /// Coverage from old tests: expired code → 404. + [Fact] + public async Task Exchange_returns_404_when_code_is_expired() + { + var ct = TestContext.Current.CancellationToken; + _pairingCodeService.GenerateCode(); + // Advance past the 5-minute TTL. + _time.Advance(TimeSpan.FromMinutes(6)); + + await using var app = await CreateAppAsync(); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/pair/exchange", + new { code = "ABCD-EFGH", deviceName = "laptop" }, ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + /// + /// Coverage from old tests: the returned bearer token authenticates a subsequent + /// request to an authorized endpoint. + /// + [Fact] + public async Task Exchange_returned_token_authenticates_against_authorized_endpoints() + { + var ct = TestContext.Current.CancellationToken; + var (code, _) = _pairingCodeService.GenerateCode(); + + await using var app = await CreateAppAsync(); + var client = app.GetTestClient(); + + var exchangeResponse = await client.PostAsJsonAsync("/api/pair/exchange", + new { code, deviceName = "phone" }, ct); + Assert.Equal(HttpStatusCode.OK, exchangeResponse.StatusCode); + + var body = await exchangeResponse.Content.ReadFromJsonAsync(ct); + var token = body.GetProperty("token").GetString()!; + + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var devicesResponse = await client.GetAsync("/api/pair/devices", ct); + Assert.Equal(HttpStatusCode.OK, devicesResponse.StatusCode); + } + + // ─── GET /api/pair/devices ───────────────────────────────────────────────── + + /// Test case 7a: unauthenticated GET → 401. + [Fact] + public async Task List_devices_returns_401_for_unauthenticated_request() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: false); + var client = app.GetTestClient(); + + var response = await client.GetAsync("/api/pair/devices", ct); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + /// + /// Test case 7b: authenticated (loopback) GET → 200 with sanitized list + /// (no TokenHash/Salt fields in the response). + /// + [Fact] + public async Task List_devices_returns_sanitized_list_for_loopback_caller() + { + var ct = TestContext.Current.CancellationToken; + var (_, laptop) = DeviceTestHelpers.MakeDevice("laptop", _time.GetUtcNow()); + var (_, phone) = DeviceTestHelpers.MakeDevice("phone", _time.GetUtcNow()); + await _registry.AddAsync(laptop, ct); + await _registry.AddAsync(phone, ct); + + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.GetAsync("/api/pair/devices", ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var devices = await response.Content.ReadFromJsonAsync>(ct); + Assert.NotNull(devices); + Assert.Equal(2, devices.Count); + Assert.Contains(devices, d => d.Name == "laptop"); + Assert.Contains(devices, d => d.Name == "phone"); + + // Verify no token hash / salt fields leak through by checking the raw JSON. + var raw = await response.Content.ReadAsStringAsync(ct); + Assert.DoesNotContain("tokenHash", raw, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("salt", raw, StringComparison.OrdinalIgnoreCase); + } + + // ─── DELETE /api/pair/devices/{name} ────────────────────────────────────── + + /// Test case 8a: unauthenticated DELETE → 401. + [Fact] + public async Task Revoke_device_returns_401_for_unauthenticated_request() + { + var ct = TestContext.Current.CancellationToken; + var (_, device) = DeviceTestHelpers.MakeDevice("laptop", _time.GetUtcNow()); + await _registry.AddAsync(device, ct); + + await using var app = await CreateAppAsync(spoofLoopback: false); + var client = app.GetTestClient(); + + var response = await client.DeleteAsync("/api/pair/devices/laptop", ct); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + /// Test case 8b: authenticated DELETE of existing device → 204. + [Fact] + public async Task Revoke_device_returns_204_and_removes_it_for_loopback_caller() + { + var ct = TestContext.Current.CancellationToken; + var (_, device) = DeviceTestHelpers.MakeDevice("laptop", _time.GetUtcNow()); + await _registry.AddAsync(device, ct); + + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.DeleteAsync("/api/pair/devices/laptop", ct); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var remaining = await _registry.ListAsync(ct); + Assert.Empty(remaining); + } + + /// Test case 8c: authenticated DELETE of missing device → 404. + [Fact] + public async Task Revoke_device_returns_404_when_device_not_found() + { + var ct = TestContext.Current.CancellationToken; + await using var app = await CreateAppAsync(spoofLoopback: true); + var client = app.GetTestClient(); + + var response = await client.DeleteAsync("/api/pair/devices/nonexistent", ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // ─── Reverse-proxy: per-IP defenses key on the forwarded client IP ───────── + + /// + /// Behind a trusted reverse proxy, the per-IP failure lockout + /// () must key on the real client IP from + /// X-Forwarded-For — not the proxy's address. Otherwise one abusive + /// client would lock out every client sharing the proxy, and a client could + /// not be individually locked out at all. + /// + [Fact] + public async Task ReverseProxy_guard_locks_out_by_forwarded_client_ip() + { + var ct = TestContext.Current.CancellationToken; + _pairingCodeService.GenerateCode(); + + // The direct peer is the trusted proxy; UseForwardedHeaders rewrites the + // request IP to the X-Forwarded-For client. + await using var app = await CreateAppAsync( + remoteIp: IPAddress.Parse("10.0.0.5"), + trustedProxies: ["10.0.0.5"]); + var client = app.GetTestClient(); + + for (var i = 0; i < PairingExchangeGuard.FailureThreshold; i++) + { + var failed = await PostExchangeAsync(client, "ZZZZ-ZZZZ", "laptop", "198.51.100.20", ct); + Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); + } + + // The next attempt from that forwarded client IP is locked out. + var blocked = await PostExchangeAsync(client, "ZZZZ-ZZZZ", "laptop", "198.51.100.20", ct); + Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); + + // A different forwarded client behind the same proxy is unaffected — the + // lockout is per real client IP, not per proxy. + var other = await PostExchangeAsync(client, "ZZZZ-ZZZZ", "laptop", "198.51.100.21", ct); + Assert.Equal(HttpStatusCode.Unauthorized, other.StatusCode); + } + + /// + /// Behind a trusted reverse proxy, the ASP.NET rate limiter must partition by + /// the forwarded client IP, so the brute-force window is per real client and + /// cannot be evaded by, or shared across, clients behind the proxy. + /// + [Fact] + public async Task ReverseProxy_rate_limiter_partitions_by_forwarded_client_ip() + { + var ct = TestContext.Current.CancellationToken; + _pairingCodeService.GenerateCode(); + + await using var app = await CreateAppAsync( + remoteIp: IPAddress.Parse("10.0.0.5"), + trustedProxies: ["10.0.0.5"], + useRealRateLimiter: true); // production 5/min/IP limit + var client = app.GetTestClient(); + + // Exhaust the 5-request window for one forwarded client IP (well under the + // guard's failure threshold, so the limiter — not the guard — is what fires). + for (var i = 0; i < 5; i++) + { + var r = await PostExchangeAsync(client, "ZZZZ-ZZZZ", "laptop", "198.51.100.30", ct); + Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode); + } + + var limited = await PostExchangeAsync(client, "ZZZZ-ZZZZ", "laptop", "198.51.100.30", ct); + Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode); + + // A different forwarded client has its own window. + var other = await PostExchangeAsync(client, "ZZZZ-ZZZZ", "laptop", "198.51.100.31", ct); + Assert.Equal(HttpStatusCode.Unauthorized, other.StatusCode); + } + + private static Task PostExchangeAsync( + HttpClient client, + string code, + string deviceName, + string forwardedFor, + CancellationToken ct) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/pair/exchange") + { + Content = JsonContent.Create(new { code, deviceName }), + }; + request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor); + return client.SendAsync(request, ct); + } +} diff --git a/src/Netclaw.Daemon.Tests/Security/PairingExchangeEndpointTests.cs b/src/Netclaw.Daemon.Tests/Security/PairingExchangeEndpointTests.cs deleted file mode 100644 index 9c8ec4f99..000000000 --- a/src/Netclaw.Daemon.Tests/Security/PairingExchangeEndpointTests.cs +++ /dev/null @@ -1,488 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading.RateLimiting; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Time.Testing; -using Netclaw.Configuration; -using Netclaw.Daemon.Security; -using Netclaw.Tests.Utilities; -using Xunit; - -namespace Netclaw.Daemon.Tests.Security; - -/// -/// Integration tests for the POST /api/pair/exchange endpoint. -/// -/// Validates HTTP-level behavior: 200 on valid exchange, 400 on missing fields, -/// 401 on invalid/expired/reused codes, and that the returned token authenticates -/// subsequent requests. -/// -public sealed class PairingExchangeEndpointTests : IDisposable -{ - private readonly DisposableTempDir _dir = new(); - private readonly FakeTimeProvider _time; - private readonly DeviceRegistry _registry; - private readonly PairingCodeService _pairingCodeService; - private readonly PairingExchangeGuard _exchangeGuard; - - public PairingExchangeEndpointTests() - { - _time = new FakeTimeProvider(new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero)); - _registry = new DeviceRegistry(new NetclawPaths(_dir.Path), _time, NullLogger.Instance); - _pairingCodeService = new PairingCodeService(_time); - _exchangeGuard = new PairingExchangeGuard(_time); - } - - public void Dispose() => _dir.Dispose(); - - private async Task CreateAppAsync( - DaemonConfig? daemonConfig = null, - IPAddress? directPeerIp = null, - bool enableRateLimiting = false) - { - daemonConfig ??= new DaemonConfig(); - - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - - builder.Services.AddSingleton(_registry); - builder.Services.AddSingleton(_pairingCodeService); - builder.Services.AddSingleton(_exchangeGuard); - builder.Services.AddSingleton(_time); - builder.Services.AddNetclawAuthSchemes(daemonConfig); - builder.Services.AddAuthorization(); - - if (enableRateLimiting) - { - builder.Services.AddRateLimiter(options => - { - options.AddPolicy("pairing-exchange", context => - RateLimitPartition.GetFixedWindowLimiter( - context.Connection.RemoteIpAddress?.ToString() ?? "unknown", - _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 5, - Window = TimeSpan.FromMinutes(1), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0, - })); - options.RejectionStatusCode = 429; - }); - } - - var app = builder.Build(); - - if (directPeerIp is not null) - { - var ip = directPeerIp; - app.Use(async (ctx, next) => - { - ctx.Connection.RemoteIpAddress = ip; - await next(ctx); - }); - } - - if (daemonConfig.ExposureMode == ExposureMode.ReverseProxy) - { - var forwardedHeadersOptions = new ForwardedHeadersOptions - { - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, - ForwardLimit = 1 - }; - - foreach (var trustedProxy in DaemonExposureValidator.ParseTrustedProxies(daemonConfig.TrustedProxies)) - { - if (trustedProxy.PrefixLength is null) - { - forwardedHeadersOptions.KnownProxies.Add(trustedProxy.Address); - } - else - { - forwardedHeadersOptions.KnownIPNetworks.Add( - new System.Net.IPNetwork(trustedProxy.Address, trustedProxy.PrefixLength.Value)); - } - } - - app.UseForwardedHeaders(forwardedHeadersOptions); - } - - app.UseAuthentication(); - app.UseAuthorization(); - - if (enableRateLimiting) - app.UseRateLimiter(); - - var exchangeEndpoint = app.MapPost("/api/pair/exchange", async ( - HttpContext httpContext, - PairingCodeExchangeRequest request, - PairingCodeService pairingCodeService, - PairingExchangeGuard exchangeGuard, - DeviceRegistry deviceRegistry, - TimeProvider timeProvider, - CancellationToken ct) => - { - var remoteIp = httpContext.Connection.RemoteIpAddress; - - if (exchangeGuard.IsBlocked(remoteIp)) - { - var retryAfter = exchangeGuard.GetRetryAfterSeconds(remoteIp); - httpContext.Response.Headers.RetryAfter = retryAfter?.ToString() ?? "900"; - return Results.Json( - new { error = "Too many failed attempts. Try again later." }, - statusCode: StatusCodes.Status429TooManyRequests); - } - - if (pairingCodeService.GetPendingExpiry() is null) - return Results.NotFound(); - - if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.DeviceName)) - return Results.BadRequest(new { error = "code and deviceName are required." }); - - if (!pairingCodeService.TryConsume(request.Code)) - { - exchangeGuard.RecordFailure(remoteIp); - return Results.Json( - new { error = "Invalid, expired, or already-used pairing code." }, - statusCode: 401); - } - - var tokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); - var rawToken = System.Buffers.Text.Base64Url.EncodeToString(tokenBytes); - - var saltBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(16); - var saltHex = Convert.ToHexString(saltBytes).ToLowerInvariant(); - var tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex); - - var now = timeProvider.GetUtcNow(); - var device = new PairedDevice - { - Name = request.DeviceName.Trim(), - TokenHash = tokenHash, - Salt = saltHex, - CreatedAt = now, - LastUsedAt = now, - }; - - try - { - await deviceRegistry.AddAsync(device, ct); - } - catch (InvalidOperationException ex) - { - return Results.Conflict(new { error = ex.Message }); - } - - return Results.Ok(new { token = rawToken }); - }); - - if (enableRateLimiting) - exchangeEndpoint.RequireRateLimiting("pairing-exchange"); - - exchangeEndpoint.AllowAnonymous(); - - // Authenticated endpoint to verify returned tokens work - app.MapGet("/api/pair/devices", async (DeviceRegistry deviceRegistry, CancellationToken ct) => - { - var devices = await deviceRegistry.ListAsync(ct); - var sanitized = devices.Select(d => new PairedDeviceInfoDto(d.Name, d.CreatedAt, d.LastUsedAt)); - return Results.Ok(sanitized); - }).RequireAuthorization(); - - await app.StartAsync(); - return app; - } - - [Fact] - public async Task Exchange_returns_200_with_token_for_valid_code() - { - var ct = TestContext.Current.CancellationToken; - var (code, _) = _pairingCodeService.GenerateCode(); - - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - var response = await client.PostAsJsonAsync("/api/pair/exchange", - new { code, deviceName = "laptop" }, ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(ct); - Assert.True(body.TryGetProperty("token", out var tokenProp)); - Assert.False(string.IsNullOrWhiteSpace(tokenProp.GetString())); - } - - [Fact] - public async Task Exchange_registers_device_and_token_authenticates() - { - var ct = TestContext.Current.CancellationToken; - var (code, _) = _pairingCodeService.GenerateCode(); - - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - // Exchange the code - var exchangeResponse = await client.PostAsJsonAsync("/api/pair/exchange", - new { code, deviceName = "phone" }, ct); - Assert.Equal(HttpStatusCode.OK, exchangeResponse.StatusCode); - - var body = await exchangeResponse.Content.ReadFromJsonAsync(ct); - var token = body.GetProperty("token").GetString()!; - - // Use the returned token to hit an authenticated endpoint - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - var devicesResponse = await client.GetAsync("/api/pair/devices", ct); - Assert.Equal(HttpStatusCode.OK, devicesResponse.StatusCode); - - var devices = await devicesResponse.Content.ReadFromJsonAsync>(ct); - Assert.NotNull(devices); - Assert.Single(devices); - Assert.Equal("phone", devices[0].Name); - } - - [Fact] - public async Task Exchange_returns_400_when_code_missing() - { - var ct = TestContext.Current.CancellationToken; - _pairingCodeService.GenerateCode(); // ensure a code is pending so the gate lets us through - - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - var response = await client.PostAsJsonAsync("/api/pair/exchange", - new { code = "", deviceName = "laptop" }, ct); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task Exchange_returns_400_when_device_name_missing() - { - var ct = TestContext.Current.CancellationToken; - var (code, _) = _pairingCodeService.GenerateCode(); - - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - var response = await client.PostAsJsonAsync("/api/pair/exchange", - new { code, deviceName = "" }, ct); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task Exchange_returns_401_for_wrong_code() - { - var ct = TestContext.Current.CancellationToken; - _pairingCodeService.GenerateCode(); // generate a real code, but present a wrong one - - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - var response = await client.PostAsJsonAsync("/api/pair/exchange", - new { code = "ZZZZ-ZZZZ", deviceName = "laptop" }, ct); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Exchange_returns_404_when_no_code_pending() - { - var ct = TestContext.Current.CancellationToken; - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - var response = await client.PostAsJsonAsync("/api/pair/exchange", - new { code = "ABCD-EFGH", deviceName = "laptop" }, ct); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task Exchange_returns_404_when_code_already_consumed() - { - var ct = TestContext.Current.CancellationToken; - var (code, _) = _pairingCodeService.GenerateCode(); - - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - // First exchange succeeds - var first = await client.PostAsJsonAsync("/api/pair/exchange", - new { code, deviceName = "laptop" }, ct); - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - - // Second exchange with same code fails — no pending code means 404 - var second = await client.PostAsJsonAsync("/api/pair/exchange", - new { code, deviceName = "phone" }, ct); - Assert.Equal(HttpStatusCode.NotFound, second.StatusCode); - } - - [Fact] - public async Task Exchange_returns_404_when_code_expired() - { - var ct = TestContext.Current.CancellationToken; - var (code, _) = _pairingCodeService.GenerateCode(); - - // Advance time past the 5-minute TTL - _time.Advance(TimeSpan.FromMinutes(6)); - - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - // Expired code means GetPendingExpiry() returns null → 404 - var response = await client.PostAsJsonAsync("/api/pair/exchange", - new { code, deviceName = "laptop" }, ct); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task Exchange_returns_409_for_duplicate_device_name() - { - var ct = TestContext.Current.CancellationToken; - var (_, existingDevice) = DeviceTestHelpers.MakeDevice("laptop", _time.GetUtcNow()); - await _registry.AddAsync(existingDevice, ct); - - var (code, _) = _pairingCodeService.GenerateCode(); - - await using var app = await CreateAppAsync(); - var client = app.GetTestClient(); - - var response = await client.PostAsJsonAsync("/api/pair/exchange", - new { code, deviceName = "Laptop" }, ct); - - Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); - } - - [Fact] - public async Task ReverseProxy_PairingGuard_UsesForwardedClientIp_FromTrustedProxy() - { - var ct = TestContext.Current.CancellationToken; - _pairingCodeService.GenerateCode(); - - var daemonConfig = new DaemonConfig - { - ExposureMode = ExposureMode.ReverseProxy, - Host = "10.0.0.10", - TrustedProxies = ["10.0.0.5"] - }; - - await using var app = await CreateAppAsync( - daemonConfig: daemonConfig, - directPeerIp: IPAddress.Parse("10.0.0.5")); - var client = app.GetTestClient(); - - for (var i = 0; i < PairingExchangeGuard.FailureThreshold; i++) - { - var failedResponse = await PostExchangeAsync( - client, - code: "ZZZZ-ZZZZ", - deviceName: "laptop", - forwardedFor: "198.51.100.20", - ct); - - Assert.Equal(HttpStatusCode.Unauthorized, failedResponse.StatusCode); - } - - var blockedResponse = await PostExchangeAsync( - client, - code: "ZZZZ-ZZZZ", - deviceName: "laptop", - forwardedFor: "198.51.100.20", - ct); - - Assert.Equal(HttpStatusCode.TooManyRequests, blockedResponse.StatusCode); - Assert.True(blockedResponse.Headers.TryGetValues("Retry-After", out _)); - - var otherForwardedClientResponse = await PostExchangeAsync( - client, - code: "ZZZZ-ZZZZ", - deviceName: "laptop", - forwardedFor: "198.51.100.21", - ct); - - Assert.Equal(HttpStatusCode.Unauthorized, otherForwardedClientResponse.StatusCode); - } - - [Fact] - public async Task ReverseProxy_RateLimiter_UsesForwardedClientIp_FromTrustedProxy() - { - var ct = TestContext.Current.CancellationToken; - _pairingCodeService.GenerateCode(); - - var daemonConfig = new DaemonConfig - { - ExposureMode = ExposureMode.ReverseProxy, - Host = "10.0.0.10", - TrustedProxies = ["10.0.0.5"] - }; - - await using var app = await CreateAppAsync( - daemonConfig: daemonConfig, - directPeerIp: IPAddress.Parse("10.0.0.5"), - enableRateLimiting: true); - var client = app.GetTestClient(); - - for (var i = 0; i < 5; i++) - { - var response = await PostExchangeAsync( - client, - code: "ZZZZ-ZZZZ", - deviceName: "laptop", - forwardedFor: "198.51.100.30", - ct); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - var limitedResponse = await PostExchangeAsync( - client, - code: "ZZZZ-ZZZZ", - deviceName: "laptop", - forwardedFor: "198.51.100.30", - ct); - - Assert.Equal(HttpStatusCode.TooManyRequests, limitedResponse.StatusCode); - - var otherForwardedClientResponse = await PostExchangeAsync( - client, - code: "ZZZZ-ZZZZ", - deviceName: "laptop", - forwardedFor: "198.51.100.31", - ct); - - Assert.Equal(HttpStatusCode.Unauthorized, otherForwardedClientResponse.StatusCode); - } - - private static Task PostExchangeAsync( - HttpClient client, - string code, - string deviceName, - string? forwardedFor, - CancellationToken ct) - { - var request = new HttpRequestMessage(HttpMethod.Post, "/api/pair/exchange") - { - Content = JsonContent.Create(new { code, deviceName }) - }; - - if (!string.IsNullOrWhiteSpace(forwardedFor)) - request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor); - - return client.SendAsync(request, ct); - } -} diff --git a/src/Netclaw.Daemon/Gateway/SessionRegistry.cs b/src/Netclaw.Daemon/Gateway/SessionRegistry.cs index f67663bb6..8faae49d4 100644 --- a/src/Netclaw.Daemon/Gateway/SessionRegistry.cs +++ b/src/Netclaw.Daemon/Gateway/SessionRegistry.cs @@ -234,10 +234,10 @@ public async Task SendMessageAsync(string connectionId, string sessionId, string Audience = TrustAudience.Personal, Boundary = SecurityPolicyDefaults.LocalDaemonBoundary, Principal = identity.Principal, - Provenance = new SourceProvenance + Provenance = new SourceProvenance( + identity.Transport, + PayloadTaint.Trusted) { - TransportAuthenticity = identity.Transport, - PayloadTaint = PayloadTaint.Trusted, SourceKind = "signalr" }, Contents = [new TextContent(text)], diff --git a/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs b/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs index 43aa53d1d..fc7126333 100644 --- a/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs +++ b/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs @@ -58,16 +58,7 @@ public static Props CreateProps(string entityId, ISessionPipeline pipeline, private SessionPipelineOptions BuildOptions() => new() { - ChannelType = _channelType, - DefaultAudience = TrustAudience.Personal, - DefaultBoundary = SecurityPolicyDefaults.LocalDaemonBoundary, - DefaultPrincipal = PrincipalClassification.Operator, - DefaultProvenance = new SourceProvenance - { - TransportAuthenticity = TransportAuthenticity.LocalProcess, - PayloadTaint = PayloadTaint.Trusted, - SourceKind = "signalr" - } + ChannelType = _channelType }; private void Initializing() diff --git a/src/Netclaw.Daemon/Lifecycle/LifecycleEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Lifecycle/LifecycleEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..f1dbcf54b --- /dev/null +++ b/src/Netclaw.Daemon/Lifecycle/LifecycleEndpointRouteBuilderExtensions.cs @@ -0,0 +1,32 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Netclaw.Daemon.Services; + +namespace Netclaw.Daemon.Lifecycle; + +public static class LifecycleEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapLifecycleEndpoints(this IEndpointRouteBuilder app) + { + // Daemon lifecycle endpoint — CLI calls this before sending SIGTERM. + // Config-triggered restart coordination happens inside DaemonRestartCoordinator. + app.MapPost("/api/lifecycle/shutdown", ( + DaemonLifecycleNotifier notifier, + HttpRequest request) => + { + var reason = request.Query["reason"].ToString(); + if (string.IsNullOrEmpty(reason)) + return Results.BadRequest(new { error = "reason query parameter is required" }); + + notifier.NotifyShutdown(reason); + return Results.Ok(new { reason, pid = Environment.ProcessId }); + }).RequireAuthorization(); + + return app; + } +} diff --git a/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..511e5189c --- /dev/null +++ b/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs @@ -0,0 +1,119 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Netclaw.Configuration; +using Netclaw.Tools; + +namespace Netclaw.Daemon.Mcp; + +public static class McpEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapMcpEndpoints(this IEndpointRouteBuilder app) + { + // MCP OAuth 2.1 endpoints + app.MapPost("/api/mcp/oauth/start/{name}", async ( + string name, + McpOAuthService oauthService, + Dictionary mcpServers, + CancellationToken ct) => + { + if (!mcpServers.TryGetValue(name, out var entry)) + return Results.NotFound(new { error = $"MCP server '{name}' not found" }); + + if (string.IsNullOrWhiteSpace(entry.Url)) + return Results.BadRequest(new { error = $"MCP server '{name}' has no URL (OAuth requires HTTP transport)" }); + + var (authUrl, state) = await oauthService.StartAuthorizationFlowAsync(new McpServerName(name), entry, ct); + return Results.Ok(new { authorizationUrl = authUrl, state }); + }).RequireAuthorization(); + + app.MapGet("/api/mcp/oauth/callback", async ( + HttpContext context, + McpOAuthService oauthService, + CancellationToken ct) => + { + var code = context.Request.Query["code"].ToString(); + var state = context.Request.Query["state"].ToString(); + + if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) + { + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync( + "

Authorization failed

Missing code or state parameter.

", ct); + return; + } + + try + { + await oauthService.CompleteAuthorizationAsync(code, state, ct); + + // Auto-reconnect the MCP server now that we have a valid token. + // Resolved as IMcpReconnectable so the callback is not coupled to the + // concrete McpClientManager type, which is heavyweight and hard to stub in tests. + var serverName = oauthService.GetServerNameForState(state); + if (serverName is not null) + { + var mcpManager = context.RequestServices.GetRequiredService(); + var reconnectLogger = context.RequestServices.GetRequiredService>(); + _ = Task.Run(async () => + { + try { await mcpManager.TryReconnectAsync(serverName.Value, CancellationToken.None); } + catch (Exception ex) { reconnectLogger.LogWarning(ex, "Post-OAuth reconnect failed for MCP server '{Name}'", serverName.Value.Value); } + }, CancellationToken.None); + } + + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync( + "

Authorization complete

You may close this tab.

", ct); + } + catch (Exception ex) + { + context.Response.StatusCode = 500; + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync( + $"

Authorization failed

{System.Net.WebUtility.HtmlEncode(ex.Message)}

", ct); + } + }).AllowAnonymous(); + + app.MapGet("/api/mcp/statuses", (McpClientManager mcpManager) => + { + var statuses = mcpManager.GetServerStatuses(); + var result = statuses.ToDictionary( + kvp => kvp.Key.Value, + kvp => new + { + state = kvp.Value.State.ToString(), + toolCount = kvp.Value.ToolCount, + error = kvp.Value.ErrorMessage, + }); + return Results.Ok(result); + }).RequireAuthorization(); + + app.MapGet("/api/mcp/tools/{name}", (string name, McpClientManager mcpManager) => + { + var tools = mcpManager.GetToolNames(new McpServerName(name)); + return Results.Ok(tools); + }).RequireAuthorization(); + + app.MapGet("/api/mcp/oauth/status/{name}", (string name, McpOAuthService oauthService) => + { + var status = oauthService.GetFlowStatus(new McpServerName(name)); + return Results.Ok(new { status = status.ToString() }); + }).RequireAuthorization(); + + app.MapGet("/api/mcp/oauth/status-by-state/{state}", (string state, McpOAuthService oauthService) => + { + var status = oauthService.GetFlowStatusByState(state); + // Tokens are persisted daemon-side — never expose them over HTTP. + return Results.Ok(new { status = status.ToString() }); + }).RequireAuthorization(); + + return app; + } +} diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index f4df2e57c..3162d1c08 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -3,8 +3,6 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Buffers.Text; -using System.Security.Cryptography; using System.Threading.RateLimiting; using Akka.Actor; using Akka.Hosting; @@ -42,6 +40,8 @@ using Netclaw.Daemon.Providers; using Netclaw.Daemon.Security; using Netclaw.Daemon.Services; +using Netclaw.Daemon.Lifecycle; +using Netclaw.Daemon.Reminders; using Netclaw.Daemon.Webhooks; using Netclaw.Search; using Netclaw.Tools; @@ -214,208 +214,20 @@ static async Task RunDaemonAsync(string[] args, DaemonRestartSignal restartSigna Results.Ok(await statsService.GetSkillUsageStatsAsync(days, ct))).RequireAuthorization(); app.MapWebhookEndpoints(); - // Device pairing exchange — unauthenticated, rate-limited, with per-IP lockout guard. - // Accepts a time-limited pairing code and a device name; returns a bearer token on success. - app.MapPost("/api/pair/exchange", async ( - HttpContext httpContext, - PairingCodeExchangeRequest request, - PairingCodeService pairingCodeService, - PairingExchangeGuard exchangeGuard, - DeviceRegistry deviceRegistry, - TimeProvider timeProvider, - CancellationToken ct) => - { - var remoteIp = httpContext.Connection.RemoteIpAddress; - - // Layer 1: Per-IP failure lockout — blocked IPs get 429 before any processing. - if (exchangeGuard.IsBlocked(remoteIp)) - { - var retryAfter = exchangeGuard.GetRetryAfterSeconds(remoteIp); - httpContext.Response.Headers.RetryAfter = retryAfter?.ToString() ?? "900"; - return Results.Json( - new { error = "Too many failed attempts. Try again later." }, - statusCode: StatusCodes.Status429TooManyRequests); - } - - // Layer 2: No-code-pending gate — if no code exists, hide the endpoint entirely. - if (pairingCodeService.GetPendingExpiry() is null) - return Results.NotFound(); - - if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.DeviceName)) - return Results.BadRequest(new { error = "code and deviceName are required." }); - - if (!pairingCodeService.TryConsume(request.Code)) - { - exchangeGuard.RecordFailure(remoteIp); - return Results.Json( - new { error = "Invalid, expired, or already-used pairing code." }, - statusCode: StatusCodes.Status401Unauthorized); - } + app.MapPairingEndpoints(); - var tokenBytes = RandomNumberGenerator.GetBytes(32); - var rawToken = Base64Url.EncodeToString(tokenBytes); - - var saltBytes = RandomNumberGenerator.GetBytes(16); - var saltHex = Convert.ToHexString(saltBytes).ToLowerInvariant(); - var tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex); - - var now = timeProvider.GetUtcNow(); - var device = new PairedDevice - { - Name = request.DeviceName.Trim(), - TokenHash = tokenHash, - Salt = saltHex, - CreatedAt = now, - LastUsedAt = now, - }; - - try - { - await deviceRegistry.AddAsync(device, ct); - } - catch (InvalidOperationException ex) - { - return Results.Conflict(new { error = ex.Message }); - } - - return Results.Ok(new { token = rawToken }); - }).RequireRateLimiting("pairing-exchange").AllowAnonymous(); - - // Device registry management — authenticated (loopback or valid bearer token required). - // Returns a sanitized view of paired devices (no TokenHash/Salt). - app.MapGet("/api/pair/devices", async (DeviceRegistry deviceRegistry, CancellationToken ct) => - { - var devices = await deviceRegistry.ListAsync(ct); - var sanitized = devices.Select(d => new PairedDeviceInfoDto(d.Name, d.CreatedAt, d.LastUsedAt)); - return Results.Ok(sanitized); - }).RequireAuthorization(); - - app.MapDelete("/api/pair/devices/{name}", async (string name, DeviceRegistry deviceRegistry, CancellationToken ct) => - { - var removed = await deviceRegistry.RemoveAsync(name, ct); - return removed - ? Results.NoContent() - : Results.NotFound(new { error = $"Device '{name}' not found." }); - }).RequireAuthorization(); - - // MCP OAuth 2.1 endpoints - app.MapPost("/api/mcp/oauth/start/{name}", async ( - string name, - McpOAuthService oauthService, - Dictionary mcpServers, - CancellationToken ct) => - { - if (!mcpServers.TryGetValue(name, out var entry)) - return Results.NotFound(new { error = $"MCP server '{name}' not found" }); - - if (string.IsNullOrWhiteSpace(entry.Url)) - return Results.BadRequest(new { error = $"MCP server '{name}' has no URL (OAuth requires HTTP transport)" }); - - var (authUrl, state) = await oauthService.StartAuthorizationFlowAsync(new McpServerName(name), entry, ct); - return Results.Ok(new { authorizationUrl = authUrl, state }); - }).RequireAuthorization(); - - app.MapGet("/api/mcp/oauth/callback", async ( - HttpContext context, - McpOAuthService oauthService, - CancellationToken ct) => - { - var code = context.Request.Query["code"].ToString(); - var state = context.Request.Query["state"].ToString(); - - if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) - { - context.Response.ContentType = "text/html"; - await context.Response.WriteAsync( - "

Authorization failed

Missing code or state parameter.

", ct); - return; - } - - try - { - await oauthService.CompleteAuthorizationAsync(code, state, ct); - - // Auto-reconnect the MCP server now that we have a valid token - var serverName = oauthService.GetServerNameForState(state); - if (serverName is not null) - { - var mcpManager = context.RequestServices.GetRequiredService(); - var reconnectLogger = context.RequestServices.GetRequiredService>(); - _ = Task.Run(async () => - { - try { await mcpManager.TryReconnectAsync(serverName.Value, CancellationToken.None); } - catch (Exception ex) { reconnectLogger.LogWarning(ex, "Post-OAuth reconnect failed for MCP server '{Name}'", serverName.Value.Value); } - }, CancellationToken.None); - } - - context.Response.ContentType = "text/html"; - await context.Response.WriteAsync( - "

Authorization complete

You may close this tab.

", ct); - } - catch (Exception ex) - { - context.Response.StatusCode = 500; - context.Response.ContentType = "text/html"; - await context.Response.WriteAsync( - $"

Authorization failed

{System.Net.WebUtility.HtmlEncode(ex.Message)}

", ct); - } - }).AllowAnonymous(); - - app.MapGet("/api/mcp/statuses", (McpClientManager mcpManager) => - { - var statuses = mcpManager.GetServerStatuses(); - var result = statuses.ToDictionary( - kvp => kvp.Key.Value, - kvp => new - { - state = kvp.Value.State.ToString(), - toolCount = kvp.Value.ToolCount, - error = kvp.Value.ErrorMessage, - }); - return Results.Ok(result); - }).RequireAuthorization(); - - app.MapGet("/api/mcp/tools/{name}", (string name, McpClientManager mcpManager) => - { - var tools = mcpManager.GetToolNames(new McpServerName(name)); - return Results.Ok(tools); - }).RequireAuthorization(); - - app.MapGet("/api/mcp/oauth/status/{name}", (string name, McpOAuthService oauthService) => - { - var status = oauthService.GetFlowStatus(new McpServerName(name)); - return Results.Ok(new { status = status.ToString() }); - }).RequireAuthorization(); - - app.MapGet("/api/mcp/oauth/status-by-state/{state}", (string state, McpOAuthService oauthService) => - { - var status = oauthService.GetFlowStatusByState(state); - // Tokens are persisted daemon-side — never expose them over HTTP. - return Results.Ok(new { status = status.ToString() }); - }).RequireAuthorization(); + app.MapMcpEndpoints(); app.MapProviderOAuthEndpoints(); - // Daemon lifecycle endpoint — CLI calls this before sending SIGTERM. - // Config-triggered restart coordination happens inside DaemonRestartCoordinator. - app.MapPost("/api/lifecycle/shutdown", ( - DaemonLifecycleNotifier notifier, - HttpRequest request) => - { - var reason = request.Query["reason"].ToString(); - if (string.IsNullOrEmpty(reason)) - return Results.BadRequest(new { error = "reason query parameter is required" }); - - notifier.NotifyShutdown(reason); - return Results.Ok(new { reason, pid = Environment.ProcessId }); - }).RequireAuthorization(); + app.MapLifecycleEndpoints(); // Register tools that need DI-resolved dependencies after the container is built. ChannelToolRegistration.RegisterChannelTools(app.Services); SkillToolRegistration.RegisterSkillTools(app.Services); // Reminder REST API - MapReminderEndpoints(app); + app.MapReminderEndpoints(); // Fire startup notification after all hosted services are ready app.Lifetime.ApplicationStarted.Register(() => @@ -1321,280 +1133,6 @@ static void CopyBuiltInSkills(string skillsDirectory) } } -// ═══════════════════════════════════════════════════════════════════════ -// Reminder REST API -// ═══════════════════════════════════════════════════════════════════════ - -static void MapReminderEndpoints(WebApplication app) -{ - var reminders = app.MapGroup("/api/reminders") - .RequireAuthorization(); - - static ReminderAudienceAuthorizationContext? ResolveReminderAuthorizationContext(ClaimsPrincipalMapper mapper, HttpContext httpContext) - { - var identity = mapper.Map(httpContext.User); - if (identity.Principal is not PrincipalClassification.Operator) - return null; - - return new ReminderAudienceAuthorizationContext( - TrustAudience.Personal, - $"{identity.Principal}/{identity.Transport}"); - } - - reminders.MapGet("", async ( - Akka.Hosting.IRequiredActor actor, - CancellationToken ct) => - { - var manager = await actor.GetAsync(ct); - var response = await manager.Ask( - new Netclaw.Actors.Reminders.ListRemindersCommand(IncludeDisabled: false), TimeSpan.FromSeconds(10), ct); - var projected = response.Reminders.Select(r => new - { - id = r.Id.Value, - title = r.Title, - enabled = r.Enabled, - schedule = Netclaw.Actors.Reminders.ListRemindersTool.DescribeSchedule(r.Schedule), - nextFire = Netclaw.Actors.Reminders.SetReminderTool.FormatTimestamp(r.NextFire), - expiresAt = r.ExpiresAt is null - ? null - : Netclaw.Actors.Reminders.SetReminderTool.FormatTimestamp(r.ExpiresAt), - audience = r.Audience?.ToWireValue(), - }); - return Results.Ok(projected); - }); - - reminders.MapPost("", async ( - CreateReminderRequest request, - Akka.Hosting.IRequiredActor actor, - IServiceProvider serviceProvider, - ClaimsPrincipalMapper mapper, - HttpContext httpContext, - TimeProvider timeProvider, - CancellationToken ct) => - { - var manager = await actor.GetAsync(ct); - var authorization = ResolveReminderAuthorizationContext(mapper, httpContext); - - var effectiveId = !string.IsNullOrWhiteSpace(request.Id) - ? request.Id - : Netclaw.Actors.Reminders.ReminderIdGenerator.Generate(request.Name).Value; - - var deliveryKind = request.Delivery?.Kind ?? request.DeliveryKind; - var deliveryTransport = request.Delivery?.Transport ?? request.DeliveryTransport; - var deliveryAddress = request.Delivery?.Address ?? request.DeliveryAddress; - - var reminderResolvers = serviceProvider.GetServices(); - var restSchedulingConfig = serviceProvider.GetRequiredService(); - var tool = new Netclaw.Actors.Reminders.SetReminderTool(manager, timeProvider, restSchedulingConfig, reminderResolvers); - var toolContext = new Netclaw.Tools.ToolExecutionContext(sessionId: null, sessionDirectory: null); - toolContext.Audience = authorization?.SourceAudience?.ToWireValue(); - toolContext.ChannelType = "manual"; - var result = await tool.ExecuteAsync( - new Dictionary - { - ["Id"] = effectiveId, - ["Name"] = request.Name, - ["Prompt"] = request.Prompt, - ["ScheduleType"] = request.ScheduleType, - ["Schedule"] = request.Schedule, - ["DeliveryKind"] = deliveryKind, - ["DeliveryTransport"] = deliveryTransport, - ["DeliveryAddress"] = deliveryAddress, - ["DeliveryRequired"] = request.DeliveryRequired, - ["DeliveryInstructions"] = request.DeliveryInstructions, - ["Audience"] = request.Audience, - ["ExpiresIn"] = request.ExpiresIn - }, toolContext, ct); - - return result.StartsWith("Error", StringComparison.Ordinal) - ? Results.BadRequest(new { error = result }) - : Results.Ok(new { message = result }); - }); - - reminders.MapPost("/validate", ( - CreateReminderRequest request, - TimeProvider timeProvider) => - { - var (schedule, error) = ReminderScheduleParser.Parse( - request.ScheduleType, - request.Schedule, - timeProvider); - - if (schedule is null) - return Results.BadRequest(new { valid = false, error }); - - return Results.Ok(new { valid = true, scheduleType = schedule.Type.ToString(), nextFire = schedule.FireAt }); - }); - - reminders.MapPost("/import", async ( - ImportReminderRequest request, - Akka.Hosting.IRequiredActor actor, - ClaimsPrincipalMapper mapper, - HttpContext httpContext, - CancellationToken ct) => - { - if (request.Definition is null) - return Results.BadRequest(new { error = "Reminder definition is required." }); - - var authorization = ResolveReminderAuthorizationContext(mapper, httpContext); - - var mode = request.WriteMode?.Trim().ToLowerInvariant() switch - { - "replace" => ReminderWriteMode.Replace, - "upsert" => ReminderWriteMode.Upsert, - null or "" or "create" or "createonly" => ReminderWriteMode.CreateOnly, - _ => (ReminderWriteMode?)null - }; - - if (mode is null) - return Results.BadRequest(new { error = "Invalid writeMode. Use create, replace, or upsert." }); - - var manager = await actor.GetAsync(ct); - var response = await manager.Ask( - new SaveReminderCommand(request.Definition, mode.Value, authorization), - TimeSpan.FromSeconds(10), - ct); - - if (!response.Success) - { - var status = response.Error is ReminderSaveError.Conflict - ? StatusCodes.Status409Conflict - : response.Error is ReminderSaveError.NotFound - ? StatusCodes.Status404NotFound - : StatusCodes.Status400BadRequest; - - return Results.Json(new - { - error = response.ErrorMessage ?? "Import failed.", - code = response.Error.ToString(), - id = response.Id.Value - }, statusCode: status); - } - - return Results.Ok(new - { - id = response.Id.Value, - title = response.Title, - nextFire = response.NextFire, - message = $"Imported reminder '{response.Id.Value}'." - }); - }); - - reminders.MapDelete("/{id}", async ( - string id, - bool? permanent, - Akka.Hosting.IRequiredActor actor, - CancellationToken ct) => - { - var manager = await actor.GetAsync(ct); - var reminderId = new Netclaw.Actors.Reminders.ReminderId(id); - - if (permanent == true) - { - var deleted = await manager.Ask( - new Netclaw.Actors.Reminders.DeleteReminderCommand(reminderId), - TimeSpan.FromSeconds(10), ct); - - return deleted.Found - ? Results.Ok(new { message = $"Reminder '{id}' permanently deleted." }) - : Results.NotFound(new { error = $"Reminder '{id}' not found." }); - } - - var response = await manager.Ask( - new Netclaw.Actors.Reminders.CancelReminderCommand(reminderId), - TimeSpan.FromSeconds(10), ct); - - return response.Found - ? Results.Ok(new { message = $"Reminder '{id}' cancelled (disabled)." }) - : Results.NotFound(new { error = $"Reminder '{id}' not found." }); - }); - - reminders.MapPost("/{id}/disable", async ( - string id, - Akka.Hosting.IRequiredActor actor, - CancellationToken ct) => - { - var manager = await actor.GetAsync(ct); - var response = await manager.Ask( - new DisableReminderCommand(new ReminderId(id)), - TimeSpan.FromSeconds(10), - ct); - - return !response.Found - ? Results.NotFound(new { error = response.ErrorMessage ?? $"Reminder '{id}' not found." }) - : Results.Ok(new { id = id, enabled = response.Enabled, message = $"Reminder '{id}' disabled." }); - }); - - reminders.MapPost("/{id}/enable", async ( - string id, - Akka.Hosting.IRequiredActor actor, - CancellationToken ct) => - { - var manager = await actor.GetAsync(ct); - var response = await manager.Ask( - new EnableReminderCommand(new ReminderId(id)), - TimeSpan.FromSeconds(10), - ct); - - if (!response.Found) - return Results.NotFound(new { error = response.ErrorMessage ?? $"Reminder '{id}' not found." }); - if (!response.Enabled && !string.IsNullOrWhiteSpace(response.ErrorMessage)) - return Results.BadRequest(new { error = response.ErrorMessage, id, enabled = false }); - - return Results.Ok(new { id, enabled = response.Enabled, nextFire = response.NextFire, message = $"Reminder '{id}' enabled." }); - }); - - reminders.MapGet("/{id}", async ( - string id, - Akka.Hosting.IRequiredActor actor, - CancellationToken ct) => - { - var manager = await actor.GetAsync(ct); - var response = await manager.Ask( - new Netclaw.Actors.Reminders.GetReminderCommand(new Netclaw.Actors.Reminders.ReminderId(id)), - TimeSpan.FromSeconds(10), ct); - - if (response.Reminder is null) - return Results.NotFound(new { error = $"Reminder '{id}' not found." }); - - var r = response.Reminder; - return Results.Ok(new - { - id = r.Id.Value, - title = r.Title, - enabled = r.Enabled, - schedule = Netclaw.Actors.Reminders.ListRemindersTool.DescribeSchedule(r.Schedule), - nextFire = Netclaw.Actors.Reminders.SetReminderTool.FormatTimestamp(r.NextFire), - expiresAt = r.ExpiresAt is null - ? null - : Netclaw.Actors.Reminders.SetReminderTool.FormatTimestamp(r.ExpiresAt), - instructions = r.Instructions, - deliveryKind = r.Delivery.Kind.ToString().ToLowerInvariant(), - deliveryTransport = r.Delivery.Transport, - deliveryAddress = r.Delivery.Address, - deliveryRequired = r.DeliveryRequired, - deliveryInstructions = r.DeliveryInstructions, - audience = r.Audience?.ToWireValue(), - }); - }); - - reminders.MapGet("/{id}/history", async ( - string id, - int? last, - ReminderDefinitionStore definitionStore, - ReminderHistoryStore historyStore, - CancellationToken ct) => - { - var rid = new ReminderId(id); - if (!definitionStore.Exists(rid)) - return Results.NotFound(new { error = $"Reminder '{id}' not found." }); - - var maxRecords = Math.Clamp(last ?? 20, 1, 500); - var records = await historyStore.ReadAsync(rid, maxRecords); - return Results.Ok(records); - }); -} - static Akka.Event.LogLevel ToAkkaLogLevel(LogLevel logLevel) { return logLevel switch @@ -1607,42 +1145,4 @@ static Akka.Event.LogLevel ToAkkaLogLevel(LogLevel logLevel) }; } -/// -/// REST API request body for creating a reminder. -/// -sealed record CreateReminderRequest -{ - public string? Id { get; init; } - public required string Name { get; init; } - public required string Prompt { get; init; } - public required string ScheduleType { get; init; } - public required string Schedule { get; init; } - public string? DeliveryKind { get; init; } - public string? DeliveryTransport { get; init; } - public string? DeliveryAddress { get; init; } - public bool DeliveryRequired { get; init; } = true; - public string? DeliveryInstructions { get; init; } - public ReminderDeliveryRequest? Delivery { get; init; } - public string? Audience { get; init; } - public string? ExpiresIn { get; init; } -} - -sealed record ReminderDeliveryRequest -{ - public string? Kind { get; init; } - public string? Transport { get; init; } - public string? Address { get; init; } -} - -sealed record ImportReminderRequest -{ - public required ReminderDefinition Definition { get; init; } - public string? WriteMode { get; init; } -} - -/// -/// Request body for POST /api/pair/exchange. -/// -sealed record PairingCodeExchangeRequest(string Code, string DeviceName); - public partial class Program; diff --git a/src/Netclaw.Daemon/Reminders/ReminderEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Reminders/ReminderEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..461f49d82 --- /dev/null +++ b/src/Netclaw.Daemon/Reminders/ReminderEndpointRouteBuilderExtensions.cs @@ -0,0 +1,338 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Hosting; +using Netclaw.Actors.Reminders; +using Netclaw.Configuration; +using Netclaw.Daemon.Security; +using Netclaw.Tools; + +namespace Netclaw.Daemon.Reminders; + +public static class ReminderEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuilder app) + { + var reminders = app.MapGroup("/api/reminders") + .RequireAuthorization(); + + reminders.MapGet("", async ( + IRequiredActor actor, + CancellationToken ct) => + { + var manager = await actor.GetAsync(ct); + var response = await manager.Ask( + new ListRemindersCommand(IncludeDisabled: false), TimeSpan.FromSeconds(10), ct); + var projected = response.Reminders.Select(r => new + { + id = r.Id.Value, + title = r.Title, + enabled = r.Enabled, + schedule = ListRemindersTool.DescribeSchedule(r.Schedule), + nextFire = SetReminderTool.FormatTimestamp(r.NextFire), + expiresAt = r.ExpiresAt is null + ? null + : SetReminderTool.FormatTimestamp(r.ExpiresAt), + audience = r.Audience?.ToWireValue(), + }); + return Results.Ok(projected); + }); + + reminders.MapPost("", async ( + CreateReminderRequest request, + IRequiredActor actor, + IServiceProvider serviceProvider, + ClaimsPrincipalMapper mapper, + HttpContext httpContext, + TimeProvider timeProvider, + CancellationToken ct) => + { + var manager = await actor.GetAsync(ct); + var authorization = ResolveReminderAuthorizationContext(mapper, httpContext); + + // Creating a reminder requires Operator authority — ResolveReminderAuthorizationContext + // returns null for a non-Operator caller. Reject here: the tool-execution context's + // audience is now required and non-nullable, so a null authorization would otherwise + // be silently defaulted, smuggling the request past the actor's authority check. + if (authorization?.SourceAudience is not { } reminderSourceAudience) + return Results.Problem( + detail: "Creating a reminder requires Operator authority.", + statusCode: StatusCodes.Status403Forbidden); + + var effectiveId = !string.IsNullOrWhiteSpace(request.Id) + ? request.Id + : ReminderIdGenerator.Generate(request.Name).Value; + + var deliveryKind = request.Delivery?.Kind ?? request.DeliveryKind; + var deliveryTransport = request.Delivery?.Transport ?? request.DeliveryTransport; + var deliveryAddress = request.Delivery?.Address ?? request.DeliveryAddress; + + var reminderResolvers = serviceProvider.GetServices(); + var restSchedulingConfig = serviceProvider.GetRequiredService(); + var tool = new SetReminderTool(manager, timeProvider, restSchedulingConfig, reminderResolvers); + var toolContext = new ToolExecutionContext(sessionId: null, sessionDirectory: null) + { + Audience = reminderSourceAudience, + Boundary = SecurityPolicyDefaults.ResolveBoundaryFromChannelType("manual", reminderSourceAudience), + }; + toolContext.ChannelType = "manual"; + var result = await tool.ExecuteAsync( + new Dictionary + { + ["Id"] = effectiveId, + ["Name"] = request.Name, + ["Prompt"] = request.Prompt, + ["ScheduleType"] = request.ScheduleType, + ["Schedule"] = request.Schedule, + ["DeliveryKind"] = deliveryKind, + ["DeliveryTransport"] = deliveryTransport, + ["DeliveryAddress"] = deliveryAddress, + ["DeliveryRequired"] = request.DeliveryRequired, + ["DeliveryInstructions"] = request.DeliveryInstructions, + ["Audience"] = request.Audience, + ["ExpiresIn"] = request.ExpiresIn + }, toolContext, ct); + + return result.StartsWith("Error", StringComparison.Ordinal) + ? Results.BadRequest(new { error = result }) + : Results.Ok(new { message = result }); + }); + + reminders.MapPost("/validate", ( + CreateReminderRequest request, + TimeProvider timeProvider) => + { + var (schedule, error) = ReminderScheduleParser.Parse( + request.ScheduleType, + request.Schedule, + timeProvider); + + if (schedule is null) + return Results.BadRequest(new { valid = false, error }); + + return Results.Ok(new { valid = true, scheduleType = schedule.Type.ToString(), nextFire = schedule.FireAt }); + }); + + reminders.MapPost("/import", async ( + ImportReminderRequest request, + IRequiredActor actor, + ClaimsPrincipalMapper mapper, + HttpContext httpContext, + CancellationToken ct) => + { + if (request.Definition is null) + return Results.BadRequest(new { error = "Reminder definition is required." }); + + var authorization = ResolveReminderAuthorizationContext(mapper, httpContext); + + var mode = request.WriteMode?.Trim().ToLowerInvariant() switch + { + "replace" => ReminderWriteMode.Replace, + "upsert" => ReminderWriteMode.Upsert, + null or "" or "create" or "createonly" => ReminderWriteMode.CreateOnly, + _ => (ReminderWriteMode?)null + }; + + if (mode is null) + return Results.BadRequest(new { error = "Invalid writeMode. Use create, replace, or upsert." }); + + var manager = await actor.GetAsync(ct); + var response = await manager.Ask( + new SaveReminderCommand(request.Definition, mode.Value, authorization), + TimeSpan.FromSeconds(10), + ct); + + if (!response.Success) + { + var status = response.Error is ReminderSaveError.Conflict + ? StatusCodes.Status409Conflict + : response.Error is ReminderSaveError.NotFound + ? StatusCodes.Status404NotFound + : StatusCodes.Status400BadRequest; + + return Results.Json(new + { + error = response.ErrorMessage ?? "Import failed.", + code = response.Error.ToString(), + id = response.Id.Value + }, statusCode: status); + } + + return Results.Ok(new + { + id = response.Id.Value, + title = response.Title, + nextFire = response.NextFire, + message = $"Imported reminder '{response.Id.Value}'." + }); + }); + + reminders.MapDelete("/{id}", async ( + string id, + bool? permanent, + IRequiredActor actor, + CancellationToken ct) => + { + var manager = await actor.GetAsync(ct); + var reminderId = new ReminderId(id); + + if (permanent == true) + { + var deleted = await manager.Ask( + new DeleteReminderCommand(reminderId), + TimeSpan.FromSeconds(10), ct); + + return deleted.Found + ? Results.Ok(new { message = $"Reminder '{id}' permanently deleted." }) + : Results.NotFound(new { error = $"Reminder '{id}' not found." }); + } + + var response = await manager.Ask( + new CancelReminderCommand(reminderId), + TimeSpan.FromSeconds(10), ct); + + return response.Found + ? Results.Ok(new { message = $"Reminder '{id}' cancelled (disabled)." }) + : Results.NotFound(new { error = $"Reminder '{id}' not found." }); + }); + + reminders.MapPost("/{id}/disable", async ( + string id, + IRequiredActor actor, + CancellationToken ct) => + { + var manager = await actor.GetAsync(ct); + var response = await manager.Ask( + new DisableReminderCommand(new ReminderId(id)), + TimeSpan.FromSeconds(10), + ct); + + return !response.Found + ? Results.NotFound(new { error = response.ErrorMessage ?? $"Reminder '{id}' not found." }) + : Results.Ok(new { id = id, enabled = response.Enabled, message = $"Reminder '{id}' disabled." }); + }); + + reminders.MapPost("/{id}/enable", async ( + string id, + IRequiredActor actor, + CancellationToken ct) => + { + var manager = await actor.GetAsync(ct); + var response = await manager.Ask( + new EnableReminderCommand(new ReminderId(id)), + TimeSpan.FromSeconds(10), + ct); + + if (!response.Found) + return Results.NotFound(new { error = response.ErrorMessage ?? $"Reminder '{id}' not found." }); + if (!response.Enabled && !string.IsNullOrWhiteSpace(response.ErrorMessage)) + return Results.BadRequest(new { error = response.ErrorMessage, id, enabled = false }); + + return Results.Ok(new { id, enabled = response.Enabled, nextFire = response.NextFire, message = $"Reminder '{id}' enabled." }); + }); + + reminders.MapGet("/{id}", async ( + string id, + IRequiredActor actor, + CancellationToken ct) => + { + var manager = await actor.GetAsync(ct); + var response = await manager.Ask( + new GetReminderCommand(new ReminderId(id)), + TimeSpan.FromSeconds(10), ct); + + if (response.Reminder is null) + return Results.NotFound(new { error = $"Reminder '{id}' not found." }); + + var r = response.Reminder; + return Results.Ok(new + { + id = r.Id.Value, + title = r.Title, + enabled = r.Enabled, + schedule = ListRemindersTool.DescribeSchedule(r.Schedule), + nextFire = SetReminderTool.FormatTimestamp(r.NextFire), + expiresAt = r.ExpiresAt is null + ? null + : SetReminderTool.FormatTimestamp(r.ExpiresAt), + instructions = r.Instructions, + deliveryKind = r.Delivery.Kind.ToString().ToLowerInvariant(), + deliveryTransport = r.Delivery.Transport, + deliveryAddress = r.Delivery.Address, + deliveryRequired = r.DeliveryRequired, + deliveryInstructions = r.DeliveryInstructions, + audience = r.Audience?.ToWireValue(), + }); + }); + + reminders.MapGet("/{id}/history", async ( + string id, + int? last, + ReminderDefinitionStore definitionStore, + ReminderHistoryStore historyStore, + CancellationToken ct) => + { + var rid = new ReminderId(id); + if (!definitionStore.Exists(rid)) + return Results.NotFound(new { error = $"Reminder '{id}' not found." }); + + var maxRecords = Math.Clamp(last ?? 20, 1, 500); + var records = await historyStore.ReadAsync(rid, maxRecords); + return Results.Ok(records); + }); + + return app; + } + + private static ReminderAudienceAuthorizationContext? ResolveReminderAuthorizationContext(ClaimsPrincipalMapper mapper, HttpContext httpContext) + { + var identity = mapper.Map(httpContext.User); + if (identity.Principal is not PrincipalClassification.Operator) + return null; + + return new ReminderAudienceAuthorizationContext( + TrustAudience.Personal, + $"{identity.Principal}/{identity.Transport}"); + } +} + +/// +/// REST API request body for creating a reminder. +/// +internal sealed record CreateReminderRequest +{ + public string? Id { get; init; } + public required string Name { get; init; } + public required string Prompt { get; init; } + public required string ScheduleType { get; init; } + public required string Schedule { get; init; } + public string? DeliveryKind { get; init; } + public string? DeliveryTransport { get; init; } + public string? DeliveryAddress { get; init; } + public bool DeliveryRequired { get; init; } = true; + public string? DeliveryInstructions { get; init; } + public ReminderDeliveryRequest? Delivery { get; init; } + public string? Audience { get; init; } + public string? ExpiresIn { get; init; } +} + +internal sealed record ReminderDeliveryRequest +{ + public string? Kind { get; init; } + public string? Transport { get; init; } + public string? Address { get; init; } +} + +internal sealed record ImportReminderRequest +{ + public required ReminderDefinition Definition { get; init; } + public string? WriteMode { get; init; } +} diff --git a/src/Netclaw.Daemon/Security/PairingEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Security/PairingEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..de34e0758 --- /dev/null +++ b/src/Netclaw.Daemon/Security/PairingEndpointRouteBuilderExtensions.cs @@ -0,0 +1,109 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Buffers.Text; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Netclaw.Configuration; + +namespace Netclaw.Daemon.Security; + +public static class PairingEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuilder app) + { + // Device pairing exchange — unauthenticated, rate-limited, with per-IP lockout guard. + // Accepts a time-limited pairing code and a device name; returns a bearer token on success. + app.MapPost("/api/pair/exchange", async ( + HttpContext httpContext, + PairingCodeExchangeRequest request, + PairingCodeService pairingCodeService, + PairingExchangeGuard exchangeGuard, + DeviceRegistry deviceRegistry, + TimeProvider timeProvider, + CancellationToken ct) => + { + var remoteIp = httpContext.Connection.RemoteIpAddress; + + // Layer 1: Per-IP failure lockout — blocked IPs get 429 before any processing. + if (exchangeGuard.IsBlocked(remoteIp)) + { + var retryAfter = exchangeGuard.GetRetryAfterSeconds(remoteIp); + httpContext.Response.Headers.RetryAfter = retryAfter?.ToString() ?? "900"; + return Results.Json( + new { error = "Too many failed attempts. Try again later." }, + statusCode: StatusCodes.Status429TooManyRequests); + } + + // Layer 2: No-code-pending gate — if no code exists, hide the endpoint entirely. + if (pairingCodeService.GetPendingExpiry() is null) + return Results.NotFound(); + + if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.DeviceName)) + return Results.BadRequest(new { error = "code and deviceName are required." }); + + if (!pairingCodeService.TryConsume(request.Code)) + { + exchangeGuard.RecordFailure(remoteIp); + return Results.Json( + new { error = "Invalid, expired, or already-used pairing code." }, + statusCode: StatusCodes.Status401Unauthorized); + } + + var tokenBytes = RandomNumberGenerator.GetBytes(32); + var rawToken = Base64Url.EncodeToString(tokenBytes); + + var saltBytes = RandomNumberGenerator.GetBytes(16); + var saltHex = Convert.ToHexString(saltBytes).ToLowerInvariant(); + var tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex); + + var now = timeProvider.GetUtcNow(); + var device = new PairedDevice + { + Name = request.DeviceName.Trim(), + TokenHash = tokenHash, + Salt = saltHex, + CreatedAt = now, + LastUsedAt = now, + }; + + try + { + await deviceRegistry.AddAsync(device, ct); + } + catch (InvalidOperationException ex) + { + return Results.Conflict(new { error = ex.Message }); + } + + return Results.Ok(new { token = rawToken }); + }).RequireRateLimiting("pairing-exchange").AllowAnonymous(); + + // Device registry management — authenticated (loopback or valid bearer token required). + // Returns a sanitized view of paired devices (no TokenHash/Salt). + app.MapGet("/api/pair/devices", async (DeviceRegistry deviceRegistry, CancellationToken ct) => + { + var devices = await deviceRegistry.ListAsync(ct); + var sanitized = devices.Select(d => new PairedDeviceInfoDto(d.Name, d.CreatedAt, d.LastUsedAt)); + return Results.Ok(sanitized); + }).RequireAuthorization(); + + app.MapDelete("/api/pair/devices/{name}", async (string name, DeviceRegistry deviceRegistry, CancellationToken ct) => + { + var removed = await deviceRegistry.RemoveAsync(name, ct); + return removed + ? Results.NoContent() + : Results.NotFound(new { error = $"Device '{name}' not found." }); + }).RequireAuthorization(); + + return app; + } +} + +/// +/// Request body for POST /api/pair/exchange. +/// +internal sealed record PairingCodeExchangeRequest(string Code, string DeviceName); diff --git a/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs b/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs index 504d4e692..111714240 100644 --- a/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs +++ b/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs @@ -72,22 +72,13 @@ private async Task InitializeAsync() try { var self = Self; + var routeAudience = _invocation.Route.Config.Audience; var inputQueue = await _handle.InitializeWithQueueAsync( Context, _invocation.SessionId, new SessionPipelineOptions { ChannelType = ChannelType.Webhook, - DefaultAudience = _invocation.Route.Config.Audience, - DefaultBoundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(_invocation.Route.Config.Audience), - DefaultPrincipal = PrincipalClassification.VerifiedAutomation, - DefaultProvenance = new SourceProvenance - { - TransportAuthenticity = TransportAuthenticity.Verified, - PayloadTaint = ToPayloadTaint(_invocation.Route.Config.Audience), - SourceKind = _invocation.EventType ?? _invocation.Route.Name, - SourceScope = _invocation.Route.Name - }, Filter = OutputFilter.TextStreaming | OutputFilter.ToolCalls, PromptOverlay = _invocation.Route.BuildPromptOverlay() }, @@ -97,6 +88,16 @@ await inputQueue.OfferAsync(new ChannelInput { SenderId = $"webhook:{_invocation.Route.Name}", ChannelId = _invocation.Route.Name, + Audience = routeAudience, + Boundary = SecurityPolicyDefaults.ResolveBoundaryFromAudience(routeAudience), + Principal = PrincipalClassification.VerifiedAutomation, + Provenance = new SourceProvenance( + TransportAuthenticity.Verified, + ToPayloadTaint(routeAudience)) + { + SourceKind = _invocation.EventType ?? _invocation.Route.Name, + SourceScope = _invocation.Route.Name + }, Contents = [new TextContent(WebhookPayloadFormatter.Format(_invocation))], ReceivedAt = _invocation.ReceivedAt }); diff --git a/src/Netclaw.Security/NullPromptInjectionDetector.cs b/src/Netclaw.Security/NullPromptInjectionDetector.cs deleted file mode 100644 index 04f5c476c..000000000 --- a/src/Netclaw.Security/NullPromptInjectionDetector.cs +++ /dev/null @@ -1,21 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -namespace Netclaw.Security; - -/// -/// No-op prompt injection detector that reports all text as safe. -/// Used as the default until a real detector is implemented. -/// -public sealed class NullPromptInjectionDetector : IPromptInjectionDetector -{ - public Task DetectAsync( - string text, - string sourceContext, - CancellationToken cancellationToken = default) - { - return Task.FromResult(PromptInjectionResult.Safe()); - } -} diff --git a/src/Netclaw.Tools.Abstractions/Netclaw.Tools.Abstractions.csproj b/src/Netclaw.Tools.Abstractions/Netclaw.Tools.Abstractions.csproj index 7867121a0..1be973ab2 100644 --- a/src/Netclaw.Tools.Abstractions/Netclaw.Tools.Abstractions.csproj +++ b/src/Netclaw.Tools.Abstractions/Netclaw.Tools.Abstractions.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs index a9a02d6a0..5df43987c 100644 --- a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs +++ b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs @@ -3,6 +3,8 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Configuration; + namespace Netclaw.Tools; /// @@ -51,7 +53,10 @@ public sealed record SubAgentFinding /// public sealed class ToolExecutionContext { - public static readonly ToolExecutionContext Empty = new(null, null); + // Context-less sentinel for tools invoked outside a session. It carries the + // most-restrictive audience — a tool with no trust context can only run at + // the lowest privilege. + public static readonly ToolExecutionContext Empty = new(null, null) { Audience = TrustAudience.Public }; private static readonly IReadOnlySet EmptyApprovedPatternSet = new HashSet(StringComparer.OrdinalIgnoreCase); private List? _fileAttachments; @@ -63,7 +68,14 @@ public ToolExecutionContext(string? sessionId, string? sessionDirectory) SessionDirectory = sessionDirectory; } - public string? Audience { get; set; } + /// + /// Parsed trust audience for this tool call. Required and non-nullable, so a + /// tool gate reads it directly with no missing-audience fallback. The default + /// is resolved once, where the context is built; the context-less + /// sentinel carries the most-restrictive + /// . + /// + public required TrustAudience Audience { get; init; } public string? Boundary { get; set; }