diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0b2c7..fda8504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,3 +50,13 @@ Initial implementation. through the cancel endpoint before teardown. Permission and question requests cannot be satisfied unattended and fail closed (exit 2) naming the tool. Runs fall back to `crush run` when the server cannot start. +- Full-permission consent: `traust-cli init` asks whether to run skills + with all tools auto-approved (the harness's skills require unattended + bash and file writes) and records the answer in the workspace state; + headless installs pass `--full-permission`. Runs translate the consent + into the right flag per harness — claude + `--dangerously-skip-permissions`, crush `--yolo` / the server's + permissions-skip endpoint, codex + `--dangerously-bypass-approvals-and-sandbox`, pi's full toolset — and + surface it in the notes, the run journal and `doctor`. Without consent, + permission requests still fail closed (exit 2) with guidance. diff --git a/cmd/init.go b/cmd/init.go index 3b84b99..4544d89 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -14,14 +14,15 @@ import ( ) var initOpts struct { - workspace string - forge string - ref string - configHome string - toolchain string - harness string - writeProfile bool - yes bool + workspace string + forge string + ref string + configHome string + toolchain string + harness string + fullPermission bool + writeProfile bool + yes bool } var initCmd = &cobra.Command{ @@ -49,6 +50,7 @@ func init() { initCmd.Flags().StringVar(&initOpts.configHome, "config-home", "", "TRAUST_CONFIG_HOME (default /config)") initCmd.Flags().StringVar(&initOpts.toolchain, "toolchain", "none", "scanner toolchain profile (none, secure-code-audit, vuln-scan, triage, …)") initCmd.Flags().StringVar(&initOpts.harness, "harness", "", "default agent harness (default: first detected)") + initCmd.Flags().BoolVar(&initOpts.fullPermission, "full-permission", false, "consent to full-permission runs: harness executes with all tools auto-approved (required by the harness's skills)") initCmd.Flags().BoolVar(&initOpts.writeProfile, "write-profile", false, "also record TRAUST_CONFIG_HOME in your shell profile (install_traust --write-profile)") initCmd.Flags().BoolVarP(&initOpts.yes, "yes", "y", false, "non-interactive: accept all defaults") } @@ -85,6 +87,7 @@ func runInit(cmd *cobra.Command, args []string) { initOpts.ref = answers.Ref initOpts.toolchain = answers.ToolchainProfile initOpts.harness = answers.Harness + initOpts.fullPermission = answers.FullPermission } log := func(format string, a ...any) { @@ -99,6 +102,7 @@ func runInit(cmd *cobra.Command, args []string) { ToolchainProfile: initOpts.toolchain, WriteProfile: initOpts.writeProfile, Harness: initOpts.harness, + FullPermission: initOpts.fullPermission, }, log) if err != nil { fatalf(1, "init: %v", err) diff --git a/cmd/run.go b/cmd/run.go index b49a2c7..2bb0f48 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -50,7 +50,7 @@ func init() { runCmd.Flags().StringVar(&runFlags.harnessID, "harness", "", "agent harness (claude, codex, pi, opencode, crush, goose; default: state default)") runCmd.Flags().StringVar(&runFlags.model, "model", "", "model override (default: the registry floor for the skill's role)") runCmd.Flags().IntVar(&runFlags.maxTurns, "max-turns", 0, "cap agent turns (0 = harness default)") - runCmd.Flags().BoolVar(&runFlags.unsafeFullPermission, "unsafe-full-permission", false, "grant the harness full permission (--dangerously-skip-permissions / --yolo class)") + runCmd.Flags().BoolVar(&runFlags.unsafeFullPermission, "unsafe-full-permission", false, "grant the harness full permission for this run (also settable at init: --full-permission)") runCmd.Flags().StringVar(&runFlags.budgetOverride, "budget-override", "", "reason to proceed past the budget refuse band (recorded)") runCmd.Flags().BoolVar(&runFlags.json, "json", false, "machine-readable result summary on stdout") runCmd.Flags().BoolVar(&runFlags.strictValidation, "strict", false, "strict schema validation of artifacts") @@ -128,6 +128,13 @@ func executeRun(state *bootstrap.State, skill *catalog.Skill, args []string) { client := api.NewPythonCLI(state.VenvPython(), state.ChildEnv(), state.TraustRepo()) + // Full-permission mode: explicit per-run flag, or the init-time consent + // recorded in state. The harness's skills require unattended tool use. + unsafeFullPermission := runFlags.unsafeFullPermission || state.FullPermission + if unsafeFullPermission && !runFlags.unsafeFullPermission { + fmt.Fprintln(os.Stderr, theme.Default().Warn.Render("note: full-permission mode active (consented at init; re-run `traust-cli init` to change) — harness runs with all tools auto-approved")) + } + if skill.Standalone == nil { styles := theme.Default() fmt.Fprintln(os.Stderr, styles.Warn.Render("warning: "+fmt.Sprintf("%q is not in the standalone profile — it may expect campaign infrastructure (siblings, ledger, tracker)", skill.Name))) @@ -141,7 +148,7 @@ func executeRun(state *bootstrap.State, skill *catalog.Skill, args []string) { Args: args, Model: runFlags.model, MaxTurns: runFlags.maxTurns, - UnsafeFullPermission: runFlags.unsafeFullPermission, + UnsafeFullPermission: unsafeFullPermission, BudgetOverrideReason: runFlags.budgetOverride, StrictValidation: runFlags.strictValidation, DeclareSpend: true, diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 8995313..1b893f9 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -33,6 +33,9 @@ type InitOptions struct { WriteProfile bool // Harness is the default harness id to store in state; empty auto-detects. Harness string + // FullPermission records the consent to run harnesses with all tools + // auto-approved (the traust skills require it). + FullPermission bool } // LogFn receives progress lines during init. @@ -166,6 +169,12 @@ func Init(ctx context.Context, opts InitOptions, log LogFn) (*State, error) { state.Harness = "claude" } + // 7. Full-permission consent (explicit --full-permission or a prior + // consent survives re-inits; the wizard's answer arrives via opts). + if opts.FullPermission { + state.FullPermission = true + } + if err := state.Save(); err != nil { return nil, err } diff --git a/internal/bootstrap/doctor.go b/internal/bootstrap/doctor.go index 0299a29..93e7fdc 100644 --- a/internal/bootstrap/doctor.go +++ b/internal/bootstrap/doctor.go @@ -39,6 +39,11 @@ func Doctor(ctx context.Context, state *State, log LogFn) (*DoctorReport, error) // State file present and parseable (caller already loaded it). add("state", true, StatePath(state.Workspace)) + if state.FullPermission { + add("permission-mode", true, "full (consented at init): harnesses run with all tools auto-approved") + } else { + add("permission-mode", true, "scoped (fail-closed): permission requests abort runs with exit 2 — the harness's skills need `traust-cli init --full-permission`") + } // Traust checkout + venv. repo := state.TraustRepo() diff --git a/internal/bootstrap/state.go b/internal/bootstrap/state.go index e54fa0f..80194a5 100644 --- a/internal/bootstrap/state.go +++ b/internal/bootstrap/state.go @@ -24,16 +24,22 @@ var ErrNotInitialized = errors.New("traust-cli is not initialized — run `traus // /cli/state.json. It is deliberately outside TRAUST_CONFIG_HOME, // which is schema-gated deployment configuration owned by the harness. type State struct { - Version int `json:"version"` - Workspace string `json:"workspace"` - ConfigHome string `json:"config_home"` - TraustRef string `json:"traust_ref"` - TraustSHA string `json:"traust_sha"` - UvVersion string `json:"uv_version,omitempty"` - Harness string `json:"harness,omitempty"` - ToolchainProfiles []string `json:"toolchain_profiles,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Workspace string `json:"workspace"` + ConfigHome string `json:"config_home"` + TraustRef string `json:"traust_ref"` + TraustSHA string `json:"traust_sha"` + UvVersion string `json:"uv_version,omitempty"` + Harness string `json:"harness,omitempty"` + ToolchainProfiles []string `json:"toolchain_profiles,omitempty"` + // FullPermission records the init-time consent to run skills with all + // tools auto-approved (claude --dangerously-skip-permissions, crush + // --yolo / permissions/skip, codex --dangerously-bypass-approvals...). + // The traust harness's skills require it; without it, permission + // requests fail closed (exit 2). + FullPermission bool `json:"full_permission,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // DefaultWorkspace returns the default managed workspace root (~/.traust). diff --git a/internal/harness/adapters_test.go b/internal/harness/adapters_test.go index 24afaf9..4529fc0 100644 --- a/internal/harness/adapters_test.go +++ b/internal/harness/adapters_test.go @@ -76,6 +76,12 @@ func TestPiCommand(t *testing.T) { if !contains(args, "--tools") || !contains(args, "read,bash,edit,write,grep,find,ls") { t.Errorf("pi default tools wrong: %v", args) } + // Full-permission mode: the complete builtin toolset. + unsafe := spec() + unsafe.UnsafeFullPermission = true + if args := argsOf(t, &Pi{}, unsafe); !contains(args, "read,bash,edit,write,grep,find,ls") { + t.Errorf("pi full-permission tools wrong: %v", args) + } // Task and AskUserQuestion are unmappable and dropped. mapped := mapTools([]string{"Read", "Task", "AskUserQuestion", "Grep"}) if mapped != "read,grep" { @@ -101,6 +107,11 @@ func TestCrushCommand(t *testing.T) { t.Errorf("crush args missing %q: %v", want, cmd.Args) } } + unsafe := spec() + unsafe.UnsafeFullPermission = true + if args := argsOf(t, &Crush{}, unsafe); !contains(args, "--yolo") { + t.Errorf("full-permission crush run should pass --yolo: %v", args) + } if cmd.Stdin == nil { t.Error("crush should take the prompt on stdin") } diff --git a/internal/harness/crush.go b/internal/harness/crush.go index b28d974..14a845c 100644 --- a/internal/harness/crush.go +++ b/internal/harness/crush.go @@ -30,6 +30,9 @@ func (c *Crush) Command(ctx context.Context, spec *RunSpec) *exec.Cmd { if spec.Model != "" { args = append(args, "--model", spec.Model) } + if spec.UnsafeFullPermission { + args = append(args, "--yolo") + } cmd := exec.CommandContext(ctx, c.Binary(), args...) cmd.Dir = spec.Cwd cmd.Env = spec.Env @@ -40,7 +43,7 @@ func (c *Crush) Command(ctx context.Context, spec *RunSpec) *exec.Cmd { func (c *Crush) Notes() []string { return []string{ "running through crush server mode: thinking, tool calls and token usage stream live into the monitor", - "permission and question requests cannot be answered unattended: the run fails closed (exit 2) naming the tool — pre-allow the tools skills need in a .crush/crushrc at the harness root (`permissions allow`)", + "without full-permission mode (consent at `traust-cli init`), permission and question requests fail closed (exit 2) — with it, the workspace runs with permissions skipped (--yolo equivalent)", "if the server cannot start, the run falls back to `crush run` (plain text, no live events, no usage)", } } diff --git a/internal/harness/crush_serve.go b/internal/harness/crush_serve.go index 23ce4b6..2b4a9df 100644 --- a/internal/harness/crush_serve.go +++ b/internal/harness/crush_serve.go @@ -45,6 +45,18 @@ func (c *Crush) RunSession(ctx context.Context, spec *RunSpec, emit func(Event)) if err != nil { return SessionResult{}, fmt.Errorf("create workspace: %w", err) } + + // Full-permission consent (init): skip the workspace's permission + // prompts — the server equivalent of `crush --yolo`. The traust + // skills cannot run unattended without it; without consent, permission + // requests fail closed (needs-input) instead. + if spec.UnsafeFullPermission { + code, err := srv.call(ctx, http.MethodPost, "/v1/workspaces/"+wsID+"/permissions/skip", map[string]bool{"skip": true}, nil) + if err != nil || code != http.StatusOK { + return SessionResult{}, fmt.Errorf("set permissions skip: %d %v", code, err) + } + } + sessID, err := srv.createSession(ctx, wsID) if err != nil { return SessionResult{}, fmt.Errorf("create session: %w", err) diff --git a/internal/harness/crush_serve_test.go b/internal/harness/crush_serve_test.go index a240f40..de2103e 100644 --- a/internal/harness/crush_serve_test.go +++ b/internal/harness/crush_serve_test.go @@ -27,6 +27,7 @@ type fakeCrush struct { agentRunID string cancelCalls []string modelReqs []map[string]any + skipReqs []bool script func(w io.Writer, r *http.Request, sid, runID string) agentPosted chan struct{} postOnce sync.Once @@ -88,6 +89,17 @@ func startFakeCrush(t *testing.T, script func(w io.Writer, r *http.Request, sid, w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", func(w http.ResponseWriter, r *http.Request) { + var body struct { + Skip bool `json:"skip"` + } + json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.skipReqs = append(f.skipReqs, body.Skip) + f.mu.Unlock() + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /v1/workspaces/{id}/events", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("client_id") == "" { w.WriteHeader(http.StatusBadRequest) @@ -266,6 +278,37 @@ func TestCrushServeIgnoresForeignRunComplete(t *testing.T) { } } +func TestCrushServeFullPermissionSkipsPermissions(t *testing.T) { + f := startFakeCrush(t, happyScript) + _, err := (&Crush{}).RunSession(context.Background(), &RunSpec{ + Prompt: "do the audit", Cwd: t.TempDir(), Env: os.Environ(), + UnsafeFullPermission: true, + }, func(Event) {}) + if err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.skipReqs) != 1 || !f.skipReqs[0] { + t.Errorf("permissions/skip requests = %v (want one skip=true)", f.skipReqs) + } +} + +func TestCrushServeScopedModeDoesNotSkipPermissions(t *testing.T) { + f := startFakeCrush(t, happyScript) + _, err := (&Crush{}).RunSession(context.Background(), &RunSpec{ + Prompt: "do the audit", Cwd: t.TempDir(), Env: os.Environ(), + }, func(Event) {}) + if err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.skipReqs) != 0 { + t.Errorf("scoped mode must not touch permissions/skip: %v", f.skipReqs) + } +} + func TestCrushServeModelOverride(t *testing.T) { f := startFakeCrush(t, happyScript) // Fake `crush models` listing; the runner resolves the floor model here. diff --git a/internal/harness/pi.go b/internal/harness/pi.go index d4b3cd3..f3cc423 100644 --- a/internal/harness/pi.go +++ b/internal/harness/pi.go @@ -71,7 +71,11 @@ func (p *Pi) Command(ctx context.Context, spec *RunSpec) *exec.Cmd { if spec.Model != "" { args = append(args, "--model", spec.Model) } - if !spec.UnsafeFullPermission { + if spec.UnsafeFullPermission { + // Full-permission mode: pi gates by tool declaration, not per-call + // approval, so "all tools auto-approved" is the full builtin set. + args = append(args, "--tools", "read,bash,edit,write,grep,find,ls") + } else { args = append(args, "--tools", mapTools(spec.AllowedTools)) } args = append(args, "--", spec.Prompt) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 808bb59..3c1a0d2 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -118,6 +118,11 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) { runID := time.Now().Format("20060102-150405") + "-" + skill.Name res := &RunResult{RunID: runID, Skill: skill.Name, Harness: h.ID(), ExitCode: ExitOK} + // Full-permission consent: the per-run flag OR the init-time consent + // recorded in state (the SDK Provider path has no cmd layer to resolve + // it). + unsafeFullPermission := opts.UnsafeFullPermission || state.FullPermission + // Pre-flight: model floor. pf := ResolveModel(ctx, opts.API, skill, opts.Model) res.Preflight = pf @@ -147,7 +152,7 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) { Harness: h.ID(), Model: pf.Model, RegistryRole: pf.Role, FloorModel: pf.ResolvedModel, ModelWarning: pf.Warning, BudgetCap: res.BudgetCap, BudgetOverride: opts.BudgetOverrideReason, - UnsafeFullPermission: opts.UnsafeFullPermission, + UnsafeFullPermission: unsafeFullPermission, TraustRef: state.TraustRef, TraustSHA: state.TraustSHA, }) if err != nil { @@ -185,6 +190,9 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) { for _, note := range h.Notes() { sink.Progress("note: " + note) } + if unsafeFullPermission { + sink.Progress("note: full permission mode (consented) — the harness runs with all tools auto-approved; S9 isolation, budget bands and the run journal still apply") + } // Launch. runCtx, cancel := context.WithCancel(ctx) @@ -192,7 +200,7 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) { spec := &harness.RunSpec{ Prompt: prompt, Model: pf.Model, Cwd: root, Env: state.ChildEnv(), AllowedTools: skill.AllowedTools, MaxTurns: opts.MaxTurns, - UnsafeFullPermission: opts.UnsafeFullPermission, OutDir: journal.Dir, + UnsafeFullPermission: unsafeFullPermission, OutDir: journal.Dir, } // handleEvent folds one normalized event into journal, sink, result and @@ -298,7 +306,7 @@ func Run(ctx context.Context, opts Options, sink Sink) (*RunResult, error) { sink.Progress(fmt.Sprintf("budget refuse band hit (%d output tokens, cap %d) — harness terminated; re-run with --budget-override \"\" if this is expected", res.Usage.TokensOut, res.BudgetCap)) case res.NeedsInput: res.ExitCode = ExitNeedsInput - sink.Progress("the skill asked for interactive input or a permission grant, which a headless run cannot provide — run it interactively in your harness and answer its questions, then re-run") + sink.Progress("the skill raised a permission request or question that a headless run cannot answer — enable full-permission mode (re-run `traust-cli init`, or --unsafe-full-permission for this run), or pre-allow the tool in your harness's config") case ctx.Err() != nil: res.Aborted = true res.ExitCode = ExitError diff --git a/internal/runner/session_test.go b/internal/runner/session_test.go index 967c3ed..5cf4a7d 100644 --- a/internal/runner/session_test.go +++ b/internal/runner/session_test.go @@ -35,6 +35,9 @@ func fakeServeCrush(t *testing.T, script string) { mux.HandleFunc("POST /v1/workspaces/{id}/current-session", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) mux.HandleFunc("POST /v1/workspaces/{id}/config/model", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) @@ -160,6 +163,46 @@ data: {"type":"run_complete","payload":{"type":"updated","payload":{"session_id" } } +func TestRunSessionModeFullPermissionFromState(t *testing.T) { + fakeServeCrush(t, happyServeScript) + state := testState(t) + state.FullPermission = true + if err := state.Save(); err != nil { + t.Fatal(err) + } + sink := &recSink{} + + skill := testSkill() + skill.Standalone = &catalog.StandaloneInfo{MinModelClass: "opus"} + + res, err := Run(context.Background(), Options{ + State: state, API: &fakeAPI{}, Harness: &harness.Crush{}, + Skill: skill, DeclareSpend: true, + }, sink) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != ExitOK { + t.Fatalf("exit = %d (%+v)", res.ExitCode, res) + } + noted := false + for _, p := range sink.progress { + if strings.Contains(p, "full permission mode") { + noted = true + } + } + if !noted { + t.Error("full-permission note not surfaced") + } + specData, err := os.ReadFile(filepath.Join(res.Dir, "spec.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(specData), `"unsafe_full_permission": true`) { + t.Errorf("journal spec does not record full permission: %s", specData) + } +} + func TestRunSessionModeRunError(t *testing.T) { fakeServeCrush(t, `{"type":"run_complete","payload":{"type":"updated","payload":{"session_id":"sess-1","run_id":"{RUN_ID}","error":"provider 401"}}}`) state := testState(t) diff --git a/internal/tui/init.go b/internal/tui/init.go index 35f7f8d..aa7847d 100644 --- a/internal/tui/init.go +++ b/internal/tui/init.go @@ -11,6 +11,7 @@ type InitAnswers struct { Ref string ToolchainProfile string Harness string + FullPermission bool } // RunInitWizard asks the interactive setup questions. detected lists the @@ -70,6 +71,16 @@ func RunInitWizard(defaultWorkspace, defaultForge string, detected []string, def Options(harnessOptions...). Value(&answers.Harness), ), + huh.NewGroup( + huh.NewConfirm(). + Title("Run skills with full permissions?"). + Description("The traust harness's skills REQUIRE unattended tool use (bash, file writes) — "+ + "without full permission they fail closed on the first permission request. "+ + "With it, your harness runs with all tools auto-approved "+ + "(claude --dangerously-skip-permissions, crush --yolo, codex --dangerously-bypass-approvals-and-sandbox). "+ + "S9 isolation still applies: agents run from the harness root and never load the audit target's configuration."). + Value(&answers.FullPermission), + ), ) if err := form.Run(); err != nil { return nil, err