Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,953 changes: 1,953 additions & 0 deletions docs/superpowers/plans/2026-05-21-phase3-permissions.md

Large diffs are not rendered by default.

347 changes: 347 additions & 0 deletions docs/superpowers/specs/2026-05-21-phase3-permissions-design.md

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion examples/chat/server/dawn.config.ts
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
export default {}
export default {
appDir: "src/app",
permissions: {
// Default mode (omitted) is "interactive" — the demo shows the permission flow.
// Seed a few obviously-safe commands so prompt fatigue is reasonable on first run.
allow: {
bash: ["ls", "pwd", "cat", "echo", "head", "tail", "wc"],
},
// Block obviously-destructive patterns even when interactive.
deny: {
bash: ["rm -rf", "sudo", "chmod 777"],
},
},
}
31 changes: 31 additions & 0 deletions examples/chat/web/app/api/permission-resume/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextRequest } from "next/server"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

export async function POST(req: NextRequest): Promise<Response> {
const serverUrl = process.env.DAWN_SERVER_URL ?? "http://127.0.0.1:3001"
const body = (await req.json()) as {
threadId: string
interruptId: string
decision: "once" | "always" | "deny"
}

const upstream = await fetch(
`${serverUrl}/threads/${encodeURIComponent(body.threadId)}/resume`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
interrupt_id: body.interruptId,
decision: body.decision,
}),
},
)

const text = await upstream.text()
return new Response(text, {
status: upstream.status,
headers: { "content-type": "application/json" },
})
}
96 changes: 95 additions & 1 deletion examples/chat/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,39 @@ function newThreadId(): string {

type RouteId = "chat" | "coordinator"

type PendingInterrupt = {
interruptId: string
type: string
kind: "command" | "path"
detail: {
command?: string
operation?: string
path?: string
suggestedPattern: string
}
}

export default function Page() {
const [threadId, setThreadId] = useState<string | null>(null)
const [input, setInput] = useState("")
const [events, setEvents] = useState<string[]>([])
const [busy, setBusy] = useState(false)
const [route, setRoute] = useState<RouteId>("chat")
const [pendingInterrupt, setPendingInterrupt] = useState<PendingInterrupt | null>(null)

async function resolveInterrupt(decision: "once" | "always" | "deny") {
if (!pendingInterrupt || !threadId) return
await fetch("/api/permission-resume", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
threadId,
interruptId: pendingInterrupt.interruptId,
decision,
}),
})
setPendingInterrupt(null)
}

function switchRoute(next: RouteId) {
if (next === route) return
Expand Down Expand Up @@ -47,14 +74,35 @@ export default function Page() {
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ""
let nextLineIsInterruptData = false
while (true) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split("\n")
buf = lines.pop() ?? ""
for (const line of lines) {
if (line.trim()) setEvents((e) => [...e, line])
if (!line.trim()) continue
if (line === "event: interrupt") {
nextLineIsInterruptData = true
setEvents((e) => [...e, line])
continue
}
if (nextLineIsInterruptData && line.startsWith("data: ")) {
try {
const payload = JSON.parse(line.slice("data: ".length))
setPendingInterrupt({
interruptId: payload.interruptId,
type: payload.type,
kind: payload.kind,
detail: payload.detail,
})
} catch {
/* ignore parse errors */
}
nextLineIsInterruptData = false
}
setEvents((e) => [...e, line])
}
}
if (buf.trim()) setEvents((e) => [...e, buf])
Expand Down Expand Up @@ -111,6 +159,52 @@ export default function Page() {
>
{busy ? "Streaming…" : "Send"}
</button>
{pendingInterrupt && (
<div
style={{
border: "2px solid #f0ad4e",
background: "#fdf7e7",
padding: "1rem",
marginTop: "0.5rem",
borderRadius: "4px",
}}
>
<strong>⚠️ Permission request</strong>
<p style={{ margin: "0.5rem 0" }}>
{pendingInterrupt.kind === "command"
? "The agent wants to run command:"
: `The agent wants to ${pendingInterrupt.detail.operation}:`}
</p>
<code
style={{
display: "block",
background: "#fff",
padding: "0.5rem",
border: "1px solid #ddd",
fontFamily: "monospace",
fontSize: 13,
}}
>
{pendingInterrupt.kind === "command"
? pendingInterrupt.detail.command
: pendingInterrupt.detail.path}
</code>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button onClick={() => resolveInterrupt("once")} style={{ padding: "0.5rem 1rem" }}>
Allow once
</button>
<button onClick={() => resolveInterrupt("always")} style={{ padding: "0.5rem 1rem" }}>
Allow always for `{pendingInterrupt.detail.suggestedPattern}`
</button>
<button
onClick={() => resolveInterrupt("deny")}
style={{ padding: "0.5rem 1rem", background: "#f5c6cb" }}
>
Deny
</button>
</div>
</div>
)}
<pre
data-testid="event-log"
style={{
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@dawn-ai/core": "workspace:*",
"@dawn-ai/langchain": "workspace:*",
"@dawn-ai/langgraph": "workspace:*",
"@dawn-ai/permissions": "workspace:*",
"commander": "14.0.3",
"tsx": "^4.8.1"
},
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/src/lib/dev/runtime-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import type { AddressInfo } from "node:net"
import type { DawnMiddleware, MiddlewareRequest } from "@dawn-ai/sdk"
import { executeResolvedRoute, streamResolvedRoute } from "../runtime/execute-route.js"
import { clearPending, getPending } from "../runtime/pending-interrupts.js"
import { type StreamChunk, toSseEvent } from "../runtime/stream-types.js"
import { loadMiddleware, runMiddleware } from "./middleware.js"
import { createRuntimeRegistry, type RuntimeRegistry } from "./runtime-registry.js"
Expand Down Expand Up @@ -132,6 +133,19 @@ async function handleRequest(options: {
return
}

const resumeMatch =
request.method === "POST" && request.url
? /^\/threads\/([^/?#]+)\/resume(?:\?.*)?$/.exec(request.url)
: null
if (resumeMatch) {
await handleResumeRequest({
request,
response,
threadId: decodeURIComponent(resumeMatch[1] ?? ""),
})
return
}

if (request.method !== "POST" || request.url !== "/runs/wait") {
sendJson(response, 404, createRequestErrorBody("Not found"))
return
Expand Down Expand Up @@ -304,6 +318,15 @@ async function handleStreamRequest(options: {
})

try {
// The web client sends a stable per-conversation `thread_id` in
// `metadata.dawn.thread_id` (see examples/chat/web/app/api/chat/route.ts).
// We forward it so the agent-adapter can park interrupts in the
// checkpointer and the resume endpoint can replay them.
const threadId =
typeof validatedBody.value.metadata.dawn.thread_id === "string"
? validatedBody.value.metadata.dawn.thread_id
: undefined

for await (const chunk of streamResolvedRoute({
appRoot: registry.appRoot,
input: validatedBody.value.input,
Expand All @@ -312,6 +335,7 @@ async function handleStreamRequest(options: {
routeFile: route.routeFile,
routeId: route.routeId,
routePath: route.routePath,
...(threadId ? { threadId } : {}),
})) {
response.write(toSseEvent(chunk))
}
Expand All @@ -326,6 +350,52 @@ async function handleStreamRequest(options: {
response.end()
}

async function handleResumeRequest(options: {
readonly request: IncomingMessage
readonly response: ServerResponse
readonly threadId: string
}): Promise<void> {
const { request, response, threadId } = options

if (!threadId) {
sendJson(response, 400, createRequestErrorBody("Missing thread_id in resume URL"))
return
}

const rawBody = await readRequestBody(request)
const parsedBody = parseJson(rawBody)
if (!parsedBody.ok || !isRecord(parsedBody.value)) {
sendJson(response, 400, createRequestErrorBody("Malformed resume request body"))
return
}

const body = parsedBody.value
const interruptId = typeof body.interrupt_id === "string" ? body.interrupt_id : undefined
const decision = body.decision
if (!interruptId) {
sendJson(response, 400, createRequestErrorBody("Missing interrupt_id"))
return
}
if (decision !== "once" && decision !== "always" && decision !== "deny") {
sendJson(response, 400, createRequestErrorBody("decision must be 'once', 'always', or 'deny'"))
return
}

const pending = getPending(threadId)
if (!pending) {
sendJson(response, 400, createRequestErrorBody("No parked interrupt for thread"))
return
}
if (pending.interruptId !== interruptId) {
sendJson(response, 409, createRequestErrorBody("Stale interrupt_id"))
return
}

pending.resolve(decision)
clearPending(threadId)
sendJson(response, 200, { ok: true })
}

const SHUTDOWN_ABORTED = Symbol("shutdown-aborted")

async function raceRequestAgainstShutdown<T>(
Expand Down Expand Up @@ -423,6 +493,7 @@ interface RunsWaitRequest {
readonly mode: "agent" | "chain" | "graph" | "workflow"
readonly route_id: string
readonly route_path: string
readonly thread_id?: string
}
}
readonly on_completion: "delete"
Expand Down
53 changes: 52 additions & 1 deletion packages/cli/src/lib/runtime/execute-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import {
resolveStateFields,
} from "@dawn-ai/core"
import { executeAgent, type SubagentResolver, streamAgent } from "@dawn-ai/langchain"
import {
createPermissionsStore,
type PermissionMode,
type PermissionsStore,
} from "@dawn-ai/permissions"
import { type DawnAgent, isDawnAgent } from "@dawn-ai/sdk"
import type { ExecBackend, FilesystemBackend } from "@dawn-ai/workspace"
import { checkToolNameUniqueness } from "./check-tool-name-uniqueness.js"
Expand Down Expand Up @@ -135,6 +140,14 @@ export async function* streamResolvedRoute(options: {
readonly routeId: string
readonly routePath: string
readonly signal?: AbortSignal
/**
* Stable per-conversation identifier forwarded to the agent-adapter as
* LangGraph's `thread_id`. When set, `interrupt()` calls park graph
* state in the checkpointer and the `/threads/:thread_id/resume`
* endpoint can replay them by handing a `PermissionDecision` back to the
* adapter via the pending-interrupts map.
*/
readonly threadId?: string
}): AsyncGenerator<StreamChunk> {
const prepared = await prepareRouteExecution(options)

Expand Down Expand Up @@ -171,6 +184,7 @@ export async function* streamResolvedRoute(options: {
...(promptFragments && promptFragments.length > 0 ? { promptFragments } : {}),
...(streamTransformers && streamTransformers.length > 0 ? { streamTransformers } : {}),
...(subagentResolver ? { subagentResolver } : {}),
...(options.threadId ? { threadId: options.threadId } : {}),
})) {
switch (chunk.type) {
case "token":
Expand All @@ -189,6 +203,14 @@ export async function* streamResolvedRoute(options: {
case "done":
yield { type: "done", output: chunk.data }
break
case "interrupt": {
// The agent-adapter registers the pending entry in
// pending-interrupts so the /threads/:thread_id/resume endpoint
// can correlate the POST. We just forward the chunk to the SSE
// consumer.
yield { type: "interrupt", data: chunk.data }
break
}
default: {
// Capability-contributed event types (e.g. plan_update from the planning capability).
// The langchain layer widened AgentStreamChunk["type"] to allow arbitrary strings;
Expand Down Expand Up @@ -293,19 +315,48 @@ async function prepareRouteExecution(options: {
let configBackends:
| { readonly filesystem?: FilesystemBackend; readonly exec?: ExecBackend }
| undefined
let permissionsConfig:
| {
readonly mode?: PermissionMode
readonly allow?: Readonly<Record<string, readonly string[]>>
readonly deny?: Readonly<Record<string, readonly string[]>>
}
| undefined
try {
const loaded = await loadDawnConfig({ appRoot: options.appRoot })
configBackends = loaded.config.backends
permissionsConfig = loaded.config.permissions
} catch {
// No dawn.config.ts (or unreadable). The workspace capability falls
// back to its defaults (localFilesystem + localExec).
// back to its defaults (localFilesystem + localExec); permissions
// defaults to "interactive" with empty allow/deny.
}

const envMode = process.env.DAWN_PERMISSIONS_MODE
const mode: PermissionMode =
envMode === "interactive" || envMode === "non-interactive" || envMode === "bypass"
? envMode
: (permissionsConfig?.mode ?? "interactive")

const permissionsStore: PermissionsStore = createPermissionsStore({
appRoot: options.appRoot,
config: permissionsConfig
? {
version: 1,
allow: permissionsConfig.allow ?? {},
deny: permissionsConfig.deny ?? {},
}
: undefined,
mode,
})
await permissionsStore.load()

const applied = await applyCapabilities(registry, routeDir, {
routeManifest,
descriptor,
descriptorRouteMap,
...(configBackends ? { backends: configBackends } : {}),
permissions: permissionsStore,
})

if (applied.errors.length > 0) {
Expand Down
Loading
Loading