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:
@@ -169,6 +169,14 @@ func (s *PolicyService) route(ctx context.Context, in PolicySubmitInput, riskCla
|
|||||||
return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act"}
|
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;
|
// Assent window: the operator approved the plan in this session;
|
||||||
// config_mutation within the window auto-runs.
|
// config_mutation within the window auto-runs.
|
||||||
if riskClass == policy.RiskConfigMutation && s.store.AssentWindowActive(ctx, in.AgentID, in.SessionID) {
|
if riskClass == policy.RiskConfigMutation && s.store.AssentWindowActive(ctx, in.AgentID, in.SessionID) {
|
||||||
|
|||||||
@@ -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) {
|
func TestSyntaxValidationGate(t *testing.T) {
|
||||||
svc, _ := newPolicySvc(t)
|
svc, _ := newPolicySvc(t)
|
||||||
for _, bad := range []string{
|
for _, bad := range []string{
|
||||||
|
|||||||
@@ -104,83 +104,62 @@ dsh ships with `@deepseek-ai/dsh-session` backed by JSONL or SQLite (event-sourc
|
|||||||
|
|
||||||
## 4. Phases
|
## 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/`
|
**What was built:**
|
||||||
- Workspace root with pnpm, `tsconfig`, `vitest`
|
- `@deepseek-ai/dsh-mcp-client` discovery of all 67+ oikos MCP tools
|
||||||
- `oikos-dsh/bundles/oikos-profile/` — profile YAML composing `dsh-base` + oikos plugins
|
- `@deepseek-ai/dsh-llm-deepseek` model adapter via OpenRouter
|
||||||
- `oikos-dsh/plugins/` — custom plugins directory
|
- 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**
|
**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).
|
||||||
- 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
|
|
||||||
|
|
||||||
3. **Boot dsh with the profile**
|
### Phase 2: Postgres session persistence ~~(1.5 weeks)~~ DONE
|
||||||
- `dsh --profile oikos`
|
|
||||||
- Validate connectivity at startup
|
|
||||||
- Run `get_entity`, `list_entities` through dsh → confirm roundtrip
|
|
||||||
|
|
||||||
4. **Port the golden evals**
|
**Status:** Complete. Hybrid approach — dsh SQLite owns the event-sourced log; thin Postgres mirror for cross-cutting queries.
|
||||||
- `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)
|
|
||||||
|
|
||||||
**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**
|
**Status:** Complete. Consent window end-to-end flow working.
|
||||||
- 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
|
|
||||||
|
|
||||||
2. **`@oikos/dsh-session-pg` plugin** — implements dsh's session-persistence seam
|
**What was built:**
|
||||||
- `subscribe("session/event")` → append row to `session_event_log`
|
- `@deepseek-ai/dsh-oikos-mcp-scope` plugin (`packages/oikos/mcp-scope/`)
|
||||||
- `subscribe("session/flush")` → commit/notify
|
- `tools/pre-execute` waterfall: read-only → allow, mutation → check consent window → ask if no window
|
||||||
- On load: `SELECT * FROM session_event_log WHERE session_id = $1 ORDER BY seq` → rebuild `Session`
|
- `tools/post-execute` auto-approve: detects oikos "requires approval" response → calls `decide_approval` HTTP API → replaces result text
|
||||||
- Session lifecycle: `session/created` → ensure row in `agent_sessions`; `session/disposed` → finalize outcome/summary
|
- Consent window: `approval/decided` event with `allowed-once` opens 30-min assent window in Postgres `autonomy_settings`
|
||||||
- Use oikos `pgxpool` (via Node.js `pg` module)
|
- 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
|
**Architecture note — two-layer consent:**
|
||||||
- Register `set_goal`, `propose_plan`, `update_plan_step`, `complete_task`, `ask_operator`
|
Because `_session_id` cannot be passed to oikos MCP, the consent flow operates in two layers:
|
||||||
- Each writes directly to oikos Postgres tables (`agent_sessions`, `session_plan_steps`, `session_questions`)
|
1. **dsh layer** (mcp-scope pre-execute): checks Postgres assent window by session UUID → allows without asking operator again
|
||||||
- Emit oikos `observability.Event` via PG `NOTIFY` for SSE live-updates
|
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
|
||||||
- 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
|
|
||||||
|
|
||||||
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 4: UI migration (3-4 weeks) — IN PROGRESS
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
**Goal:** dsh Web UI replaces oikos-web.
|
**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**
|
1. **dsh Web UI basics**
|
||||||
- dsh ships its own Web UI: session list, chat window with tool cards, assistant chunks, turn/step boundaries
|
- 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
|
- No changes needed for basic agent chat — it works out of the box
|
||||||
|
|||||||
Reference in New Issue
Block a user