From eca81ae9af0d25719bb6b0eb8edb24b386a2f44c Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 16 Aug 2026 17:03:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20dsh-gate=20=E2=80=94=20auto-run=20non-d?= =?UTF-8?q?estructive=20mutations=20when=20no=20session=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolicyService.route now auto-runs config_mutation and reversible_low when sessionID is empty (dsh sidecar path), routing as 'dsh-gate'. Destructive still queues. This eliminates the two-layer consent issue: dsh pre-execute is the sole consent gate, oikos no longer queues dsh-originated mutations. Also updates the dsh plan with Phase 1-3 completion status and the architectural decision that _session_id injection is permanently deferred. v0.38.0 --- VERSION | 2 +- internal/core/app/policy.go | 8 ++ internal/core/app/policy_test.go | 20 ++++ .../2026-08-16-dsh-as-agent-replace-nomos.md | 101 +++++++----------- 4 files changed, 69 insertions(+), 62 deletions(-) diff --git a/VERSION b/VERSION index 0f1a7dfc..ca75280b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.0 +0.38.0 diff --git a/internal/core/app/policy.go b/internal/core/app/policy.go index d7694e39..3a32cecf 100644 --- a/internal/core/app/policy.go +++ b/internal/core/app/policy.go @@ -169,6 +169,14 @@ func (s *PolicyService) route(ctx context.Context, in PolicySubmitInput, riskCla return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act"} } + // No session context (dsh sidecar or direct MCP call without nomos): + // the agent runtime owns consent gating, so oikos auto-runs everything + // except destructive without queueing. The destructive class still + // queues because it requires explicit operator confirmation. + if in.SessionID == "" && riskClass != policy.RiskDestructive { + return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act", AutoViaWindow: "dsh-gate"} + } + // Assent window: the operator approved the plan in this session; // config_mutation within the window auto-runs. if riskClass == policy.RiskConfigMutation && s.store.AssentWindowActive(ctx, in.AgentID, in.SessionID) { diff --git a/internal/core/app/policy_test.go b/internal/core/app/policy_test.go index d684ad19..1b0a4e49 100644 --- a/internal/core/app/policy_test.go +++ b/internal/core/app/policy_test.go @@ -144,6 +144,26 @@ func TestPlanFirstGate(t *testing.T) { } } +func TestNoSessionAutoRunExceptDestructive(t *testing.T) { + svc, _ := newPolicySvc(t) + noSession := func(cmd, risk string) PolicySubmitInput { + return PolicySubmitInput{AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x", Command: cmd, DeclaredRisk: risk} + } + // config_mutation with no session → auto-run (dsh owns consent) + d := svc.Decide(context.Background(), noSession("systemctl restart caddy", "")) + if d.Action != DecisionAuto { + t.Errorf("no-session config_mutation = %+v, want auto", d) + } + if d.AutoViaWindow != "dsh-gate" { + t.Errorf("no-session config_mutation via = %q, want dsh-gate", d.AutoViaWindow) + } + // destructive with no session → still queue + d = svc.Decide(context.Background(), noSession("rm -rf /data", "")) + if d.Action != DecisionQueue { + t.Errorf("no-session destructive = %+v, want queue", d) + } +} + func TestSyntaxValidationGate(t *testing.T) { svc, _ := newPolicySvc(t) for _, bad := range []string{ diff --git a/plans/2026-08-16-dsh-as-agent-replace-nomos.md b/plans/2026-08-16-dsh-as-agent-replace-nomos.md index 560d74a4..7e4144bd 100644 --- a/plans/2026-08-16-dsh-as-agent-replace-nomos.md +++ b/plans/2026-08-16-dsh-as-agent-replace-nomos.md @@ -104,83 +104,62 @@ dsh ships with `@deepseek-ai/dsh-session` backed by JSONL or SQLite (event-sourc ## 4. Phases -### Phase 1: Scaffold + MCP tool bridge (2 weeks) +### Phase 1: Scaffold + MCP tool bridge ~~(2 weeks)~~ DONE -**Goal:** dsh boots, connects to oikos MCP, agents run real tasks through oikos tools. +**Status:** Complete. 8/8 golden evals passing. -1. **Create `oikos-dsh/` monorepo** alongside `oikos-web/` - - Workspace root with pnpm, `tsconfig`, `vitest` - - `oikos-dsh/bundles/oikos-profile/` — profile YAML composing `dsh-base` + oikos plugins - - `oikos-dsh/plugins/` — custom plugins directory +**What was built:** +- `@deepseek-ai/dsh-mcp-client` discovery of all 67+ oikos MCP tools +- `@deepseek-ai/dsh-llm-deepseek` model adapter via OpenRouter +- Golden eval suite at `packages/oikos/evals/src/golden.test.ts` +- Bundle at `packages/oikos/bundle/cordis.patch.yml` +- Plugin packages inside deepseek-harness workspace at `packages/oikos/` -2. **`@oikos/dsh-mcp-tools` plugin** - - On `apply(ctx)`: connect to oikos MCP (configured URL + token) - - Call `tools/list`, for each tool: `ctx.tools.register(mcpToolDef)` - - On execute: forward to oikos `tools/call`, stream result back - - Handle `_session_id` scoping for concurrent task isolation - - Cache tool list, invalidate on reconnect +**Known limitation:** `_session_id` cannot be injected into MCP tool call args — dsh deep-freezes args before pre-execute hooks fire, tool definitions have no interceptor mechanism, and MCP protocol has no per-call metadata. This is accepted as a permanent architectural constraint (see Phase 3 note below). -3. **Boot dsh with the profile** - - `dsh --profile oikos` - - Validate connectivity at startup - - Run `get_entity`, `list_entities` through dsh → confirm roundtrip +### Phase 2: Postgres session persistence ~~(1.5 weeks)~~ DONE -4. **Port the golden evals** - - `evals/golden.yaml` → dsh vitest test suite - - Assert tool calls, completion, plan structure same as nomos - - Gate: 4/4 golden evals pass (trivial_readonly, plan_advances_on_proceed, ui_complaint_no_rerun, knowledge_preferred_over_rerun) +**Status:** Complete. Hybrid approach — dsh SQLite owns the event-sourced log; thin Postgres mirror for cross-cutting queries. -**Check:** `dsh` agent answers "What is the state of lxc:dns?" through oikos MCP, returns the same answer nomos would. +**What was built:** +- `@deepseek-ai/dsh-oikos-session-summary` plugin (`packages/oikos/session-summary/`) +- Mirrors `session/created` → `agent_sessions` INSERT, `session/disposed` → UPDATE status/outcome/closed_at +- Tracks `session/title`, `turn/start`, `turn/end`, message counts for status transitions +- UUID v5 deterministic mapping from dsh `session-` IDs to oikos UUID `agent_sessions.id` -### Phase 2: Postgres session persistence (1.5 weeks) +**What was deferred:** +- Full `session-persistence` seam replacement (dsh SQLite → Postgres) — hybrid mirror is sufficient for oikos cross-cutting queries +- `@oikos/dsh-task-tools` (set_goal, propose_plan, etc.) — nomos task model not yet ported -**Goal:** dsh sessions write to oikos Postgres, not dsh SQLite. +### Phase 3: Policy bridge ~~(1 week)~~ DONE -1. **Design the bridging schema** - - Add `session_event_log` table: `(session_id UUID, seq INT, event_type TEXT, payload JSONB, created_at TIMESTAMPTZ)` - - This is the event-sourced log dsh needs for `deriveMessages()` - - `agent_sessions`, `agent_messages` become materialized projections — updated via triggers or application-level write-through - - `agent_activity` stays as-is for the audit/operations views +**Status:** Complete. Consent window end-to-end flow working. -2. **`@oikos/dsh-session-pg` plugin** — implements dsh's session-persistence seam - - `subscribe("session/event")` → append row to `session_event_log` - - `subscribe("session/flush")` → commit/notify - - On load: `SELECT * FROM session_event_log WHERE session_id = $1 ORDER BY seq` → rebuild `Session` - - Session lifecycle: `session/created` → ensure row in `agent_sessions`; `session/disposed` → finalize outcome/summary - - Use oikos `pgxpool` (via Node.js `pg` module) +**What was built:** +- `@deepseek-ai/dsh-oikos-mcp-scope` plugin (`packages/oikos/mcp-scope/`) +- `tools/pre-execute` waterfall: read-only → allow, mutation → check consent window → ask if no window +- `tools/post-execute` auto-approve: detects oikos "requires approval" response → calls `decide_approval` HTTP API → replaces result text +- Consent window: `approval/decided` event with `allowed-once` opens 30-min assent window in Postgres `autonomy_settings` +- Auto-expiry cleanup (1-hour interval) for consent/destructive windows +- Go-side fixes: `decide_approval` token mismatch, `X-Oikos-Session-Id` header fallback, LIKE-based assent query -3. **`@oikos/dsh-task-tools` plugin** — replaces nomos's task tools - - Register `set_goal`, `propose_plan`, `update_plan_step`, `complete_task`, `ask_operator` - - Each writes directly to oikos Postgres tables (`agent_sessions`, `session_plan_steps`, `session_questions`) - - Emit oikos `observability.Event` via PG `NOTIFY` for SSE live-updates - - Mirror nomos's business logic: auto-append writeback step, refuse complete without writeback, plan generation tracking, completion ordering, auto-complete-if-plan-done safety net +**Architecture note — two-layer consent:** +Because `_session_id` cannot be passed to oikos MCP, the consent flow operates in two layers: +1. **dsh layer** (mcp-scope pre-execute): checks Postgres assent window by session UUID → allows without asking operator again +2. **oikos layer** (Go classifyAndGate): always sees empty `_session_id` → queues execution → post-execute auto-approve detects the queued result and calls the oikos `decide_approval` HTTP API -4. **Verify against evals** — all golden evals pass through dsh with Postgres persistence +This is the permanent solution — not a workaround. The dsh layer provides the operator consent UX; the post-execute layer bridges the gap to oikos's execution pipeline. -**Check:** Create a session in dsh, verify `agent_sessions` and `agent_messages` rows appear in oikos Postgres. Read them back from oikos REST API. - -### Phase 3: Policy bridge (1 week) - -**Goal:** dsh respects oikos risk classification and approval gating. - -1. **`@oikos/dsh-policy` plugin** - - Listen on `tools/pre-execute` waterfall - - For each tool call: call oikos `preflight` or `classify_command` MCP tool - - Map risk class to dsh decision: - - `readonly` → `allow` (no gate) - - `reversible_low` → `allow` (auto-execute, same as nomos) - - `config_mutation` → check oikos assent window; if active → `allow`, else → `ask` - - `destructive` → check oikos destructive window; if active → `allow`, else → `ask` with typed-confirmation requirement - - `ask` returns a dsh `Interaction` — the UI shows an approval dialog; operator decides → tool continues or is denied - -2. **Backend:** no changes needed — oikos MCP `classify_command` and `preflight` already exist - -**Check:** A `run` call with `config_mutation` risk triggers an approval dialog in dsh UI. "Go ahead" in chat grants it. - -### Phase 4: UI migration (3-4 weeks) +### Phase 4: UI migration (3-4 weeks) — IN PROGRESS **Goal:** dsh Web UI replaces oikos-web. +**Prerequisites:** +- [x] Phase 1-3 complete +- [x] dsh running at http://127.0.0.1:3080 with all oikos MCP tools +- [x] Consent/approval flow working end-to-end +- [ ] Commit dsh-harness plugin changes (packages/oikos/ untracked) + 1. **dsh Web UI basics** - dsh ships its own Web UI: session list, chat window with tool cards, assistant chunks, turn/step boundaries - No changes needed for basic agent chat — it works out of the box