diff --git a/.github/workflows/README.md b/.github/workflows/README.md index acc3572d5..5e39d1c99 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -75,13 +75,12 @@ are in the `master` branch protection rule alongside `cert-gate` (see above). ### Faster multi-PR merges (process + optional admin) `strict: true` makes every merge leave every other open PR BEHIND, so a fan-out of -readiness-touching PRs pays the heavy matrix once per tip. Prefer: +readiness-touching PRs pays the heavy matrix once per tip update. Prefer: -1. **Stack** core-path series (each PR’s base is the previous tip) so CI runs once. -2. **Split** the queue: land non-`READINESS_PATHS` PRs first (~1 min Readiness), then one stack. -3. **One settled tip** before waiting — conflicts, knowledge-graph regen, PR-body tokens — every - `synchronize` restarts the matrix. -4. **Squash-merge into `master`** unless history must be preserved (SPEC series may use merge commits). +1. **Split** the queue: land non-`READINESS_PATHS` PRs first (~1 min Readiness). +2. **One settled tip** before waiting — conflicts, knowledge-graph regen, PR-body tokens — every + `synchronize` restarts the matrix (~30–40 min when core paths changed). +3. **Squash-merge into `master`** by default. **Merge queue (admin):** the readiness `changes` job already handles `merge_group`. Enabling a GitHub merge queue on `master` (Settings → Rules → Ruleset / merge queue, require the same five diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c7a7fc2..c39a0815b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,15 +19,21 @@ the version on nuget.org, which is why it trails `VERSION` between releases rath ### Added +- **CI: Full Platform Readiness no longer push-triggers on `cursor/**`.** Branch protection uses the PR + `Readiness summary` check; tip pushes on agent branches had been starting a second heavy matrix. Also: + `scripts/knowledge-graph/resolve-rebase-conflict.sh` for mid-rebase KG conflicts, two shell-lint gates that pin + the push-branch list and the helper, and a more race-tolerant `doctor-container-truthful` UAT probe. + - **SPEC-007 PR 4.5: subject producers, still report-only.** The self-extend runner enters `agent:` at `SystemHigh` around the cycle and the post-cycle admission and auto-share. Egress records for that cycle go from `no-subject` to `subject:agent:` at `SystemHigh`. Nothing refuses, and no send changes. `ToolCallingAgent` begins a read around each tool call. Only a tool that implements `IEgressLabelledTool` may report; `RAGTool` is the only one, and it reports each hit's canonical label, or `Public` when the search read nothing. Any other tool, and a tool that throws, counts as `SystemHigh`. A `peer:` chat response is observed as `SystemHigh` after it - returns. A model response is not a read. An open read still counts only when the scope ends, which is a known - limit when a runner's floor is below `SystemHigh` (the owner can reverse it). The labelled-tool marker is recorded - in `PublicAPI.Unshipped.txt`. + returns. A model response is not a read. An open read still counts only when the scope ends (PR 4.4 rule); the + owner decided on 2026-10-06 that an open unreported scope should decide at `SystemHigh` (Scenario B → + `SystemHighData`) — not yet implemented, tracked in PR 4.5b. The labelled-tool marker is recorded in + `PublicAPI.Unshipped.txt`. - **`Ashlar.Abstractions.Security`: Bell-LaPadula security labels and reference-monitor decisions.** `SecurityLevel` orders `Public < Internal < Confidential < Secret < TopSecret`, with the names and @@ -169,10 +175,10 @@ the version on nuget.org, which is why it trails `VERSION` between releases rath subject; a task, timer, registration or continuation the publisher created and the callback starts or triggers keeps the publisher's frame, since each captures the flow where it is created, and a thread or `System.Timers.Timer` the callback created and the publisher starts runs in the - publisher's frame, since it captures the flow where it is started. Still report-only, and no production code enters a frame - or begins a read yet, so no recorded decision changes; the agent producers come in PR 4.5. The new - public API is in `src/Ashlar.Abstractions/PublicAPI.Unshipped.txt`, and the behaviour is pinned by - cert-gate tests listed in `ci/cert-gate-assertions.md`. + publisher's frame, since it captures the flow where it is started. Still report-only. Until PR 4.5 + no production code entered a frame or began a read, so no recorded decision changed then; the agent + producers are in PR 4.5. The new public API is in `src/Ashlar.Abstractions/PublicAPI.Unshipped.txt`, + and the behaviour is pinned by cert-gate tests listed in `ci/cert-gate-assertions.md`. - **A Claude Code cloud session sets docker up by itself.** A new SessionStart hook, `.claude/hooks/session-start.sh` (registered in `.claude/settings.json`), runs only when `CLAUDE_CODE_REMOTE=true`: it starts `dockerd` if it is not running, writes a session-local @@ -236,7 +242,8 @@ the version on nuget.org, which is why it trails `VERSION` between releases rath - MCP over HTTP fails boot on SecureWorkstation. Stdio still boots. AirGapped still refuses an enabled MCP server. Hosts select HTTP with `WithAshlarHttpTransport` (it registers the marker, then `WithHttpTransport`). - On AirGapped and SecureWorkstation, API urls (`ASPNETCORE_URLS` and `Kestrel:Endpoints:*:Url`) and mesh serve - must bind loopback or boot fails. `127.0.0.1`, `localhost` and `::1` succeed; `0.0.0.0`, `+` and `*` fail. + must bind loopback or boot fails. `127.0.0.1`, `localhost` and `[::1]` succeed (bare `::1` is refused by + URI parsing); `0.0.0.0`, `+` and `*` fail. An empty url list is the host default and is allowed. Mesh serve reads optional `ASHLAR_MESH_SERVE_BIND`; unset on these profiles is any-interface and fails boot rather than being rewritten. diff --git a/CLAUDE.md b/CLAUDE.md index beda82cf9..b57e97725 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,9 @@ Read automatically by Claude Code at the start of every session in this reposito - **Five required checks**, with `strict` and `enforce_admins` on master: `cert-gate`, `build-core`, `shell-lint`, `lychee (README + docs)`, `Readiness summary`. Every merge makes every other open PR BEHIND, so merges are serial; a day-one red blocks every PR in the repository. For multi-PR - sweeps: stack readiness-touching series (base = previous tip), land non-core-path PRs first, push - one settled tip then wait. Full Platform Readiness does **not** push-trigger on `cursor/**` (PR - only) — see `CONTRIBUTING.md` ("Landing several PRs…"). + sweeps: land non-core-path PRs first, push one settled tip then wait (every tip update restarts + Readiness). Full Platform Readiness does **not** push-trigger on `cursor/**` (PR only) — see + `CONTRIBUTING.md` ("Landing several PRs…"). - `shell-lint` runs `scripts/ci/run-repo-gates.sh`, which **auto-discovers** `tests/scripts/*.test.sh`, `scripts/ci/test-*.{sh,py}`, `scripts/ci/verify-*.{sh,py}` and `scripts/knowledge-graph/verify-*-current.py` and runs each one **bare**, with no arguments. A script diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 761b2d6e2..c83240716 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,25 +11,26 @@ These five checks run on every PR and always report a status, so they are safe a - Branch from the latest `master`, keep branches short-lived (days, not weeks), one concern per branch. - Name branches `/` using the same types as Conventional Commits: `feat/…`, `fix/…`, `docs/…`, `chore/…`, `ci/…`, `refactor/…`, `test/…`. For multi-PR efforts, put the epic name at the front of the topic so related branches sort together: `feat/trust-loop-hot-swap`, `feat/trust-loop-fence-probe`. Do **not** name a head branch `application/*` when it targets `master` (the layer-boundary gate rejects that pairing). - Everything lands through a PR into `master`. Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) **by convention** — `type(scope): subject`, WHY in the body — there is no commitlint hook or workflow enforcing it; `scripts/changelog-snippet-for-release.sh` relies on the convention to draft release notes. Merged head branches are deleted automatically. -- **Merge into `master` with squash** unless a series needs preserved merge commits. Prefer one method per sweep so rebases stay boring. +- **Merge into `master` with squash** by default. Prefer one method per sweep so rebases stay boring. ### Landing several PRs without paying Readiness N times Branch protection is `strict` (must be up to date with `master`) and `enforce_admins`, so each merge makes every other open PR BEHIND and forces another CI cycle. The expensive part is usually -the Full Platform Readiness heavy lanes (~40 minutes when a core path changed), not `cert-gate`. - -1. **Stack core-path series.** For SPEC / kernel / hosting / CLI work, open PRs as a chain: PR N+1’s - base is PR N’s head branch (not `master`). CI runs on the tip once; merges become fast-forwards - or cheap updates instead of N full readiness matrices. -2. **Split by cost.** Merge PRs that do not touch readiness core paths first (`Readiness summary` - passes in ~1 minute). Save readiness-touching work for one stacked series. -3. **Push once, then wait.** Resolve conflicts, regenerate `docs/knowledge-graph.{json,md}` +the Full Platform Readiness heavy lanes (~30–40 minutes when a core path changed), not `cert-gate`. +With `strict`, updating a tip onto the post-merge `master` re-fires the matrix — stacking alone does +not skip that cost. + +1. **Split by cost.** Merge PRs that do not touch readiness core paths first (`Readiness summary` + passes in ~1 minute). Save readiness-touching work for later in the sweep. +2. **Push once, then wait.** Resolve conflicts, regenerate `docs/knowledge-graph.{json,md}` (`scripts/knowledge-graph/resolve-rebase-conflict.sh` during a rebase), settle PR-body tokens (`[coordinated-integration]`, `[skip-prod-style]`), then one push. Every tip update restarts the platform matrix. -4. **Do not expect a second readiness run on `cursor/**` pushes.** Branch protection uses the PR +3. **Do not expect a second readiness run on `cursor/**` pushes.** Branch protection uses the PR check; the workflow’s `push:` trigger is `master`/`main` only so agent tips do not double-fire. +4. **Optional: merge queue (admin).** A GitHub merge queue on `master` rebases and re-checks once per + entry; the readiness `changes` job already handles `merge_group`. - Spikes that should not merge get an `archive/spike-` tag on their tip, then the branch is deleted — the work stays reachable without cluttering the branch list. - Releases are tags on `master`: bump `VERSION`, move the `[Unreleased]` notes in `CHANGELOG.md` under the new version heading, tag `vX.Y.Z`, and publish a GitHub Release (`scripts/changelog-snippet-for-release.sh` drafts the notes). Release branches (`release/x.y`) only appear if an old version ever needs long-term patch support. diff --git a/ci/cert-gate-assertions.md b/ci/cert-gate-assertions.md index a1cc33d44..07bd0cc0f 100644 --- a/ci/cert-gate-assertions.md +++ b/ci/cert-gate-assertions.md @@ -66,13 +66,14 @@ away, can see what they would be turning off. | **A read counts at what it reported only when it returned and reported; otherwise it counts as `SystemHigh`.** `EgressSubject.BeginRead()` returns a `ReadScope`, which observes into every frame of the chain it was begun on when it is disposed: after `Complete()` with no `Report`, `SystemHigh`; after `Complete()` with reports, their join and nothing else (a `Public` report reads nothing and raises no mark); without `Complete()`, as when the read throws, `SystemHigh` even after a report. `EgressSubject.Observe` never satisfies a scope. The scope observes into the chain it was begun on from whatever flow it ends on, including one that never had that chain (`ExecutionContext.SuppressFlow`, a thread started without the execution context, or a callback run detached); an unreported read begun in a task with no frame of its own after its parent frame ended still raises that parent, so a sibling task inside it decides `SystemHigh`; a report after it ended still reaches the frames from any flow, concurrent reports all count, and with no frame a scope changes nothing. | `EgressSubjectReadScopeTests` | Without the unreported-read rule, an unlabelled tool result counts as nothing read and the agent's next model call carries it at the floor. Without the completion rule, a tool that reads and then throws sends its data out in the exception's message at whatever it reported first. If `Observe` satisfied a scope, any tool could launder its result by observing `Public` on a side value. `ToolCallingAgent` begins a read around each tool call and accepts `Report` only from `IEgressLabelledTool` (`RAGTool` in PR 4.5); an unlabelled tool or a throw is `SystemHigh`. | | **Work handed to another component is not decided at its caller's mark.** The internal, raise-only `EgressSubject.RunDetached(Action)` runs a callback with no subject on the calling flow and puts back exactly the caller's frame when it returns or throws, and when it throws, before any exception filter of the caller runs, since an exception's first pass runs filters before any `finally` block: the callback's own filters and `finally` blocks run detached, and a filter of the caller decides at the caller's mark (`Secret`, `LevelTooLow`), also around a nested callback, and what it reads, by `Observe` or a read scope, raises the caller's frame, and a frame it enters stays on the caller's flow until the flow disposes it (`Secret`, two frames on the flow); a decision inside it has no subject (`SystemHigh`); a frame entered inside it starts a chain of its own, whose reads never reach the caller's frames; a frame the callback enters and leaves undisposed is dropped from the calling flow, which decides at its own mark (`Internal`), also after it disposes that frame; a task inside a parent frame that has ended is back inside that parent after an `AgentBus` publish, and a frame it enters afterwards still decides at the parent's mark (`Secret`); and a task started inside the callback, and a `Task`, `System.Threading.Timer`, registration or continuation created inside it wherever it is started or triggered, keep no subject for their whole life: a task started inside it, after the callback returns, after the caller's frame ends, and when a frame it was handed ends after the callback returned, and a cold task, a timer, a cancellation registration and a continuation created inside it, wherever they are started or triggered (`SystemHigh`). Each of those captures the flow where it is created, so one the caller created keeps the caller's frame although the callback starts or triggers it (`Secret`, `LevelTooLow`); a thread and a `System.Timers.Timer` capture it where they are started, so one the caller created and the callback starts runs detached (`SystemHigh`), and one the callback created and the caller starts runs in the caller's frame (`Secret`, `LevelTooLow`). No caller holds the detachment, so none can end it out of order, on another flow, or by handing it to a task, and no program on one flow leaves the flow detached, or below a live frame it entered outside every callback: not the 24 that dispose three frames entered inside the callback in every order, 0 to 3 of them before it returns and the rest after it (each back in the caller's frame, at its mark, with one frame on the flow), and not any of the 2,092 programs of an enclosing frame, up to two more frames and up to two callbacks, nested or in turn, each checked step by step against a model of the rule, whose flows keep only the frames they entered outside the callbacks and disposed out of order (2,474). Read from its compiled body, `RunDetached` writes the flow's frame only inside a try that catches everything or in that catch (the detachment and both restores). `AgentBus.PublishAsync` starts every subscriber's task inside it, so a subscriber decides with no subject while the publisher is inside a frame, and the publisher's own frame is restored. A convention fact pins every `RunDetached` call site in the repository's C#, every tree but build output, dot directories and nested checkouts (exactly `AgentBus.cs` x1, over a floor of 1000 scanned files), and that the bus's `Task.Run(` and `subscription.Handler(` sit inside the callback and nowhere else in the file. | `EgressSubjectDetachTests` | `Task.Run` captures the publisher's execution context, so before SPEC-007 PR 4.4 a subscriber's egress was decided at the publisher's mark and recorded as the publisher's. A detachment a caller could hold could be disposed out of order, which left that flow detached below the live frame it was inside, so a frame it entered later decided below that frame: a write-down. A callback cannot be ended out of order. A restore made only in a `finally` would run a filter of the caller, such as `catch (Exception ex) when (Log(ex))`, at the callback's frame, below the live frame the caller is inside: a write-down, and its reads would miss the caller's frames. A restore repeated in a `finally` after the catch would run after that filter, in the rethrow's second pass, and drop a frame the filter entered, which the flow never disposed. A write of the flow's frame outside that try and its catch, such as a restore after the try, leaves a window in which an asynchronous abort (`ControlledExecution.Run` on .NET 7 and later) leaves `RunDetached` with the flow detached: the caller's filter for it decides below the caller's mark and what it reads misses the caller's frames, and the flow stays detached afterwards. A `Task`, a `System.Threading.Timer`, a registration or a continuation captures the flow where it is created, and a `Thread` or a `System.Timers.Timer` where it is started, so a new site must create and start the work it hands off inside the callback: a task created before it and started inside keeps the caller's frame, and a thread created inside it and started after it returns runs in the caller's frame. `RunDetached` only raises, but a call placed around a read would hide that read from the caller's frames, so a new site needs its reason recorded here. A tripwire, not a proof: a call through an alias, a delegate or reflection is not seen. | | **Every production `EgressSubject.Enter` is pinned with its floor and the method that encloses it.** The scan is the egress inventory's production trees (`src`, `application`, `applications`, `commercial`, `products`, `tools`, `consumer-template`), pruning build output, dot directories, nested checkouts and test projects by csproj content, never by name. Each call is recorded as file, enclosing method, the `new HighWaterMark(...)` argument and a count. The pin and the scan must match in both directions: a production `Enter` that is not listed fails, and a listed row that the scan does not find fails. Below 1000 scanned files the scan is taken to have lost its reach. PR 4.5 has one site: `SelfExtendRunnerAdapter.cs`, method `RunAsync`, floor `SecurityLabel.SystemHigh`. Test projects are not pinned. A tripwire, not a proof: an alias, a delegate or reflection is not seen. | `EgressSubjectFloorPinTests` | A runner that entered at `Public`, or in a helper whose frame does not reach the flow that awaits it, would decide below the unlabelled carry-over in the snapshot, and once the switch is on that is a write-down. A new production `Enter` with no row would ship the same way. The floor is part of the pin so a later runner that declares below `SystemHigh` has to say so here. | -| **AirGapped keeps remote execution local, and AirGapped and SecureWorkstation refuse a non-loopback inbound listener.** Composing `AddAshlar(AirGapped)` makes an overnight job `ExecutionTarget.Local` with the reason `AirGapped: remote execution unavailable; running locally (Overnight/background job forces remote execution.)`, and peer-network-only throws. The same overnight job on Full is still `ExecutionTarget.Remote`. Single-image vision and LLM on AirGapped never call the inner factory with `openai` or `azure` (a fake inner records the names); on Full the vision and LLM lists still contain both. Boot validation (`IOptions.Value`, the `ValidateOnStart` path) refuses, on AirGapped only, `EnablePeerNetworkRouting`, a non-empty `RemoteCatalogBaseUrls`, mesh-lab `Enabled` and Bedrock `Enabled`; the same flags succeed on Full, and a plain AirGapped composition leaves them off. The ollama.com catalog `Enabled` is false on AirGapped and true on Full. MCP over HTTP (`WithAshlarHttpTransport`) fails options validation on SecureWorkstation; stdio (`WithStdioServerTransport`) boots; AirGapped still refuses an enabled server with no HTTP marker. A non-loopback URL (`0.0.0.0`, `+`, `*`, a routable address) fails that validation on AirGapped and SecureWorkstation; `127.0.0.1`, `localhost`, `::1` and an empty list succeed; Full allows `http://+:8080`. The class resets process egress state on every composition. | `AirGappedSecureWorkstationHygieneCertificationTests` | Without it, AirGapped still selects RunPod for an overnight job (the default remote target, base URL `https://api.runpod.io`) and single-image vision still tries OpenAI, and a SecureWorkstation or AirGapped host can listen on every interface. Responses on those listeners are not mediated until PR 5. | +| **AirGapped keeps remote execution local, and AirGapped and SecureWorkstation refuse a non-loopback inbound listener.** Composing `AddAshlar(AirGapped)` makes an overnight job `ExecutionTarget.Local` with the reason `AirGapped: remote execution unavailable; running locally (Overnight/background job forces remote execution.)`, and peer-network-only throws. The same overnight job on Full is still `ExecutionTarget.Remote`. Single-image vision and LLM on AirGapped never call the inner factory with `openai` or `azure` (a fake inner records the names); on Full the vision and LLM lists still contain both. Boot validation (`IOptions.Value`, the `ValidateOnStart` path) refuses, on AirGapped only, `EnablePeerNetworkRouting`, a non-empty `RemoteCatalogBaseUrls`, mesh-lab `Enabled` and Bedrock `Enabled`; the same flags succeed on Full, and a plain AirGapped composition leaves them off. The ollama.com catalog `Enabled` is false on AirGapped and true on Full. MCP over HTTP (`WithAshlarHttpTransport`) fails options validation on SecureWorkstation; stdio (`WithStdioServerTransport`) boots; AirGapped still refuses an enabled server with no HTTP marker. A non-loopback URL (`0.0.0.0`, `+`, `*`, a routable address) fails that validation on AirGapped and SecureWorkstation; `127.0.0.1`, `localhost`, `http://[::1]:5000` and an empty list succeed; Full allows `http://+:8080`. The class resets process egress state on every composition. | `AirGappedSecureWorkstationHygieneCertificationTests` | Without it, AirGapped still selects RunPod for an overnight job (the default remote target, base URL `https://api.runpod.io`) and single-image vision still tries OpenAI, and a SecureWorkstation or AirGapped host can listen on every interface. Responses on those listeners are not mediated until PR 5. | The gate also carries the certification gate's own teeth, the hot-swap host, the adversarial -campaigns, the analyzer fence, sandbox-escape tests and the dogfood suites — 135 `.cs` files in -`src/Ashlar.Tests.Infrastructure/Tests/Certification/` as of 2026-10-06 (138 entries in all; the +campaigns, the analyzer fence, sandbox-escape tests and the dogfood suites — 136 `.cs` files in +`src/Ashlar.Tests.Infrastructure/Tests/Certification/` as of 2026-10-08 (139 entries in all; the other three are golden JSON fixtures). This figure read 31, then 68, then 82, then 111, then 113, -then 120, then 125, then 127, then 128, then 131, then 132, before it read 135; count it rather than quoting it. The +then 120, then 125, then 127, then 128, then 131, then 132, then 135, before it read 136; count it +rather than quoting it. The table above is only the *conventions*: assertions about how the repository is allowed to be shaped, which have no other home and which nothing else would catch. diff --git a/ci/egress-inventory.tsv b/ci/egress-inventory.tsv index 1cca85b0f..1b0071942 100644 --- a/ci/egress-inventory.tsv +++ b/ci/egress-inventory.tsv @@ -6,8 +6,8 @@ # ids: EG-... rows of docs/EgressInventory.md, comma-separated, or -. F1, F2 and F3 print the observed rows in this form when they fail. application/src/Ashlar.API/Endpoints/IdeEndpoints.cs http.new 1 1 Wrapped - EG-MDL-14 IDE tags endpoint: the fallback client when no IHttpClientFactory is registered, built by EgressHttp.CreateClient (:193) application/src/Ashlar.API/Middleware/Ingress/AwsSnsSmsWebhook.cs http.param 1 0 - Factory EG-HTTP-03 SNS confirm/verify helper (:107) is called only with the ashlar-sns-signing factory client (:72) -application/src/Ashlar.API/Program.cs http.register 1 1 Factory - EG-HTTP-03 named client ashlar-sns-signing (:171); AddAshlarEgressGuard follows AddAshlar in the same top-level block (:239) -application/src/Ashlar.API/Program.cs telemetry 2 2 Precedes - EG-TEL-01 OTLP metrics and traces exporters (:252, :259); the EG-TEL-01 guard opens their block (:247), decided once at registration +application/src/Ashlar.API/Program.cs http.register 1 1 Factory - EG-HTTP-03 named client ashlar-sns-signing (:172); AddAshlarEgressGuard follows AddAshlar in the same top-level block (:246) +application/src/Ashlar.API/Program.cs telemetry 2 2 Precedes - EG-TEL-01 OTLP metrics and traces exporters (:266, :273); the EG-TEL-01 guard opens their block (:261), decided once at registration application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshAutoPullService.cs http.new 2 2 Wrapped - EG-MESH-04 static mTLS SocketsHttpHandler (:57), handed to EgressHttp.CreateClient for the pull client (:72) application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshAutoPullService.cs http.param 1 0 - Upstream:application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshAutoPullService.cs EG-MESH-04 PullPeerOnceAsync takes the static EgressHttp pull client; its only production call is in this file (:166) application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshDiscoveryService.cs socket 2 1 Precedes Exempt:Inbound EG-MESH-05 beacon sender (:253) after the EG-MESH-05 guard that opens AnnounceLoopAsync (:249); the listener (:191) is Exempt:Inbound diff --git a/docs/Configuration.md b/docs/Configuration.md index fb8bc7ac4..452bc9208 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -384,7 +384,7 @@ For a **layered breakdown** of mesh capabilities (identity, registry, transport, | Variable | Description | Default | |----------|-------------|---------| | `ASHLAR_MESH_DIR` | Root for this node's published packages and imported peer content. One level off yields an empty peer store with **no error**. | `/mesh` | -| `ASHLAR_MESH_SERVE_BIND` | Optional bind address for mesh serve (`ASHLAR_MESH_SERVE_PORT`). On AirGapped and SecureWorkstation an unset value is any-interface (`0.0.0.0`) and boot fails; `127.0.0.1`, `localhost` and `::1` succeed. Other profiles keep `ListenAnyIP` and do not apply this value. | unset | +| `ASHLAR_MESH_SERVE_BIND` | Optional bind address for mesh serve (`ASHLAR_MESH_SERVE_PORT`). On AirGapped and SecureWorkstation an unset value is any-interface (`0.0.0.0`) and boot fails; `127.0.0.1`, `localhost` and `[::1]` succeed (bare `::1` is refused by URI parsing). Other profiles keep `ListenAnyIP` and do not apply this value. | unset | | `ASHLAR_TAILNET_CMD` | Command invoked to enumerate tailnet peers. See `docs/Federation.md` (F4). | unset (tailnet discovery off) | | `ASHLAR_TAILNET_REFRESH_SECONDS` | How often the tailnet peer list is re-read. | `300` | | `ASHLAR_MESH_PEER_ID` | Mesh peer identifier | random GUID | diff --git a/docs/EgressInventory.md b/docs/EgressInventory.md index e65ccaffb..9c81090c3 100644 --- a/docs/EgressInventory.md +++ b/docs/EgressInventory.md @@ -1,6 +1,6 @@ # Egress inventory -This file lists every path by which a request or data leaves an Ashlar process, as found at master `f1f2cff` (2026-10-04). It is the written inventory for SPEC-007 PR 3. PR 3b re-derived every line number that points into a file it edited, from the tree it merged, and SPEC-007 PRs 4.1, 4.6 and 4.10 did the same for the files they edited; every other file cited here is unchanged since `f1f2cff` (or, for `EgressGuard.cs`, since PR 3a added it), so its line numbers still hold. +This file lists every path by which a request or data leaves an Ashlar process, as found at master `f1f2cff` (2026-10-04). It is the written inventory for SPEC-007 PR 3. PR 3b re-derived every line number that points into a file it edited, from the tree it merged, and SPEC-007 PRs 4.1, 4.3, 4.5, 4.6 and 4.10 did the same for the files they edited; every other file cited here is unchanged since `f1f2cff` (or, for `EgressGuard.cs`, since PR 3a added it), so its line numbers still hold. - **The machine-checked half is `ci/egress-inventory.tsv`.** It pins a count per file and per marker, and `EgressGuardConventionTests` (cert-gate) holds those counts exactly. Every TSV row names IDs from this file. Every `EG-…` id that production code passes to the guard must have a row here. - **What the controls are.** These are classification-style controls inside the runtime. They are not an accredited cross-domain solution. @@ -12,7 +12,7 @@ This file lists every path by which a request or data leaves an Ashlar process, - **The strictest profile noted wins.** Once `AddAshlar` has noted AirGapped nothing replaces it, and once SecureWorkstation, only AirGapped does, so a second `AddAshlar()` with no profile no longer turns an AirGapped process into Full. Tests restore the noted profile and the mode latch through an internal reset seam, `EgressProcessState`, and `ProcessGlobalEnvironmentConventionTests` treats composing AirGapped or SecureWorkstation, raising the mode, and the seam as process-global writes, and pins every file that names the seam or a member that writes this state outside its rules. - **Startup line.** A hosted activator logs one line at Information (`Ashlar.Egress`, event 7302 `EgressMode`) with the mode, its basis, the profile of the guard `AddAshlar` composed and whether that profile was defaulted; an unrecognised override logs at Warning. A later `AddAshlar` on the same collection replaces the line together with the guard, so the line describes the container's guard whenever that guard is one `AddAshlar` composed. When the host's own `IEgressGuard` is the container's guard (registered before `AddAshlar` or after it), that guard decides and the line does not describe it. A process whose mode is not plain `report` also writes the line to standard error, once, so a host-less CLI verb shows it. - **Synchronous `Send` on the netstandard2.0 asset is refused, not evaluated (SPEC-007 PR 4.2).** .NET 5–7 apps resolve the netstandard2.0 asset of `Ashlar.Abstractions`, which is compiled against a `DelegatingHandler` with no `Send` to override, so the guard handler cannot evaluate a synchronous `HttpClient.Send` or `HttpMessageInvoker.Send` there. On that asset `EgressHttp.CreateClient` and `Wrap` put a hop under the guard handler (`SynchronousSendRefusedOnNetstandard20Asset`, an `HttpMessageHandler` that overrides only `SendAsync`), so on a runtime that has a synchronous `Send` (.NET 5 or later) the runtime refuses it with `NotSupportedException`, naming the hop, before anything is sent. No Ashlar code runs on that path, so the refusal is not recorded: the caller gets the exception, and no decision is published, neither for the refused send nor when the client or handler is built. `EgressHttp.CreateDelegatingHandler`, whose inner handler a factory pipeline sets, throws `PlatformNotSupportedException` on such a runtime. Replacing the `InnerHandler` of a `Wrap` handler removes the hop, which the `Wrap` docs forbid. This holds in every mode. `SendAsync` is still evaluated, and a redirect on that path is evaluated again; a synchronous `Send` never reaches the follower. On .NET Framework, classic Mono and Unity, which have no synchronous `Send`, nothing is refused; the only change there is the hop itself, one internal forwarding step under the guard handler, which a walker of `DelegatingHandler.InnerHandler` cannot step through (Known limits, below). No Ashlar source makes a synchronous HTTP `Send`, and every Ashlar host binds the net8.0 or later asset. `docs/SdkCompatibilityPolicy.md` states which asset covers synchronous sends; `EgressHttpNetstandard20TwinTests` (cert-gate) runs the netstandard2.0 build to show it. -- **Redirects are evaluated per hop (SPEC-007 PR 4.3), still report-only.** `AllowAutoRedirect` reads false on every `HttpClientHandler` or `SocketsHttpHandler` Ashlar builds or binds. Ashlar's internal follower then applies that primary's own previous settings. An `EgressHttp` client (P2) follows a same-host redirect and records another decision, and returns a cross-host 3xx without requesting the second host. A factory client (P1) follows a cross-host redirect and re-evaluates each hop. HTTPS to HTTP, and any scheme other than http or https, is not followed. `Authorization` is cleared on a followed redirect. `EgressDecision.Refused` stays false. A primary type Ashlar cannot flip is not wrapped; if the response URI's authority differs from the one evaluated before the send, the guard evaluates it afterwards and logs that the body may already have gone. The SNS signing clients set `AllowAutoRedirect` false on the existing primary (`application/src/Ashlar.API/Program.cs:171`, `commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs:79`) and do not follow. The Bedrock runtime client sets `AmazonBedrockRuntimeConfig.AllowAutoRedirect` false (`AwsBedrockChatClientFactory.cs:40-41`); the SDK owns that HTTP stack. `MeshAutoPullService.cs:57` and, since PR 4.1, `OllamaHttpChatClient.cs:170` already refused redirects and are unchanged. On the netstandard2.0 asset the synchronous-send hop still throws before the follower runs. +- **Redirects are evaluated per hop (SPEC-007 PR 4.3), still report-only.** `AllowAutoRedirect` reads false on every `HttpClientHandler` or `SocketsHttpHandler` Ashlar builds or binds. Ashlar's internal follower then applies that primary's own previous settings. An `EgressHttp` client (P2) follows a same-host redirect and records another decision, and returns a cross-host 3xx without requesting the second host. A factory client (P1) follows a cross-host redirect and re-evaluates each hop. HTTPS to HTTP, and any scheme other than http or https, is not followed. `Authorization` is cleared on a followed redirect. `EgressDecision.Refused` stays false in report mode (under `ASHLAR_EGRESS_MODE=enforce` a refused hop records `Refused=true` and is still sent until PR 4.7). A primary type Ashlar cannot flip is not wrapped; if the response URI's authority differs from the one evaluated before the send, the guard evaluates it afterwards and logs that the body may already have gone. The SNS signing clients set `AllowAutoRedirect` false on the existing primary (`application/src/Ashlar.API/Program.cs:172`, `commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs:79`) and do not follow. The Bedrock runtime client sets `AmazonBedrockRuntimeConfig.AllowAutoRedirect` false (`AwsBedrockChatClientFactory.cs:40-41`); the SDK owns that HTTP stack. `MeshAutoPullService.cs:57` and, since PR 4.1, `OllamaHttpChatClient.cs:170` already refused redirects and are unchanged. On the netstandard2.0 asset the synchronous-send hop still throws before the follower runs. **Known limit until PR 4.3b:** owner O2 (never follow into the Host boundary from outside) is not yet implemented; P1 may still follow into loopback / `*.localhost` / link-local with the request body. - **Known limits of the routed records.** These are records, not refusals, and none of them changes a request: - **Bedrock's host is reconstructed** (EG-MDL-02) from the configured region as `https://bedrock-runtime..amazonaws.com`, unless the inner client reports an absolute `ChatClientMetadata.ProviderUri`, which since PR 4.1 is recorded instead. The AWS MEAI client (`AWSSDK.Extensions.Bedrock.MEAI` 4.0.101.8) reports none, so the default record is the reconstruction; a cert-gate twin pins that. The AWS SDK's own endpoint resolution is not read: `AWS_ENDPOINT_URL`, `AWS_ENDPOINT_URL_BEDROCK_RUNTIME` and a profile `endpoint_url`, the FIPS and dual-stack flags, and partitions other than `aws` (`cn-*` is `amazonaws.com.cn`). So the recorded host can differ from the one the SDK dials. The record still fails closed: model family, `Internal`, whatever the host. - **MEAI guard faults are not recorded.** On the MEAI path a host `IEgressGuard` that throws drops that call's decision, and a guard that cannot be resolved falls back to `EgressGuard.ProcessDefault`; neither fault is counted or logged, where the HTTP handler counts `GuardFaults`. The chat layer also does not activate the Debug logger subscription itself (`Ashlar.AI.Pipeline` cannot reference `Ashlar.Infrastructure`), so its decisions reach the Debug log only once a factory client or the hosted activator has subscribed the logger. The EventSource gets every decision either way. @@ -25,7 +25,7 @@ This file lists every path by which a request or data leaves an Ashlar process, **Producers (SPEC-007 PR 4.5, report-only).** Nothing here refuses, and no request changes. The only production frame is `SelfExtendRunnerAdapter.RunAsync` (`src/Ashlar.BackgroundAgents.HostRunners/SelfExtendRunnerAdapter.cs`), which enters `agent:` at `SystemHigh` around the cycle and the post-cycle admission and auto-share. The floor is `SystemHigh` because the snapshot carries unlabelled carry-over. Records for that cycle go from `no-subject` to `subject:agent:` at `SystemHigh`. The `using` is on the flow that enters it, disposed in order, and it does not span a `yield return`. Overloads that only await `RunAsync` do not enter. `ToolCallingAgent` begins a `ReadScope` around each tool invocation and calls `Complete` only when the tool returns. A tool may `Report` only when it implements `IEgressLabelledTool` and the toolbox is a `CapabilityRegistry` that can find it; otherwise the report is ignored and the unreported scope is `SystemHigh`. A tool that throws, a refusal that throws included, is not completed, so dispose observes `SystemHigh`. In #721 the only labelled tool is `RAGTool`. It reports each hit: trim the sensitivity name, resolve it with `GetByName`, and map it with `ToDataLabel` only when the level is one of `DataSensitivityLevels.All`. Canonical names are the five primitives plus `top-secret`, any case, trimmed. A custom level, an unknown name, or an unmarked hit is `SystemHigh`. Zero hits, and the unrankable-query refusal (a returned result, not a throw), report `Public` ("read nothing"). A model response is not a read. A response from a chat target whose key starts with `peer:` is another agent's output and is observed as `SystemHigh` after the response returns, until PR 5. `local:` and `cloud:` are not. No new egress site is added, so `ci/egress-inventory.tsv` is unchanged. -- **Within-call egress (known limit, PR 4.5).** A `BeginRead` scope observes only when it ends, which is the rule PR 4.4 shipped. A tool that reads and then egresses inside the same call, before the scope ends, is decided at the pre-read mark. This matters for a runner whose floor is below `SystemHigh`. The production self-extend floor is `SystemHigh`, so the limit does not declassify that runner. The owner can reverse it. Making an open scope count as `SystemHigh` would turn the leak scenario from `LevelTooLow` into `SystemHighData`: a mark only rises, and a later Secret report could not lower a mark the open scope had already raised. +- **Within-call egress (owner decision 2026-10-06; not yet implemented — PR 4.5b).** The owner decided that while a read scope is open and unreported, every egress on the flows inside it is decided at `SystemHigh` (Scenario B → `SystemHighData`). Master still observes only when the scope ends (PR 4.4 rule left in place by #721); a cert-gate twin pins the pre-read mark. The production self-extend floor is already `SystemHigh`, so today's exposure is zero bytes until a runner declares a lower floor. **Profiles.** @@ -47,7 +47,7 @@ Module flags and the profiles that include them (`src/Ashlar.Hosting/AshlarServi | RuntimeTransport, TestingAdapters | F, S | | Persistence | F, S, E, AG, SW | -`AshlarDeploymentProfileEnvironment.ForbidsRemoteProtocolEgress` (`src/Ashlar.Abstractions/AshlarDeploymentProfileEnvironment.cs:81`) is used by the MCP-client, A2A-client and A2A-server option validators. SPEC-007 PR 4.10 adds further profile checks, fed by `AshlarResolvedDeploymentProfileOptions` (Hosting registers it from the resolved profile; Infrastructure does not read the environment): on AirGapped, remote routing reasons run locally and peer-network-only is refused, `AdaptiveProviderFactory` does not escalate to OpenAI or Azure, boot refuses a remote brick catalog, peer-network routing, the mesh-lab worker and Bedrock, and the ollama.com catalog defaults off when `Enabled` is unset. On SecureWorkstation, MCP over HTTP fails boot and stdio stays. On both profiles an API listener or mesh serve that is not loopback fails boot. Responses on inbound connections are not mediated until PR 5's CanRead at the server seams. From 3a, `EgressGuard` also applies its test, report-only: when the guard reads the profile from the environment, it reads the same effective value, and `DescribeProfile` (`src/Ashlar.Abstractions/Security/Egress/EgressGuard.cs:212`) sets `EgressDecision.ProfileEnforcesByDefault` by the same AirGapped-or-SecureWorkstation test. Since PR 4.6 the guard reads that value once per decision and resolves the mode from it, so it no longer calls `ForbidsRemoteProtocolEgress` itself. The guard gates no path below and still refuses nothing. +`AshlarDeploymentProfileEnvironment.ForbidsRemoteProtocolEgress` (`src/Ashlar.Abstractions/AshlarDeploymentProfileEnvironment.cs:81`) is used by the MCP-client, A2A-client and A2A-server option validators. SPEC-007 PR 4.10 adds further profile checks, fed by `AshlarResolvedDeploymentProfileOptions` (Hosting registers it from the resolved profile; Infrastructure does not read `ASHLAR_DEPLOYMENT_PROFILE` / `Effective` — a convention since `Ashlar.Abstractions` grants `InternalsVisibleTo` Infrastructure, not yet a cert-gate fact): on AirGapped, remote routing reasons run locally and peer-network-only is refused, `AdaptiveProviderFactory` does not escalate to OpenAI or Azure for LLM or single-image vision, boot refuses a remote brick catalog, peer-network routing, the mesh-lab worker and Bedrock, and the ollama.com catalog defaults off when `Enabled` is unset. On SecureWorkstation, MCP over HTTP fails boot and stdio stays. On both profiles an API listener or mesh serve that is not loopback fails boot (`127.0.0.1`, `localhost`, `[::1]`; bare `::1` refused). Known limits until PR 4.10b: `ASPNETCORE_HTTP_PORTS` is not collected when `urls` is unset; multi-frame vision may still send to a cloud resolve on AirGapped. Responses on inbound connections are not mediated until PR 5's CanRead at the server seams. From 3a, `EgressGuard` also applies its test, report-only: when the guard reads the profile from the environment, it reads the same effective value, and `DescribeProfile` (`src/Ashlar.Abstractions/Security/Egress/EgressGuard.cs:214`) sets `EgressDecision.ProfileEnforcesByDefault` by the same AirGapped-or-SecureWorkstation test. Since PR 4.6 the guard reads that value once per decision and resolves the mode from it, so it no longer calls `ForbidsRemoteProtocolEgress` itself. The guard gates no path below and still refuses nothing. **Route vocabulary** @@ -137,9 +137,9 @@ No remote embedding generator and no MCP sampling call exist. | ID | Where | Leaves via | Destination | Payload | Gates today | Label at the site | Route | |---|---|---|---|---|---|---|---| -| EG-MESH-01 | `src/Ashlar.Manifest/Packaging/MeshStore.cs:62-63`. Callers: `application/src/Ashlar.CLI/Commands/PkgCommand.cs:390` (publish) and `:445`/`:461` (share); `src/Ashlar.BackgroundAgents.HostRunners/SelfExtendAdmissionBridge.cs:289-290` (auto-share) | File write and atomic rename. A directory sync outside the process is the transport. | `--store`, else `$ASHLAR_MESH_DIR/published`, else `~/.ashlar/mesh/published` | A sealed `.ashpkg`: the gate record with its diff and course detail, the signer and sealer public keys, and the full source of every packaged file | `TryOpen` checks integrity only. Auto-share needs `ASHLAR_MESH_AUTOSHARE=1` and a signed record (`:247`, `:255`), and also runs inside Ashlar.API (`Program.cs:221`). No profile or label check. | None. `GateRecord`, `ExtensionProposal` and `PackageFile` have no label field. | Explicit, at the three callers, before `Pack` or `Publish`, with `file:` and the store directory each one resolves; auto-share records the mesh directory it was given, which `MeshStore.Resolve` returns as is. `Ashlar.Manifest` references no Ashlar project. | +| EG-MESH-01 | `src/Ashlar.Manifest/Packaging/MeshStore.cs:62-63`. Callers: `application/src/Ashlar.CLI/Commands/PkgCommand.cs:390` (publish) and `:445`/`:461` (share); `src/Ashlar.BackgroundAgents.HostRunners/SelfExtendAdmissionBridge.cs:289-290` (auto-share) | File write and atomic rename. A directory sync outside the process is the transport. | `--store`, else `$ASHLAR_MESH_DIR/published`, else `~/.ashlar/mesh/published` | A sealed `.ashpkg`: the gate record with its diff and course detail, the signer and sealer public keys, and the full source of every packaged file | `TryOpen` checks integrity only. Auto-share needs `ASHLAR_MESH_AUTOSHARE=1` and a signed record (`:247`, `:255`), and also runs inside Ashlar.API (`Program.cs:228-235`). No profile or label check. | None. `GateRecord`, `ExtensionProposal` and `PackageFile` have no label field. | Explicit, at the three callers, before `Pack` or `Publish`, with `file:` and the store directory each one resolves; auto-share records the mesh directory it was given, which `MeshStore.Resolve` returns as is. `Ashlar.Manifest` references no Ashlar project. | | EG-MESH-02 | `PkgCommand.cs:94-95` (`Pack`, then `File.WriteAllTextAsync` to `--out`) | Local file write to any path | `--out`: removable media, a synced folder | As EG-MESH-01 | Needs `ashlar.yaml`, an operator key, and applied forge rows that match the signed claims (`:67-157`) | None | Explicit, before `Pack` (`:93`), with the `--out` file | -| EG-MESH-03 | `application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshServeService.cs:316` (`ListenAnyIP`, profiles that do not require loopback); `ListenLoopback` `:261` called at `:314` when the profile does; refusal before listen `:157-166`; hello `:321-333`, index `:335`, pkg `:357-383` (`Results.Stream` `:382`) | Kestrel. All interfaces when the profile does not require loopback. On AirGapped and SecureWorkstation a non-loopback bind, including an unset bind (`0.0.0.0`), throws before listen. Plaintext unless `ASHLAR_MESH_SERVE_TLS_CERT` and `_KEY` are set. | Any client that reaches the port. mTLS is optional, and the client certificate is never read. | Package bytes up to 4 MiB. Hello and index return node name, fingerprint and the package list. | `ASHLAR_MESH_SERVE_PORT` (`BackgroundAgentDaemonCommand.cs:417`). On AirGapped and SecureWorkstation `ASHLAR_MESH_SERVE_BIND` (`:432`) must be loopback (`127.0.0.1`, `localhost` or `::1`) or boot fails; other profiles still `ListenAnyIP` and do not apply the bind. Seals are not re-verified at serve time. Responses are not mediated until PR 5. | Only the optional mTLS client certificate, which is unread | Explicit, in the pkg handler before `Results.Stream` (`MeshServeService.cs:379`); the destination is `mesh-peer:` (`PeerDestination` `:397`: an IPv4-mapped address as IPv4, `mesh-peer:unknown` when there is none), which is never read as a URL, so it is a network export whatever the IP, loopback included (PR 4.1: a local proxy or tunnel delivers remote pullers as loopback). Hello and index are listed as metadata responses and not routed in PR 3. | +| EG-MESH-03 | `application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshServeService.cs:316` (`ListenAnyIP`, profiles that do not require loopback); `ListenLoopback` `:261` called at `:314` when the profile does; refusal before listen `:157-166`; hello `:321-333`, index `:335`, pkg `:357-383` (`Results.Stream` `:382`) | Kestrel. All interfaces when the profile does not require loopback. On AirGapped and SecureWorkstation a non-loopback bind, including an unset bind (`0.0.0.0`), throws before listen. Plaintext unless `ASHLAR_MESH_SERVE_TLS_CERT` and `_KEY` are set. | Any client that reaches the port. mTLS is optional, and the client certificate is never read. | Package bytes up to 4 MiB. Hello and index return node name, fingerprint and the package list. | `ASHLAR_MESH_SERVE_PORT` (`BackgroundAgentDaemonCommand.cs:417`). On AirGapped and SecureWorkstation `ASHLAR_MESH_SERVE_BIND` (`:432`) must be loopback (`127.0.0.1`, `localhost` or `[::1]`; bare `::1` is refused by URI parsing) or boot fails; other profiles still `ListenAnyIP` and do not apply the bind. Seals are not re-verified at serve time. Responses are not mediated until PR 5. | Only the optional mTLS client certificate, which is unread | Explicit, in the pkg handler before `Results.Stream` (`MeshServeService.cs:379`); the destination is `mesh-peer:` (`PeerDestination` `:397`: an IPv4-mapped address as IPv4, `mesh-peer:unknown` when there is none), which is never read as a URL, so it is a network export whatever the IP, loopback included (PR 4.1: a local proxy or tunnel delivers remote pullers as loopback). Hello and index are listed as metadata responses and not routed in PR 3. | | EG-MESH-04 | `application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshAutoPullService.cs:269`, `:310`; static client `:53-75` (`SocketsHttpHandler` plus mTLS) | `HttpClient.GetAsync` | `ASHLAR_MESH_PEERS`, LAN-discovered peers and tailnet peers (at most 16 per tick) | Outbound: request line, package file names and the client certificate. Packages flow inbound, which is the read side. | Env opt-in (`BackgroundAgentDaemonCommand.cs:331-346`) | n/a | Handler(raw): the handler is passed to `EgressHttp.CreateClient` | | EG-MESH-05 | `application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshDiscoveryService.cs:267`; sender `:253`, listener `:191` | `UdpClient.SendAsync`, multicast every 15 s | `239.7.42.1:7421` | Node name, operator key fingerprint, serve port (`MeshBeacon.Encode` `:53-54`) | `ASHLAR_MESH_DISCOVERY=1` (`BackgroundAgentDaemonCommand.cs:387-392`) | n/a | Explicit, decided once when `AnnounceLoopAsync` starts, before the sender is built (`MeshDiscoveryService.cs:249`): `udp://239.7.42.1:` when the node announces, `host:listen-only` when it only listens. The listener is `Exempt:Inbound`. | | EG-MESH-06 | `application/src/Ashlar.CLI/Commands/BackgroundAgent/TailnetPeerSource.cs:164-176` | `Process.Start` of `tailscale status --json` (local IPC) | Local `tailscaled` | Nothing leaves. Its peer list feeds EG-MESH-04. | `ASHLAR_MESH_TAILNET=1` | n/a | `Exempt:LocalOnly` | @@ -157,7 +157,7 @@ No remote embedding generator and no MCP sampling call exist. | ID | Where | Leaves via | Destination | Payload | Gates today | Label at the site | Route | |---|---|---|---|---|---|---|---| -| EG-TEL-01 | `application/src/Ashlar.API/Program.cs:259`, `:266` (`AddOtlpExporter`) | OpenTelemetry OTLP exporter, batch export owned by the SDK | `OTEL_EXPORTER_OTLP_ENDPOINT` | Ashlar metrics, ASP.NET Core and HttpClient metrics. Traces include a client span for every outbound HttpClient call, which leaks the map of all other egress. | Only the variable being set (`:249-251`). No profile check. | n/a | Explicit, decided once at registration (`:254`), with the endpoint by name. That is before `builder.Build()`, so the decision reaches the `Ashlar-Egress` EventSource only, not the Debug log. | +| EG-TEL-01 | `application/src/Ashlar.API/Program.cs:266`, `:273` (`AddOtlpExporter`) | OpenTelemetry OTLP exporter, batch export owned by the SDK | `OTEL_EXPORTER_OTLP_ENDPOINT` | Ashlar metrics, ASP.NET Core and HttpClient metrics. Traces include a client span for every outbound HttpClient call, which leaks the map of all other egress. | Only the variable being set (`:256-257`). No profile check. | n/a | Explicit, decided once at registration (`:261`), with the endpoint by name. That is before `builder.Build()`, so the decision reaches the `Ashlar-Egress` EventSource only, not the Debug log. | | EG-TEL-02 | `src/Ashlar.Hosting/Sdk/Extensions/OpenTelemetryServiceCollectionExtensions.cs:20-29` (`AddAshlarOpenTelemetry`) | Whatever exporter an SDK host attaches | Configured by the host | As EG-TEL-01 | None | n/a | `Unscanned:HostConfigured` | ## 9. Child processes diff --git a/docs/Federation.md b/docs/Federation.md index 6036c8a35..06e081d2f 100644 --- a/docs/Federation.md +++ b/docs/Federation.md @@ -47,7 +47,7 @@ ASHLAR_MESH_SERVE_PORT=7420 ASHLAR_NODE_NAME=study-node # optional; defaults to the machine name ``` -On AirGapped and SecureWorkstation the serve address must be loopback or the daemon fails to boot. Set `ASHLAR_MESH_SERVE_BIND` to `127.0.0.1`, `localhost`, or `::1`. Leaving it unset is any-interface (`0.0.0.0`) and boot fails on those profiles. Other profiles keep `ListenAnyIP` and do not apply the bind. +On AirGapped and SecureWorkstation the serve address must be loopback or the daemon fails to boot. Set `ASHLAR_MESH_SERVE_BIND` to `127.0.0.1`, `localhost`, or `[::1]` (bare `::1` is refused by URI parsing). Leaving it unset is any-interface (`0.0.0.0`) and boot fails on those profiles. Other profiles keep `ListenAnyIP` and do not apply the bind. Three read-only endpoints: diff --git a/docs/specs/SPEC-007-security-labels-and-reference-monitor.md b/docs/specs/SPEC-007-security-labels-and-reference-monitor.md index 8b9a49586..967f65f09 100644 --- a/docs/specs/SPEC-007-security-labels-and-reference-monitor.md +++ b/docs/specs/SPEC-007-security-labels-and-reference-monitor.md @@ -8,7 +8,7 @@ in §8 rather than guessing. Master at the time of writing: `0960774` (#704 and #705 merged). -## Status (2026-10-05) +## Status (2026-10-08) *Added when the spec was committed. Everything else is the owner's text of 2026-10-03, with these additions, each marked: test names next to the §2 MUSTs (labelled **Enforced by**, as §7 asks), a status column in §5, the sections @@ -46,7 +46,8 @@ The status line above and the starting prompt are the owner's, as written; the s `Ashlar.Abstractions` cannot evaluate a synchronous `Send`, so on .NET 5 to 7 a synchronous `Send` went out unevaluated. PR 4.2 (below) closes that gap: such a `Send` is refused before anything is sent, with no record (the owner's 2026-10-06 amendment of D31). PR 3b found two more (`docs/EgressInventory.md`): redirects that the - primary handler follows are not evaluated, which stays open until 4.3, and a few records could read Host for a + primary handler follows are not evaluated (closed by PR 4.3, #722, `904cf909a`, with the host-boundary exception + still outstanding — owner O2, decisions log, tracked for PR 4.3b), and a few records could read Host for a remote peer (EG-MESH-03 behind a local proxy or tunnel, EG-MDL-01 with a custom `local:` inner client, EG-MESH-07/08 with a `//127.0.0.1/…` path). PR 4.1 closes the third gap: those records no longer read Host. - **PR 4 plan** (2026-10-05). A design pass found that no production code enters an `EgressSubject` frame. Turning @@ -97,11 +98,14 @@ The status line above and the starting prompt are the owner's, as written; the s or later. The refusal publishes no decision record (the owner's 2026-10-06 amendment of the design's D31, decisions log): no Ashlar code runs on that path, short of a process-wide first-chance-exception hook, and a record published when a client is built would report a refused egress where none happened. -- **PR 4.3** (#722): known primaries Ashlar builds or binds have `AllowAutoRedirect` false, and an internal - follower applies that primary's own redirect settings. An `EgressHttp` client follows only a same-host redirect - (P2); a factory client follows a cross-host redirect and each hop is evaluated again (P1). HTTPS to HTTP is not - followed. `Authorization` is cleared on a followed redirect. Report-only: `Refused` stays false. On the - netstandard2.0 asset the synchronous-send hop is unchanged and still throws before the follower runs. +- **PR 4.3** (#722, `904cf909a`): known primaries Ashlar builds or binds have `AllowAutoRedirect` false, and an + internal follower applies that primary's own redirect settings. An `EgressHttp` client follows only a same-host + redirect (P2); a factory client follows a cross-host redirect and each hop is evaluated again (P1). HTTPS to HTTP + is not followed. `Authorization` is cleared on a followed redirect. Report-only: `Refused` stays false in report + mode. On the netstandard2.0 asset the synchronous-send hop is unchanged and still throws before the follower runs. + **Known limit until PR 4.3b:** owner decision O2 (2026-10-06) — never follow a redirect from outside the host + boundary into it — is not yet implemented; a factory client may still follow into loopback / `*.localhost` / + link-local (see decisions log). - **PR 4.6** (#718, `3196ba1`), mode plumbing: one resolver gives every decision a mode (`report` or `enforce`), its basis, `Refused` and a random `Ref`. `enforce` is an opt-in on every profile through `ASHLAR_EGRESS_MODE` (read once per process), `AshlarHostingOptions.EgressMode` (raise-only) or an explicit guard's constructor, and every profile @@ -122,36 +126,38 @@ The status line above and the starting prompt are the owner's, as written; the s inside the callback, such as each subscriber's `Task.Run`, keeps no subject; a task, timer, registration or continuation the publisher created and the callback starts or triggers keeps the publisher's frame, since each captures the flow where it is created, and a thread or `System.Timers.Timer` captures it where it is started. - Report-only, and no production code enters a frame yet. **Amended PR 4 design:** its - `InternalsVisibleTo` list for `Ashlar.Abstractions` (decision D9 in `_handoff/spec-007-pr4/DESIGN-4-final.md` on + Report-only. Until PR 4.5 no production code entered a frame; the producers are in PR 4.5. **Amended PR 4 design:** + its `InternalsVisibleTo` list for `Ashlar.Abstractions` (decision D9 in `_handoff/spec-007-pr4/DESIGN-4-final.md` on the `claude/spec-007-pr4-workspace` branch: `Ashlar.AI.Pipeline` and `Ashlar.Infrastructure`) gains `Ashlar.Orchestration`, for the internal `EgressSubject.RunDetached` at the `AgentBus` dispatch point. The grant exposes every Abstractions internal to Orchestration; 4.6's convention fact (`ProcessGlobalEnvironmentConventionTests.Only_AddAshlar_and_the_reset_seam_reach_the_process_egress_state`) reads every source file, Orchestration's included, so no new caller of the reset seam or the mode latch setters appears unlisted. -- **PR 4.5** (#721), still report-only. `SelfExtendRunnerAdapter.RunAsync` enters `agent:` at `SystemHigh` - around the cycle and the post-cycle admission and auto-share, because the snapshot carries unlabelled carry-over. - Records go from `no-subject` to `subject:agent:` at `SystemHigh`. Nothing refuses. `ToolCallingAgent` begins a - read around each tool call and accepts `Report` only from `IEgressLabelledTool`; in #721 the only labelled tool - is `RAGTool`, which reports each hit's canonical label and `Public` when it read nothing. An unlabelled tool, or a - tool that throws, is `SystemHigh`. A response from a `peer:` chat target is observed as `SystemHigh` after it - returns; a model endpoint is not a read. An open read scope still observes only when it ends (the merged 4.4 rule): - a tool that egresses before the scope ends is decided at the pre-read mark. That is a known limit for a runner whose - floor is below `SystemHigh`. The owner can reverse it. Reversing it, so an open scope counted as `SystemHigh`, - would turn the leak's `LevelTooLow` into `SystemHighData`, because a mark only rises. -- **PR 4.10** (#720) AirGapped and SecureWorkstation hygiene. The profile reaches Infrastructure through +- **PR 4.5** (#721, `9b2ea56f4`), still report-only. `SelfExtendRunnerAdapter.RunAsync` enters `agent:` at + `SystemHigh` around the cycle and the post-cycle admission and auto-share, because the snapshot carries unlabelled + carry-over. Records go from `no-subject` to `subject:agent:` at `SystemHigh`. Nothing refuses. + `ToolCallingAgent` begins a read around each tool call and accepts `Report` only from `IEgressLabelledTool`; in + #721 the only labelled tool is `RAGTool`, which reports each hit's canonical label and `Public` when it read + nothing. An unlabelled tool, or a tool that throws, is `SystemHigh`. A response from a `peer:` chat target is + observed as `SystemHigh` after it returns; a model endpoint is not a read. **Owner decision 2026-10-06 (not yet + implemented on master; tracked in PR 4.5b):** while a read scope is open and unreported, every egress decided on + the flows inside it is decided at `SystemHigh` (Scenario B's expected reason becomes `SystemHighData`). Master + still observes only when the scope ends (the merged 4.4 rule); a cert-gate twin still pins the pre-read mark. +- **PR 4.10** (#720, `02fa27f1d`) AirGapped and SecureWorkstation hygiene. The profile reaches Infrastructure through `AshlarResolvedDeploymentProfileOptions`, which `AddAshlar` registers from the resolved profile. On AirGapped, every remote routing reason runs locally (`AirGapped: remote execution unavailable; running locally ()`); an explicit peer-network-only request fails with an explained error and is not sent to a peer or to RunPod. - `AdaptiveProviderFactory` does not try OpenAI or Azure on AirGapped, for LLM calls or single-image vision. - Boot refuses, on AirGapped only, a non-empty `BrickHost:RemoteCatalogBaseUrls`, - `Ashlar:RunPod:EnablePeerNetworkRouting=true`, `Ashlar:MeshLab:WorkerExecutor:Enabled=true` and - `Ashlar:Meai:Bedrock:Enabled=true`. The ollama.com catalog defaults off on AirGapped when `Enabled` is unset. - SecureWorkstation refuses MCP over HTTP and still boots stdio; AirGapped still refuses an enabled MCP server - whether or not the transport is HTTP. On both profiles an API listener or mesh serve that does not bind - loopback fails boot (`127.0.0.1`, `localhost` and `::1` succeed). Responses on inbound connections are not - mediated until PR 5's CanRead at the server seams. + `AdaptiveProviderFactory` does not try OpenAI or Azure on AirGapped, for LLM calls or single-image vision + (multi-frame vision may still send to a cloud resolve — Known limit until PR 4.10b). Boot refuses, on AirGapped + only, a non-empty `BrickHost:RemoteCatalogBaseUrls`, `Ashlar:RunPod:EnablePeerNetworkRouting=true`, + `Ashlar:MeshLab:WorkerExecutor:Enabled=true` and `Ashlar:Meai:Bedrock:Enabled=true`. The ollama.com catalog + defaults off on AirGapped when `Enabled` is unset. SecureWorkstation refuses MCP over HTTP and still boots stdio; + AirGapped still refuses an enabled MCP server whether or not the transport is HTTP. On both profiles an API + listener or mesh serve that does not bind loopback fails boot (`127.0.0.1`, `localhost` and `[::1]` succeed; + bare `::1` is refused by URI parsing — use the bracketed form). `ASPNETCORE_HTTP_PORTS` is not yet collected when + `urls` is unset (Known limit until PR 4.10b). Responses on inbound connections are not mediated until PR 5's + CanRead at the server seams. --- @@ -430,8 +436,9 @@ vision, split the cert-gate tests into their own project, and move the commercia ## Decisions log (added 2026-10-04) The owner's decisions only, each with the PR it applied to: the ones #707, #709 and 3b list under "Owner decisions -applied", the eight PR 4 answers of 2026-10-05, and the PR 4.2 answer of 2026-10-06. None of them answers a §8 question; §8 stands as written. Design choices that merged with those PRs but -were not the owner's are in the next section. +applied", the eight PR 4 answers of 2026-10-05, and the PR 4.2 and phase C answers of 2026-10-06. None of them +answers a §8 question; §8 stands as written. Design choices that merged with those PRs but were not the owner's are +in the next section. | Date | Applies to | Decision | |---|---|---| @@ -450,6 +457,9 @@ were not the owner's are in the next section. | 2026-10-05 | PR 4 (Q7, open question D) | **A refusal names its category and nothing about the data's label.** The refused subject (the model, agent memory, the exception message) gets the reason category, site, family, destination class and a random reference. Operators get the full `Detail` and the sequence number. Remote parties get a fixed text and the reference. | | 2026-10-05 | PR 4 (Q8, open question C) | **v1 labels carry the level only.** The four sensitivity flags are not caveats, and PR 4 maps only the five canonical level names. The first producer that labels data from a custom `IDataSensitivityLevel` applies a fail-closed normalisation: the lowest built-in level whose flags are no more permissive. `ORCON` and REL TO stay out of scope, and §8 Q1 stays open. | | 2026-10-06 | PR 4 (4.2) | **A synchronous `Send` refused on the `netstandard2.0` asset leaves no decision record; the exception is the only signal.** This amends the PR 4 design's default D31, which wanted the hop to publish a `NoDecision` record so the refusal reaches the operator log. No Ashlar code runs when the runtime refuses that `Send`, so a record could only be published when a client is built, which would claim a refused egress where none happened, or from a process-wide `AppDomain.FirstChanceException` hook, which runs on every exception in the host. The `NotSupportedException` reaches the caller, whose own error handling logs it. The accepted cost: under `AirGapped` or `SecureWorkstation` enforcement, such a refusal never appears in Ashlar's egress log. Ashlar's own hosts bind `net8.0` or `net10.0` and are unaffected; only an app that binds the `netstandard2.0` asset on .NET 5 or later is. | +| 2026-10-06 | PR 4 (4.5) | **While a read scope is open and unreported, every egress decided on the flows inside it is decided at `SystemHigh`, for every tool, `RAGTool` included (no labelled-tool exemption).** This answers the read-scope question the PR 4.4 phase carried to PR 4.5: a scope observes its result only when it ends, so without this a tool that reads and then egresses within one call was decided at the mark from before the read. Once the scope ends, the frames it was begun in hold what it observed: the reported label if it completed and reported, else `SystemHigh`, as PR 4.4 already does. The accepted cost: under a runner floor below `SystemHigh`, a tool's own egress during its call is refused once the guard enforces. In the PR 4 design's §5 leak test, Scenario B's expected reason changes from `LevelTooLow` to `SystemHighData`; control C4's inner send during a tool call is decided at `SystemHigh` too. Scenario B and control C5 then share a reason and differ by site and family, which the leak test must assert. **Implemented in:** not yet on master (PR #721 left the 4.4 observe-on-dispose rule and pinned it); tracked in PR 4.5b. | +| 2026-10-06 | PR 4 (Q8 clarified) | **In PR 4, RAG maps a custom `IDataSensitivityLevel` to `SystemHigh` (D15 stands); Q8's C3 normalisation ("the lowest built-in level whose flags are no more permissive") is deferred** to the first producer that labels data from a custom level on purpose. **Implemented in:** #721 (`RAGTool.MapHitLabel`: non-canonical levels → `SystemHigh`). | +| 2026-10-06 | PR 4 (4.3, O2) | **A redirect hop from outside the host boundary into it is never followed.** When the redirect follower holds a 3xx whose `Location` names the host boundary (loopback, `localhost` and `*.localhost`, `unix`, `npipe`, as a decision classifies Host, plus a link-local address) and the request came from an authority outside it, the hop is not followed, for factory (P1) clients as much as for `EgressHttp` (P2): the hop is decided and the 3xx is returned. Reason: a remote peer must not bounce a request, body included, to a local service. Cost: a host client behind a remote that legitimately redirects to a local service gets the 3xx. The rest of P1 stands. **Implemented in:** not yet on master (#722 follows cross-host without this exception); tracked in PR 4.3b. | ## Design as merged (added 2026-10-04) diff --git a/src/Ashlar.Infrastructure/AshlarInboundListenerPolicy.cs b/src/Ashlar.Infrastructure/AshlarInboundListenerPolicy.cs index 9d20d0293..ef40f5956 100644 --- a/src/Ashlar.Infrastructure/AshlarInboundListenerPolicy.cs +++ b/src/Ashlar.Infrastructure/AshlarInboundListenerPolicy.cs @@ -21,8 +21,8 @@ public sealed class AshlarInboundListenerOptions public static class AshlarInboundListenerPolicy { /// - /// True for localhost, 127.0.0.1 and ::1 (and other addresses). - /// 0.0.0.0, +, * and any other host are not loopback. A value with no scheme is read as HTTP. + /// True for localhost, 127.0.0.1 and [::1] (and other addresses). + /// Bare ::1 is not accepted (URI parsing requires brackets). 0.0.0.0, +, * and any other host are not loopback. A value with no scheme is read as HTTP. /// public static bool IsLoopbackEndpoint(string endpoint) { diff --git a/tests/scripts/readiness-push-no-cursor-branches.test.sh b/tests/scripts/readiness-push-no-cursor-branches.test.sh index 9a757710c..af515103f 100755 --- a/tests/scripts/readiness-push-no-cursor-branches.test.sh +++ b/tests/scripts/readiness-push-no-cursor-branches.test.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Gate: Full Platform Readiness must not push-trigger on cursor/**. +# Gate: Full Platform Readiness push.branches is exactly [master, main]. # # Branch protection only needs the PR `Readiness summary` check. A second heavy matrix on every -# agent tip push doubled runner load. This pins the workflow's push.branches to master/main only. +# agent tip push doubled runner load. The list must be master/main only — not merely +# "cursor/** absent" (a claude/** addition would otherwise pass). set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -11,19 +12,27 @@ WF="${ROOT}/.github/workflows/full-platform-readiness-gate.yml" fail() { echo "FAIL: $*" >&2; exit 1; } [[ -f "${WF}" ]] || fail "missing ${WF}" -# Extract the push: branches: [...] line (first occurrence under on.push). +# Stay inside on.push. Without the event boundary, a missing push filter could +# accidentally read a later pull_request.branches and permit every push branch. +# Match this workflow's two-space YAML indentation; unfamiliar formatting fails closed. line="$(awk ' - $1=="push:" { in_push=1; next } - in_push && $1=="branches:" { print; exit } + /^on:[[:space:]]*$/ { in_on=1; next } + in_on && /^[^[:space:]#]/ { exit } + in_on && /^ push:[[:space:]]*$/ { in_push=1; next } + in_push && /^ [^[:space:]#]/ { exit } + in_push && /^ branches:/ { print; exit } ' "${WF}")" [[ -n "${line}" ]] || fail "could not find on.push.branches in ${WF}" echo "found: ${line}" -echo "${line}" | grep -q 'master' || fail "push.branches must include master" -echo "${line}" | grep -q 'main' || fail "push.branches must include main" -if echo "${line}" | grep -q 'cursor/\*\*'; then - fail "push.branches must NOT include cursor/** (PR check is enough; tip pushes must not double-fire)" +# Normalise: drop "branches:" and whitespace, require exact list. +list="$(echo "${line}" | sed -E 's/^[[:space:]]*branches:[[:space:]]*//; s/[[:space:]]+$//')" +expected='[master, main]' +[[ "${list}" == "${expected}" ]] || fail "push.branches must be exactly ${expected}, got: ${list}" + +if echo "${line}" | grep -qE 'cursor/\*\*|claude/\*\*'; then + fail "push.branches must not include agent-branch globs" fi echo "PASS: readiness-push-no-cursor-branches"