diff --git a/internal/commands/connect.go b/internal/commands/connect.go index 316d963ec..cdc89e196 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -186,6 +186,9 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any } trust += ": operators " + strings.Join(ids, ", ") } + if f.Trust.AllowAssignments { + trust += "; they may assign" + } d := map[string]any{ "file": exact(path), "account": f.AccountID, @@ -260,6 +263,9 @@ type connectSetupFlags struct { trust string allow []string + // allowAssignments is --allow-assignments-from-authorized, unset when + // not passed so setup keeps what connect.json has. + allowAssignments optionalBool serve []string classes []string @@ -308,9 +314,14 @@ Trust. operator (default): the operator alone. allowlist: the operator and the people passed with --allow. project: any non-client member of the event's project, as a participant, beside the operator and any people passed with --allow, now or in an earlier run. The operator and the people passed with ---allow are operators; a project member is a participant. Each request the connector -prints names its role. -Assignments are the operator's alone in every mode. +--allow are operators; a project member is a participant. Each request the +connector prints names its role. Assignments are the operator's alone in every +mode, unless --allow-assignments-from-authorized opts in the people the +allowlist names: the assigner is the account feed's performer, which +Basecamp writes, so naming someone trusts that person and nothing that claims +to be them. It rides with --allow: a run that passes --allow turns it off +unless it passes the flag again, and a run that passes neither keeps both. +--allow-assignments-from-authorized=false takes it back. Projects. connect.json is the local list of Basecamp projects this agent serves: --serve , --unserve . Nothing in a project it @@ -374,6 +385,8 @@ Examples: fl.StringVar(&f.operatorProfile, "operator-profile", "", "Profile whose identity is the operator") fl.StringVar(&f.trust, "trust", "", "Who may drive the agent: operator, allowlist or project") fl.StringArrayVar(&f.allow, "allow", nil, "Person id to trust as an operator besides the operator (repeatable; with no --trust it implies allowlist; works with --trust allowlist or project, not operator)") + fl.Var(&f.allowAssignments, "allow-assignments-from-authorized", "Let the people the allowlist names, this run's --allow or the list kept, assign the agent work too (default: the operator's alone; =false to turn off)") + fl.Lookup("allow-assignments-from-authorized").NoOptDefVal = "true" fl.StringArrayVar(&f.serve, "serve", nil, "Serve a Basecamp project: (repeatable)") fl.StringArrayVar(&f.unserve, "unserve", nil, "Stop serving a project (repeatable)") fl.StringArrayVar(&f.classes, "class", nil, "Classify a served project: =, or = to clear it (repeatable)") @@ -590,7 +603,9 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags) if kind == setup.KindBotUser { next.Agent.IdentityID = expect } - next.Trust.OperatorID = trust.Operator.ID + if err := setup.SetOperator(&next.Trust, trust.Operator.ID, f.allowAssignments.set && f.allowAssignments.value); err != nil { + return output.ErrUsage("connect.json was not written: " + err.Error()) + } if err := next.Validate(); err != nil { return output.ErrUsage("connect.json was not written: " + err.Error()) } @@ -786,6 +801,10 @@ func canonicalAccount(raw string) (string, error) { // changes turns setup's flags into the changes connect.json takes. func (f *connectSetupFlags) changes() (setup.Changes, error) { var ch setup.Changes + if f.allowAssignments.set { + on := f.allowAssignments.value + ch.AllowAssignments = &on + } if f.trust != "" { ch.Trust = admission.TrustMode(f.trust) switch ch.Trust { @@ -1263,3 +1282,24 @@ func connectSDKOptions() []basecamp.ClientOption { basecamp.WithUserAgent(version.UserAgent() + " " + basecamp.DefaultUserAgent), } } + +// optionalBool is a boolean flag that knows whether it was given, so a setup +// run that leaves it out keeps what connect.json has. +type optionalBool struct { + set, value bool +} + +func (b *optionalBool) String() string { return strconv.FormatBool(b.value) } +func (b *optionalBool) Type() string { return "bool" } +func (b *optionalBool) IsBoolFlag() bool { + return true +} + +func (b *optionalBool) Set(s string) error { + v, err := strconv.ParseBool(s) + if err != nil { + return err + } + b.set, b.value = true, v + return nil +} diff --git a/internal/commands/connect_setup_guided.go b/internal/commands/connect_setup_guided.go index 6a000a5c3..344f31e4f 100644 --- a/internal/commands/connect_setup_guided.go +++ b/internal/commands/connect_setup_guided.go @@ -47,6 +47,7 @@ const connectAgentProfileName = "agent" var connectSetupPolicyFlags = []string{ "expect-identity", "operator", "operator-profile", "trust", "allow", "serve", "unserve", "class", "watch-completions", "no-watch-completions", + "allow-assignments-from-authorized", } // connectGOOS is the platform guided setup checks before connecting. A diff --git a/internal/commands/connect_setup_test.go b/internal/commands/connect_setup_test.go index bbb13ca3b..d5acb3dc8 100644 --- a/internal/commands/connect_setup_test.go +++ b/internal/commands/connect_setup_test.go @@ -1045,6 +1045,33 @@ func TestConnectSetupNamesOperatorsAlongsideProjectTrust(t *testing.T) { assert.Equal(t, []int64{setupOperatorPerson + 1}, f.Trust.AllowlistIDs) } +// --allow-assignments-from-authorized opts the named people in to assigning +// the agent work; =false takes it back, and leaving it out keeps it. +func TestConnectSetupOptsNamedOperatorsInToAssignments(t *testing.T) { + s := startConnectSetupServer(t) + connectSetupApp(t, s, "agent") + storeConnectProfile(t, s, "me", setupOperatorToken) + run := func(args ...string) *setup.File { + t.Helper() + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), append([]string{"--operator-profile", "me", serveArg()}, args...)...) + require.NoError(t, err, out) + f, err := setup.Load(connectSetupPath(t, "agent")) + require.NoError(t, err) + return &f + } + + assert.True(t, run("--allow", fmt.Sprint(setupOperatorPerson+1), "--allow-assignments-from-authorized").Trust.AllowAssignments) + assert.True(t, run().Trust.AllowAssignments, "kept when not passed") + assert.False(t, run("--allow-assignments-from-authorized=false").Trust.AllowAssignments) + + out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", "--trust", "operator", "--allow-assignments-from-authorized") + require.Error(t, err, out, "an opt-in with nobody named is refused") + f, err := setup.Load(connectSetupPath(t, "agent")) + require.NoError(t, err) + assert.False(t, f.Trust.AllowAssignments, "and nothing was written") + assert.Equal(t, []int64{setupOperatorPerson + 1}, f.Trust.AllowlistIDs) +} + // The scope that decides readiness is the one the credential was granted, // not the one the profile's configuration names. func TestConnectSetupReadsTheGrantedScopeNotTheProfiles(t *testing.T) { diff --git a/internal/connector/admission/assignments_test.go b/internal/connector/admission/assignments_test.go new file mode 100644 index 000000000..c90581901 --- /dev/null +++ b/internal/connector/admission/assignments_test.go @@ -0,0 +1,88 @@ +package admission + +import ( + "testing" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// allow_assignments lets the people the allowlist names assign the agent work, +// as the operator does. The assigner is the feed's performer, which Basecamp +// writes and no payload carries, so naming someone here trusts them, not text +// that claims to be them. Nobody it doesn't name gains anything. +func TestNamedOperatorsMayAssignWhenOptedIn(t *testing.T) { + optedIn := func() Policy { + p := basePolicy() + p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{allowedID}, AllowAssignments: true} + return p + } + assignment := func(performer int64) Event { + return Event{ID: eventID, EventType: "card.assignment_changed", BucketID: servedProj, RecordingID: recordingID, CreatorID: performer} + } + card := func(f *fakeReads) { + f.summaries[recordingID] = summaryWith(recordingID, servedProj, "Kanban::Card", strangerID, "
a card
") + f.summaries[recordingID].Assignees = []basecamp.Person{{ID: agentID}} + f.assignments[eventID] = []int64{agentID} + } + + t.Run("a named operator assigning the agent is admitted", func(t *testing.T) { + f := newFakeReads() + card(f) + v := decide(t, newAdmitter(t, optedIn(), f), assignment(allowedID)) + require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) + assert.Equal(t, TriggerAssigned, v.Trigger) + assert.Equal(t, allowedID, v.RequesterID) + assert.Equal(t, 1, f.assignCalls, "and only once the events show this assignment added the agent") + }) + + t.Run("so is one named beside project trust, and the members still are not", func(t *testing.T) { + p := optedIn() + p.Trust.Mode = TrustProject + f := newFakeReads() + card(f) + f.members[servedProj] = map[int64]bool{memberID: true} + v := decide(t, newAdmitter(t, p, f), assignment(allowedID)) + require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) + assert.Equal(t, RoleOperator, v.Role) + + f = newFakeReads() + card(f) + f.members[servedProj] = map[int64]bool{memberID: true} + v = decide(t, newAdmitter(t, p, f), assignment(memberID)) + assert.Equal(t, ReasonAssignmentNotOperator, v.Reason) + }) + + t.Run("anyone else is still refused at the gate", func(t *testing.T) { + for _, performer := range []int64{strangerID, memberID} { + f := newFakeReads() + card(f) + v := decide(t, newAdmitter(t, optedIn(), f), assignment(performer)) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonAssignmentNotOperator, v.Reason) + assert.Zero(t, f.totalReads()) + } + }) + + t.Run("without the opt-in, a named operator's assignment is refused", func(t *testing.T) { + f := newFakeReads() + card(f) + p := optedIn() + p.Trust.AllowAssignments = false + v := decide(t, newAdmitter(t, p, f), assignment(allowedID)) + assert.Equal(t, ReasonAssignmentNotOperator, v.Reason) + }) + + t.Run("an opt-in that names nobody is refused", func(t *testing.T) { + p := basePolicy() + p.Trust.AllowAssignments = true + assert.Error(t, p.Validate()) + }) + + t.Run("and so is one that names only the operator, who needs none", func(t *testing.T) { + p := optedIn() + p.Trust.AllowlistIDs = []int64{operatorID} + assert.Error(t, p.Validate()) + }) +} diff --git a/internal/connector/admission/doc.go b/internal/connector/admission/doc.go index f3be6b2a8..1ac1af6f9 100644 --- a/internal/connector/admission/doc.go +++ b/internal/connector/admission/doc.go @@ -33,7 +33,9 @@ // actor type, and an event performed on someone's behalf by any agent are all // discarded before anything else is considered, so an agent mentioning an // agent stops at zero hops. Assignments are operator-only in every trust -// mode. For the triggers that carry an instruction in content (mentioned, +// mode, unless allow_assignments opts in the people the allowlist names; the +// assigner is the feed's performer, which Basecamp writes, so naming someone +// trusts that person and not a payload that claims to be them. For the triggers that carry an instruction in content (mentioned, // subscribed) the recording's author must be trusted too, because the // performer of a *.created event is not always the person who wrote it — a // to-do moved in from another project is created by the mover. @@ -62,7 +64,8 @@ // write (a completion). Allowlisting an agent is an operator error that // Validate cannot detect; the feed's actor_types=person filter excludes // agents at source. -// 2. An assignment is admitted only when the operator performed it, the +// 2. An assignment is admitted only when the operator performed it (or a +// person the allowlist names, under allow_assignments), the // recording's events show this event added the agent, and the agent is // still assigned. Missing, unfound or partial assignment data blocks as // delta_unverified; it never admits and never discards. diff --git a/internal/connector/admission/gate.go b/internal/connector/admission/gate.go index d6d79904f..ea6a86293 100644 --- a/internal/connector/admission/gate.go +++ b/internal/connector/admission/gate.go @@ -70,6 +70,9 @@ func Gate(ev Event, p Policy, m Matrix) GateResult { needsMembership := false switch { case isOperator: + case rule.OperatorOnly && p.Trust.AllowAssignments && role == RoleOperator: + // A named operator the operator opted in to assigning. The + // performer is the feed's, written by Basecamp, never a payload's. case rule.OperatorOnly: drop(ReasonAssignmentNotOperator) continue diff --git a/internal/connector/admission/matrix.go b/internal/connector/admission/matrix.go index c009978ae..6270fb9ff 100644 --- a/internal/connector/admission/matrix.go +++ b/internal/connector/admission/matrix.go @@ -16,8 +16,10 @@ const ( type Rule struct { Trigger Trigger // OperatorOnly restricts the rule to events the operator performed, - // whatever the trust mode. Assignments run the agent against a recording - // on the assigner's say-so, so no broadened mode extends to them. + // whatever the trust mode, and to the people the allowlist names when + // Trust.AllowAssignments opts them in. Assignments run the agent against + // a recording on the assigner's say-so, so no broadened mode extends to + // them by itself. OperatorOnly bool // RequiresServed discards the rule at the gate when connect.json does not // serve the project. Only mentioned and assigned are answered in an @@ -99,8 +101,9 @@ const ( ReasonOutOfScope Reason = "out_of_scope" // ReasonUntrustedPerformer: the performer is not in the trust set. ReasonUntrustedPerformer Reason = "untrusted_performer" - // ReasonAssignmentNotOperator: an assignment performed by anyone but the - // operator. + // ReasonAssignmentNotOperator: an assignment performed by anyone other + // than the operator or a person the allowlist names whom + // allow_assignments opts in. ReasonAssignmentNotOperator Reason = "assignment_not_operator" // ReasonUntrustedAuthor: the recording carrying the instruction was // written by someone outside the trust set. diff --git a/internal/connector/admission/policy.go b/internal/connector/admission/policy.go index c9cc9ef74..58f4640be 100644 --- a/internal/connector/admission/policy.go +++ b/internal/connector/admission/policy.go @@ -46,6 +46,9 @@ type Trust struct { // AllowlistIDs are the Person ids trusted as operators besides the // operator, in allowlist or project mode. AllowlistIDs []int64 `json:"allowlist_ids,omitempty"` + // AllowAssignments lets the people AllowlistIDs names assign the agent + // work, as the operator does. Off, assignments are the operator's alone. + AllowAssignments bool `json:"allow_assignments,omitempty"` } // roleOf is the role a trusted person holds, before any membership read: an @@ -306,6 +309,10 @@ func (p Policy) Validate() error { return errors.New("admission: policy needs the operator's Person id") case p.Trust.OperatorID == p.AgentID: return errors.New("admission: the operator cannot be the agent itself") + case p.Trust.AllowAssignments && !slices.ContainsFunc(p.Trust.AllowlistIDs, func(id int64) bool { return id != p.Trust.OperatorID }): + // An opt-in nobody can use reads as a widening that is not there: + // the operator assigns without one. + return errors.New("admission: allow_assignments is set but the allowlist names nobody besides the operator") } switch p.Trust.Mode { case TrustOperator: diff --git a/internal/connector/setup/apply.go b/internal/connector/setup/apply.go index 5a6e6d203..8808ff7ec 100644 --- a/internal/connector/setup/apply.go +++ b/internal/connector/setup/apply.go @@ -19,6 +19,11 @@ type Changes struct { // same run asks for project mode, which admits the project's members as // participants beside them. Allow []int64 + // AllowAssignments turns the allowlist's assignment opt-in on or off. + // Nil keeps the file's, except in a run that passes Allow, which + // restates the opt-in with the list it covers: off unless set here. + // SetOperator drops a kept one that is left covering nobody. + AllowAssignments *bool // Serve are the project (bucket) ids to serve. A project already served // keeps its class and watch_completions. @@ -46,6 +51,9 @@ func Apply(f File, ch Changes) (File, error) { if err := applyTrust(&out.Trust, ch); err != nil { return File{}, err } + if err := applyAssignmentOptIn(&out.Trust, ch); err != nil { + return File{}, err + } for _, id := range ch.Remove { if slices.Contains(ch.Serve, id) { @@ -136,3 +144,42 @@ func sortedIDs(ids []int64) []int64 { slices.Sort(out) return slices.Compact(out) } + +// applyAssignmentOptIn sets the opt-in that lets the allowlist's people +// assign the agent work. It rides with the --allow that names them: a run +// that passes --allow restates it, off unless this run sets it, so whoever a +// new list names gains assignments only when asked, with no comparison to +// what the file held before. Setting it needs someone named. +func applyAssignmentOptIn(t *admission.Trust, ch Changes) error { + switch { + case ch.AllowAssignments != nil: + t.AllowAssignments = *ch.AllowAssignments + case len(ch.Allow) > 0: + t.AllowAssignments = false + } + if len(t.AllowlistIDs) == 0 { + if ch.AllowAssignments != nil && *ch.AllowAssignments { + return errors.New("--allow-assignments-from-authorized opts in the people the allowlist names, and after this run it names nobody") + } + t.AllowAssignments = false + } + return nil +} + +// SetOperator records the operator. When the allowlist then names nobody +// besides them, the assignment opt-in has nobody left to cover. Kept from an +// earlier run, it is dropped: the operator assigns without one, and keeping +// it would make the file refuse to validate over a change setup itself made. +// Asked for in this run (requested), it is refused, so the person learns it +// does nothing rather than finding it quietly off. +func SetOperator(t *admission.Trust, id int64, requested bool) error { + t.OperatorID = id + if !t.AllowAssignments || slices.ContainsFunc(t.AllowlistIDs, func(a int64) bool { return a != id }) { + return nil + } + if requested { + return errors.New("--allow-assignments-from-authorized covers the people the allowlist names besides the operator, and it names nobody else") + } + t.AllowAssignments = false + return nil +} diff --git a/internal/connector/setup/apply_test.go b/internal/connector/setup/apply_test.go index be53561bf..c92f30d6e 100644 --- a/internal/connector/setup/apply_test.go +++ b/internal/connector/setup/apply_test.go @@ -146,3 +146,95 @@ func TestApplyServedProjects(t *testing.T) { assert.Error(t, err, "serving and removing one project in one run") }) } + +// The assignment opt-in rides with the allowlist it opts in: setting it needs +// someone named, and a run that empties the list takes it away too. +func TestApplyAssignmentOptIn(t *testing.T) { + on, off := true, false + base := validFile(t) + + t.Run("set with the people it opts in", func(t *testing.T) { + out, err := Apply(base, Changes{Allow: []int64{9}, AllowAssignments: &on}) + require.NoError(t, err) + assert.True(t, out.Trust.AllowAssignments) + _, err = out.Policy(agentID) + assert.NoError(t, err, "admission accepts the result") + }) + t.Run("refused with nobody named", func(t *testing.T) { + _, err := Apply(base, Changes{AllowAssignments: &on}) + assert.Error(t, err) + }) + optedIn := base + optedIn.Trust = admission.Trust{Mode: admission.TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{7}, AllowAssignments: true} + t.Run("kept when neither it nor the list is passed", func(t *testing.T) { + out, err := Apply(optedIn, Changes{Serve: []int64{777}}) + require.NoError(t, err) + assert.True(t, out.Trust.AllowAssignments) + }) + // The opt-in was given for the people named then. A run that names + // others does not hand them assignments unless it says so again. + t.Run("reset when the list is replaced without it", func(t *testing.T) { + out, err := Apply(optedIn, Changes{Allow: []int64{8}}) + require.NoError(t, err) + assert.False(t, out.Trust.AllowAssignments) + + out, err = Apply(optedIn, Changes{Allow: []int64{8}, AllowAssignments: &on}) + require.NoError(t, err) + assert.True(t, out.Trust.AllowAssignments, "restated with the new list") + }) + // One rule, no history: a run that passes --allow says the opt-in too, + // off unless it passes the flag, whoever the list names. + t.Run("a run that passes --allow restates it", func(t *testing.T) { + f := base + f.Trust = admission.Trust{Mode: admission.TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{7, 9}, AllowAssignments: true} + for _, allow := range [][]int64{{9, 7}, {7}, {7, 9, 11}} { + out, err := Apply(f, Changes{Allow: allow}) + require.NoError(t, err) + assert.False(t, out.Trust.AllowAssignments, "allow %v without the flag", allow) + out, err = Apply(f, Changes{Allow: allow, AllowAssignments: &on}) + require.NoError(t, err) + assert.True(t, out.Trust.AllowAssignments, "allow %v with it", allow) + } + }) + t.Run("set over the list connect.json keeps", func(t *testing.T) { + f := base + f.Trust = admission.Trust{Mode: admission.TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{7}} + out, err := Apply(f, Changes{AllowAssignments: &on}) + require.NoError(t, err) + assert.True(t, out.Trust.AllowAssignments) + assert.Equal(t, []int64{7}, out.Trust.AllowlistIDs) + }) + t.Run("refused under project trust with nobody named", func(t *testing.T) { + _, err := Apply(base, Changes{Trust: admission.TrustProject, AllowAssignments: &on}) + assert.Error(t, err, "the members are participants, and the opt-in never covers them") + }) + t.Run("turned off", func(t *testing.T) { + out, err := Apply(optedIn, Changes{AllowAssignments: &off}) + require.NoError(t, err) + assert.False(t, out.Trust.AllowAssignments) + }) + t.Run("dropped with the list", func(t *testing.T) { + out, err := Apply(optedIn, Changes{Trust: admission.TrustOperator}) + require.NoError(t, err) + assert.False(t, out.Trust.AllowAssignments) + _, err = out.Policy(agentID) + assert.NoError(t, err) + }) +} + +// Making the one person the allowlist names the operator leaves the opt-in +// nobody to cover. Setup drops it rather than refusing the change. +func TestSetOperatorDropsAnOptInWithNobodyLeftToCover(t *testing.T) { + tr := admission.Trust{Mode: admission.TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{7}, AllowAssignments: true} + require.NoError(t, SetOperator(&tr, 7, false)) + assert.Equal(t, int64(7), tr.OperatorID) + assert.False(t, tr.AllowAssignments) + + tr = admission.Trust{Mode: admission.TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{7, 9}, AllowAssignments: true} + require.NoError(t, SetOperator(&tr, 7, false)) + assert.True(t, tr.AllowAssignments, "9 is still covered") + + // Asked for in this very run, it is refused rather than quietly dropped. + tr = admission.Trust{Mode: admission.TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{7}, AllowAssignments: true} + assert.Error(t, SetOperator(&tr, 7, true)) +} diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index e24bf2a63..620b62537 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -75,6 +75,7 @@ Each becomes a flag on setup (*First-time setup*, step 4): | "me and Jane" | `--allow ` (and everyone else who stays: the list is replaced) | | "anyone in the project can ask it things" | `--trust project` (they ask as participants) | | "Jane operates it, and anyone in the project can ask" | `--trust project --allow ` | +| "the people I named can assign it work too" | `--allow-assignments-from-authorized`: it covers the whole allowlist setup leaves (this run's `--allow`, or the list kept), not one person, so say who that is and confirm. With nobody on the allowlist setup refuses it, so name someone first. A run that passes `--allow` turns it off unless it's passed again | | "also work in project X" | `--serve ` | | "stop working in X" | `--unserve ` | @@ -193,11 +194,13 @@ The operator and the people named with `--allow` are **operators**. Project members admitted only by `project` are **participants**: they reach the agent by mention, by commenting on a thread it follows, and by completing something it has a stake in (context, not a request), never by assignment. Assignments -count only from the operator, in every mode. For a -personal agent pass no operator flag: setup takes its owner. For any other -agent, name the operator by their own CLI profile with `--operator-profile -''`. For allowlist, or operators beside project trust, look up each -person's id with `basecamp people list --json`, +count only from the operator, in every mode, unless they ask for the people +named with `--allow` to assign work too: `--allow-assignments-from-authorized` +(and `=false` to undo it), which covers everyone on the allowlist. For a personal agent pass no operator flag: setup +takes its owner. For any other agent, name the operator by their own CLI +profile with `--operator-profile ''`. For allowlist, or operators +beside project trust, look up each person's id with +`basecamp people list --json`, and pass `--allow ` for each. A run that passes `--allow` replaces the list, a run that passes none keeps it, and `--trust operator` clears it; setup refuses `--trust operator` with @@ -216,8 +219,9 @@ each project by name. Then: basecamp connect setup -P '' --serve --serve --json ``` -adding `--trust`, `--allow` or `--operator-profile` as chosen, and on the -bot-user path `--expect-identity `. +adding `--trust`, `--allow`, `--allow-assignments-from-authorized` or +`--operator-profile` as chosen, and on the bot-user path +`--expect-identity `. ### Reading setup's result @@ -309,7 +313,9 @@ thing to check. - **`trigger`**: why it reached you. - `mentioned`: someone @mentioned the agent. - - `assigned`: the operator assigned it a card, to-do or step. + - `assigned`: the operator, or someone they opted in, assigned it a card, + to-do or step. Only when the recording's events show this assignment + added the agent, and the agent is still assigned. - `subscribed`: a new comment on a thread the agent follows, with no mention. - `completed`: something completed in a project it watches. - **`acknowledge`**: true when a person asked for something. False for