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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 12 additions & 8 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -49,6 +50,7 @@ func init() {
initCmd.Flags().StringVar(&initOpts.configHome, "config-home", "", "TRAUST_CONFIG_HOME (default <workspace>/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")
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)))
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions internal/bootstrap/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
26 changes: 16 additions & 10 deletions internal/bootstrap/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,22 @@ var ErrNotInitialized = errors.New("traust-cli is not initialized — run `traus
// <workspace>/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).
Expand Down
11 changes: 11 additions & 0 deletions internal/harness/adapters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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")
}
Expand Down
5 changes: 4 additions & 1 deletion internal/harness/crush.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)",
}
}
Expand Down
12 changes: 12 additions & 0 deletions internal/harness/crush_serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions internal/harness/crush_serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion internal/harness/pi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -185,14 +190,17 @@ 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)
defer cancel()
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
Expand Down Expand Up @@ -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 \"<reason>\" 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
Expand Down
Loading
Loading