Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c3710aa
Tell operators from participants on every connect request
jeremy Oct 6, 2026
339c48f
Assert why setup refuses, and the role field itself
jeremy Oct 6, 2026
e8af7b7
Admit a participant's completion as context, marked as theirs
jeremy Oct 6, 2026
c39f86d
Say exactly when --allow implies allowlist, and when a run replaces t…
jeremy Oct 6, 2026
83548e8
Keep the named operators when a run under project trust names none
jeremy Oct 6, 2026
6a86a03
Say members join project trust as participants, and name the role pla…
jeremy Oct 6, 2026
8988239
Look the operator up in the connector's account, not the login's default
jeremy Oct 6, 2026
a022d48
Keep pull requests an operator's call, and ask the operator privately
jeremy Oct 6, 2026
6693c86
Don't guess which ask a bare go approves
jeremy Oct 6, 2026
9d5fdc4
Name the member who wrote the words as the requester, and hand every …
jeremy Oct 6, 2026
d71e742
Take the main session's word as the operator's only when the operator…
jeremy Oct 6, 2026
1cec6a8
Let an operator's word approve in one form only: their mention on the…
jeremy Oct 6, 2026
73bca23
Treat any role but operator as a participant's, and finish the ping's…
jeremy Oct 6, 2026
890ca2b
Approve only on an operator's go, and test that --allow replaces kept…
jeremy Oct 6, 2026
57754f3
Make the ask line the whole record of an approval, and name the agent…
jeremy Oct 6, 2026
dcb99fa
Keep a participant from choosing a local repo, mark a done ask in the…
jeremy Oct 6, 2026
b0015df
Ask the operator in the participant's thread, and drop the private ping
jeremy Oct 6, 2026
0a0c734
Push only on an operator's go, act on that go wherever it lands, and …
jeremy Oct 6, 2026
4294a69
Say setup refuses --trust operator with --allow
jeremy Oct 6, 2026
164bb78
Take only a mentioned go as approval, ask for non-code actions by nam…
jeremy Oct 6, 2026
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
14 changes: 9 additions & 5 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any
for i, id := range f.Trust.AllowlistIDs {
ids[i] = strconv.FormatInt(id, 10)
}
trust += ": people " + strings.Join(ids, ", ")
trust += ": operators " + strings.Join(ids, ", ")
}
d := map[string]any{
"file": exact(path),
Expand Down Expand Up @@ -305,9 +305,12 @@ the operator connect.json already has; on a first setup of an agent with no
owner, one of the two is required.

Trust. operator (default): the operator alone. allowlist: the operator and
the people passed with --allow. project: the operator and any non-client
member of the event's project. Assignments are the operator's alone in every
mode.
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.

Projects. connect.json is the local list of Basecamp projects this agent
serves: --serve <project-id>, --unserve <project-id>. Nothing in a project it
Expand Down Expand Up @@ -348,6 +351,7 @@ Examples:
basecamp connect setup -P agent --serve 12345 # a personal agent: its owner operates it
basecamp connect setup -P agent --operator-profile me --serve 12345
basecamp connect setup -P agent --operator-profile me --trust allowlist --allow 111 --allow 222
basecamp connect setup -P agent --operator-profile me --trust project --allow 111
basecamp connect setup -P bot --operator-profile me --expect-identity 4242 --serve 12345
basecamp connect setup -P agent --class 12345=internal`,
Annotations: map[string]string{AnnotationProfileMayCreate: "true"},
Expand All @@ -369,7 +373,7 @@ Examples:
fl.StringVar(&f.operator, "operator", "", "Person id of the operator the agent follows")
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 besides the operator (repeatable; implies --trust allowlist)")
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.StringArrayVar(&f.serve, "serve", nil, "Serve a Basecamp project: <project-id> (repeatable)")
fl.StringArrayVar(&f.unserve, "unserve", nil, "Stop serving a project (repeatable)")
fl.StringArrayVar(&f.classes, "class", nil, "Classify a served project: <project-id>=<class>, or <project-id>= to clear it (repeatable)")
Expand Down
29 changes: 28 additions & 1 deletion internal/commands/connect_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ func TestConnectSetupRefusesBadInput(t *testing.T) {
for name, args := range map[string][]string{
"class without a served project": {"--operator", op, "--class", fmt.Sprintf("%d=internal", setupProject)},
"bad trust mode": {"--operator", op, "--trust", "domain"},
"allow outside allowlist": {"--operator", op, "--trust", "project", "--allow", "7"},
"allow under operator trust": {"--operator", op, "--trust", "operator", "--allow", "7"},
"malformed serve": {"--operator", op, "--serve", "not-a-number"},
"zero serve": {"--operator", op, "--serve", "0"},
"no operator": {serveArg()},
Expand Down Expand Up @@ -1018,6 +1018,33 @@ func TestConnectSetupVerifiesTheAllowlistBeforeWriting(t *testing.T) {
assert.Equal(t, []int64{setupOperatorPerson + 1}, f.Trust.AllowlistIDs)
}

// Named operators beside the project's members: the same verification, and
// the file says both.
func TestConnectSetupNamesOperatorsAlongsideProjectTrust(t *testing.T) {
for name, id := range map[string]int64{"unknown person": 424242, "client": setupClientPerson} {
t.Run(name, func(t *testing.T) {
s := startConnectSetupServer(t)
connectSetupApp(t, s, "agent")
storeConnectProfile(t, s, "me", setupOperatorToken)

out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", "--trust", "project", "--allow", fmt.Sprint(id), serveArg())
require.Error(t, err, out)
Comment thread
jeremy marked this conversation as resolved.
assert.Contains(t, err.Error(), fmt.Sprintf("Allowlist %d", id))
assertNotWritten(t, "agent")
})
}

s := startConnectSetupServer(t)
connectSetupApp(t, s, "agent")
storeConnectProfile(t, s, "me", setupOperatorToken)
out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), "--operator-profile", "me", "--trust", "project", "--allow", fmt.Sprint(setupOperatorPerson+1), serveArg())
require.NoError(t, err, out)
f, err := setup.Load(connectSetupPath(t, "agent"))
require.NoError(t, err)
assert.Equal(t, admission.TrustProject, f.Trust.Mode)
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) {
Expand Down
13 changes: 12 additions & 1 deletion internal/connector/admission/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// least one of the type's triggers, the bucket is in scope, and a trigger
// that only a served project admits is in one. Most account traffic
// ends here, for the price of its pointer.
// 2. In project trust mode a performer other than the operator is confirmed
// 2. In project trust mode a performer the policy does not name is confirmed
// as a non-client member of the project. That is a read, cached per
// project, and it is the one trust decision the pointer alone cannot make.
// 3. The RecordingSummary read (the SDK's step-10 helper) resolves the
Expand Down Expand Up @@ -38,6 +38,17 @@
// 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.
//
// Trust has two roles. Operators are the operator and the people the
// allowlist names, in allowlist or project mode; their word authorizes the
// agent. Participants are the members project mode admits besides them:
// they reach the agent by mention, by comment on a thread it follows, and by
// a completion it has a stake in, never by assignment. Every admitted
// verdict carries its Role, the lesser of the performer's and, for mentioned
// and subscribed, the author's, so words a participant wrote stay a
// participant's request whoever brought them in.
// Admission only labels; what a participant may get the agent to do is the
// policy of whoever reads the request.
//
// # Invariants
//
// Every rule below is held by a test that fails without it.
Expand Down
13 changes: 8 additions & 5 deletions internal/connector/admission/gate.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package admission

import "slices"

// GateResult is the gate's reading of a pointer.
type GateResult struct {
// Rules are the matrix rules still open after the gate, in matrix order.
Expand All @@ -11,8 +9,11 @@ type GateResult struct {
Reason Reason
// ConfirmMembership says the performer is trusted only if a read confirms
// they are a non-client member of the project (project trust mode, a
// performer other than the operator). The gate cannot see membership.
// performer the policy does not name). The gate cannot see membership.
ConfirmMembership bool
// Role is the performer's: operator when the policy names them,
// participant when only membership can trust them.
Role Role
}

// Discarded reports whether the gate ended the event.
Expand Down Expand Up @@ -52,6 +53,7 @@ func Gate(ev Event, p Policy, m Matrix) GateResult {

performer := ev.Performer()
isOperator := performer == p.Trust.OperatorID
role := p.Trust.roleOf(performer)
_, served := p.served(ev.BucketID)

var (
Expand All @@ -71,7 +73,8 @@ func Gate(ev Event, p Policy, m Matrix) GateResult {
case rule.OperatorOnly:
drop(ReasonAssignmentNotOperator)
continue
case p.Trust.Mode == TrustAllowlist && slices.Contains(p.Trust.AllowlistIDs, performer):
case role == RoleOperator:
// Named in the allowlist; Validate keeps one out of operator mode.
case p.Trust.Mode == TrustProject:
needsMembership = true
default:
Expand All @@ -88,5 +91,5 @@ func Gate(ev Event, p Policy, m Matrix) GateResult {
if len(open) == 0 {
return GateResult{Reason: reason}
}
return GateResult{Rules: open, ConfirmMembership: membership}
return GateResult{Rules: open, ConfirmMembership: membership, Role: role}
}
51 changes: 43 additions & 8 deletions internal/connector/admission/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,54 @@ const (
TrustOperator TrustMode = "operator"
// TrustAllowlist trusts the operator and a list of Person ids.
TrustAllowlist TrustMode = "allowlist"
// TrustProject trusts the operator and any non-client member of the
// event's project.
// TrustProject trusts the operator, anyone in the allowlist, and any
// non-client member of the event's project — the members as
// participants.
TrustProject TrustMode = "project"
)

// Role is whose request an admitted event is. Operators are the people whose
// word authorizes the agent: the operator, and anyone named in the allowlist.
// Participants are the wider set project trust admits — any non-client
// member of the project — whose requests reach the agent but carry no
// authority of their own. Admission says which; what each may get the agent
// to do is the policy of whoever reads the request.
type Role string

const (
RoleOperator Role = "operator"
RoleParticipant Role = "participant"
)

// Trust is the trust section of connect.json.
type Trust struct {
Mode TrustMode `json:"mode"`
// OperatorID is the Person id of the person who approved the connection.
OperatorID int64 `json:"operator_id"`
// AllowlistIDs are the Person ids trusted in allowlist mode.
// AllowlistIDs are the Person ids trusted as operators besides the
// operator, in allowlist or project mode.
AllowlistIDs []int64 `json:"allowlist_ids,omitempty"`
}

// roleOf is the role a trusted person holds, before any membership read: an
// operator when the policy names them, otherwise a participant, whom only
// project mode admits and only once membership confirms them.
func (t Trust) roleOf(person int64) Role {
if person == t.OperatorID || slices.Contains(t.AllowlistIDs, person) {
return RoleOperator
}
return RoleParticipant
}

// least is the lesser of two roles: a request any participant had a hand in
// carries no operator's word.
func least(a, b Role) Role {
if a == RoleOperator && b == RoleOperator {
return RoleOperator
}
return RoleParticipant
}

// Project is one served project's entry in connect.json: a project the agent
// may be driven from, and how admission treats it. The entries are local:
// nothing read from Basecamp can add or change one, which is what makes the
Expand Down Expand Up @@ -274,14 +308,15 @@ func (p Policy) Validate() error {
return errors.New("admission: the operator cannot be the agent itself")
}
switch p.Trust.Mode {
case TrustOperator, TrustProject:
case TrustOperator:
if len(p.Trust.AllowlistIDs) > 0 {
return fmt.Errorf("admission: allowlist_ids is set but trust mode is %q", p.Trust.Mode)
}
case TrustAllowlist:
// The agent in its own allowlist is a configuration that reads as
// "the agent may drive itself". The gate refuses the agent regardless;
// the policy says so here too, where the operator can see it.
case TrustAllowlist, TrustProject:
// Named operators, alone or beside the project's members. The agent
// in its own allowlist is a configuration that reads as "the agent
// may drive itself". The gate refuses the agent regardless; the
// policy says so here too, where the operator can see it.
for _, id := range p.Trust.AllowlistIDs {
if id <= 0 {
return fmt.Errorf("admission: allowlist id %d is not a Person id", id)
Expand Down
9 changes: 7 additions & 2 deletions internal/connector/admission/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ func TestValidateFailsClosed(t *testing.T) {
"agent in a longer allowlist": func(p *Policy) {
p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{allowedID, agentID}}
},
"allowlist ids in project mode": func(p *Policy) {
p.Trust = Trust{Mode: TrustProject, OperatorID: operatorID, AllowlistIDs: []int64{allowedID}}
// Project mode names operators beside its members, and holds the
// names to the same checks.
"agent among project mode's operators": func(p *Policy) {
p.Trust = Trust{Mode: TrustProject, OperatorID: operatorID, AllowlistIDs: []int64{allowedID, agentID}}
},
"non-positive id among project mode's operators": func(p *Policy) {
p.Trust = Trust{Mode: TrustProject, OperatorID: operatorID, AllowlistIDs: []int64{-1}}
},
} {
t.Run(name, func(t *testing.T) {
Expand Down
Loading
Loading