feat: dsh-gate — auto-run non-destructive mutations when no session ID

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
This commit is contained in:
2026-08-16 17:03:27 +02:00
parent 04aa1bd5e8
commit eca81ae9af
4 changed files with 69 additions and 62 deletions

View File

@@ -1 +1 @@
0.37.0
0.38.0

View File

@@ -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) {

View File

@@ -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{

View File

@@ -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-<n>` 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