Skip to content

Commit 9b94309

Browse files
leo-aa88cursoragent
andcommitted
fix(attribution): address PR #141 review — guardrails, tests, request_id
Add AGENTCTL_REQUIRE_ATTRIBUTION/--require-attribution guardrail and stderr warning when local defaults apply; env overrides for tenant/thread/actor. Consolidate request_id generation in StartRun (UUID). Document idempotency as stored-only metadata. Remove redundant GetRun in executeEngine; add resume attribution, OTel span attribute, and actor-only filter tests. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5c27d3b commit 9b94309

16 files changed

Lines changed: 576 additions & 57 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2121

2222
### Changed
2323

24+
- **`agentctl logs` table output** (issue #111): the default (non-JSON) run list adds `TENANT`, `THREAD`, and `ACTOR` columns. Scripts that parse fixed column positions should switch to `-o json` or match by header names.
2425
- **Breaking — tool calls without explicit policy are no longer unrestricted.** Previously, `CheckToolCall` with a nil [spec.PolicySpec] allowed all tools. Now fail-closed safety always applies from the project graph (even when the workflow omits `spec.policy` or the Policy resource is missing).
2526
- Tools with **no** `spec.safety` block behave as **untrusted with side effects** after normalization → require `--approve` unless an explicit `approvals.requiredFor` rule matches.
2627

docs/ATTRIBUTION.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Issue [#111](https://github.com/LAA-Software-Engineering/agentic-control-plane/i
1111
| `actor_id` | Who triggered the run (caller-asserted for now) |
1212
| `parent_run_id` | Lineage for sub-runs (not set on resume of the same run) |
1313
| `request_id` | Per-invocation correlation id (distinct from `run_id`) |
14-
| `idempotency_key` | Optional dedupe key for accidental re-triggers |
14+
| `idempotency_key` | Client reference key (stored only; dedupe is not enforced yet) |
1515
| `source` | Origin label (`cli`, `actions`, `api`, …) |
1616

1717
Trace events duplicate `tenant_id`, `thread_id`, and `actor_id` from the parent run so `logs` and the inspector can filter without joins.
@@ -25,7 +25,14 @@ When flags are omitted, `agentctl run` stores:
2525
- `actor_id`: `user-1`
2626
- `source`: `cli`
2727

28-
**Do not rely on these defaults in CI or production.** Pass real actor ids (for example the CI principal) and include tenant/environment context in `thread_id`.
28+
**Do not rely on these defaults in CI or production.** A stderr warning is emitted when defaults apply. For CI/prod, pass real actor ids, set env vars, or enable the guardrail:
29+
30+
```bash
31+
export AGENTCTL_REQUIRE_ATTRIBUTION=1
32+
# or: agentctl run ... --require-attribution --tenant-id ... --thread-id ... --actor-id ...
33+
```
34+
35+
Env overrides when flags are omitted: `AGENTCTL_TENANT_ID`, `AGENTCTL_THREAD_ID`, `AGENTCTL_ACTOR_ID`.
2936

3037
```bash
3138
agentctl run workflow/demo \
@@ -58,7 +65,15 @@ agentctl logs --tenant-id acme --thread-id prod-review-42
5865

5966
When telemetry is enabled, spans emit `gen_ai.tenant.id`, `gen_ai.thread.id`, `gen_ai.actor.id`, `gen_ai.run.id`, and `gen_ai.request.id` alongside existing gen_ai attributes. See [OTEL.md](./OTEL.md).
6067

68+
## request_id
69+
70+
When omitted, [state.RuntimeStore.StartRun] assigns a new UUID via `util.NewRequestID()`. Legacy rows migrated from pre-005 databases may have `request_id == run_id`.
71+
72+
## Idempotency key
73+
74+
`idempotency_key` is persisted and exposed in JSON for future dedupe. There is no unique index or at-most-once enforcement in this release — do not assume idempotent execution from the key alone.
75+
6176
## Production guidance
6277

6378
- SQLite attribution is advisory; DB-level tenant isolation belongs to a future remote/Postgres store.
64-
- `actor_id` is supplied by the caller and is not authenticated in this release.
79+
- `actor_id` is supplied by the caller and is not authenticated in this release. Do not use attribution for access control.

internal/cli/attribution.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"strings"
8+
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/runtime"
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
11+
)
12+
13+
// EnvRequireAttribution, when set to a truthy value, requires explicit tenant/thread/actor ids on run.
14+
const EnvRequireAttribution = "AGENTCTL_REQUIRE_ATTRIBUTION"
15+
16+
// EnvTenantID overrides --tenant-id when the flag is omitted.
17+
const EnvTenantID = "AGENTCTL_TENANT_ID"
18+
19+
// EnvThreadID overrides --thread-id when the flag is omitted.
20+
const EnvThreadID = "AGENTCTL_THREAD_ID"
21+
22+
// EnvActorID overrides --actor-id when the flag is omitted.
23+
const EnvActorID = "AGENTCTL_ACTOR_ID"
24+
25+
func resolveRunAttributionFlags(tenantID, threadID, actorID, parentRunID, requestID, idempotencyKey, source string, requireFlag bool) runtime.WorkflowRunOptions {
26+
return runtime.WorkflowRunOptions{
27+
TenantID: firstNonEmpty(strings.TrimSpace(tenantID), os.Getenv(EnvTenantID)),
28+
ThreadID: firstNonEmpty(strings.TrimSpace(threadID), os.Getenv(EnvThreadID)),
29+
ActorID: firstNonEmpty(strings.TrimSpace(actorID), os.Getenv(EnvActorID)),
30+
ParentRunID: strings.TrimSpace(parentRunID),
31+
RequestID: strings.TrimSpace(requestID),
32+
IdempotencyKey: strings.TrimSpace(idempotencyKey),
33+
Source: strings.TrimSpace(source),
34+
RequireAttribution: requireFlag || envTruthy(EnvRequireAttribution),
35+
}
36+
}
37+
38+
func applyRunAttributionOpts(opts *runtime.WorkflowRunOptions, tenantID, threadID, actorID, parentRunID, requestID, idempotencyKey, source string, requireFlag bool) {
39+
if opts == nil {
40+
return
41+
}
42+
resolved := resolveRunAttributionFlags(tenantID, threadID, actorID, parentRunID, requestID, idempotencyKey, source, requireFlag)
43+
opts.TenantID = resolved.TenantID
44+
opts.ThreadID = resolved.ThreadID
45+
opts.ActorID = resolved.ActorID
46+
opts.ParentRunID = resolved.ParentRunID
47+
opts.RequestID = resolved.RequestID
48+
opts.IdempotencyKey = resolved.IdempotencyKey
49+
opts.Source = resolved.Source
50+
opts.RequireAttribution = resolved.RequireAttribution
51+
}
52+
53+
// warnAttributionDefaults writes a one-line stderr warning when local attribution defaults apply.
54+
func warnAttributionDefaults(w io.Writer, attr state.RunAttribution) {
55+
if w == nil || !state.UsesAttributionDefaults(attr) {
56+
return
57+
}
58+
_, _ = fmt.Fprintf(w, "warning: run attribution using local defaults (tenant-1/thread-1/user-1); set --tenant-id, --thread-id, and --actor-id or AGENTCTL_REQUIRE_ATTRIBUTION=1 in CI/prod\n")
59+
}
60+
61+
func firstNonEmpty(values ...string) string {
62+
for _, v := range values {
63+
if strings.TrimSpace(v) != "" {
64+
return strings.TrimSpace(v)
65+
}
66+
}
67+
return ""
68+
}
69+
70+
func envTruthy(name string) bool {
71+
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
72+
case "1", "true", "yes", "on":
73+
return true
74+
default:
75+
return false
76+
}
77+
}

internal/cli/attribution_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"strings"
7+
"testing"
8+
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
10+
)
11+
12+
func TestResolveRunAttributionFlags_envOverrides(t *testing.T) {
13+
t.Setenv(EnvTenantID, "env-tenant")
14+
t.Setenv(EnvThreadID, "env-thread")
15+
t.Setenv(EnvActorID, "env-actor")
16+
17+
got := resolveRunAttributionFlags("", "", "", "", "", "", "", false)
18+
if got.TenantID != "env-tenant" || got.ThreadID != "env-thread" || got.ActorID != "env-actor" {
19+
t.Fatalf("env overrides: %+v", got)
20+
}
21+
22+
got = resolveRunAttributionFlags("flag-tenant", "", "", "", "", "", "", false)
23+
if got.TenantID != "flag-tenant" {
24+
t.Fatalf("flag wins: %+v", got)
25+
}
26+
}
27+
28+
func TestWarnAttributionDefaults(t *testing.T) {
29+
var buf bytes.Buffer
30+
warnAttributionDefaults(&buf, state.RunAttribution{})
31+
if !strings.Contains(buf.String(), "warning:") || !strings.Contains(buf.String(), "tenant-1") {
32+
t.Fatalf("warn: %q", buf.String())
33+
}
34+
buf.Reset()
35+
warnAttributionDefaults(&buf, state.RunAttribution{TenantID: "t", ThreadID: "th", ActorID: "a"})
36+
if buf.Len() != 0 {
37+
t.Fatalf("no warn expected: %q", buf.String())
38+
}
39+
}
40+
41+
func TestRun_requireAttributionRejectsDefaults(t *testing.T) {
42+
root := runProjRoot(t)
43+
db := t.TempDir() + "/req.db"
44+
45+
ResetGlobalsForTest()
46+
cmd := NewRootCmd()
47+
cmd.SetOut(io.Discard)
48+
var errBuf bytes.Buffer
49+
cmd.SetErr(&errBuf)
50+
cmd.SetArgs([]string{
51+
"run", "workflow/demo",
52+
"--project", root,
53+
"--state", db,
54+
"--input", "topic=x",
55+
"--require-attribution",
56+
})
57+
err := cmd.Execute()
58+
if err == nil {
59+
t.Fatal("expected validation error")
60+
}
61+
if !strings.Contains(err.Error(), "attribution required") {
62+
t.Fatalf("err = %v", err)
63+
}
64+
}
65+
66+
func TestRun_requireAttributionViaEnv(t *testing.T) {
67+
t.Setenv(EnvRequireAttribution, "1")
68+
root := runProjRoot(t)
69+
db := t.TempDir() + "/req-env.db"
70+
71+
ResetGlobalsForTest()
72+
cmd := NewRootCmd()
73+
cmd.SetOut(io.Discard)
74+
cmd.SetErr(io.Discard)
75+
cmd.SetArgs([]string{
76+
"run", "workflow/demo",
77+
"--project", root,
78+
"--state", db,
79+
"--input", "topic=x",
80+
})
81+
err := cmd.Execute()
82+
if err == nil {
83+
t.Fatal("expected validation error")
84+
}
85+
if !strings.Contains(err.Error(), "attribution required") {
86+
t.Fatalf("err = %v", err)
87+
}
88+
}
89+
90+
func TestRun_warnsOnDefaultAttribution(t *testing.T) {
91+
root := runProjRoot(t)
92+
db := t.TempDir() + "/warn.db"
93+
94+
ResetGlobalsForTest()
95+
cmd := NewRootCmd()
96+
cmd.SetOut(io.Discard)
97+
var errBuf bytes.Buffer
98+
cmd.SetErr(&errBuf)
99+
cmd.SetArgs([]string{
100+
"run", "workflow/demo",
101+
"--project", root,
102+
"--state", db,
103+
"--input", "topic=warn-test",
104+
})
105+
if err := cmd.Execute(); err != nil {
106+
t.Fatal(err)
107+
}
108+
if !strings.Contains(errBuf.String(), "warning:") {
109+
t.Fatalf("stderr = %q", errBuf.String())
110+
}
111+
}
112+
113+
func TestEnvTruthy(t *testing.T) {
114+
t.Setenv("AGENTCTL_TEST_TRUTHY", "yes")
115+
if !envTruthy("AGENTCTL_TEST_TRUTHY") {
116+
t.Fatal("expected truthy")
117+
}
118+
t.Setenv("AGENTCTL_TEST_TRUTHY", "0")
119+
if envTruthy("AGENTCTL_TEST_TRUTHY") {
120+
t.Fatal("expected false")
121+
}
122+
}

internal/cli/logs_attribution_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,50 @@ func TestLogs_runListShowsAttributionColumns(t *testing.T) {
103103
}
104104
}
105105

106+
func TestLogs_filterByActorOnly(t *testing.T) {
107+
ctx := context.Background()
108+
db := filepath.Join(t.TempDir(), "logs-actor.db")
109+
st, err := sqlite.Open(ctx, db)
110+
if err != nil {
111+
t.Fatal(err)
112+
}
113+
t.Cleanup(func() { _ = st.Close() })
114+
115+
start := time.Now().UTC()
116+
for _, r := range []state.Run{
117+
{RunID: "a", WorkflowName: "wf", Env: "local", Status: "succeeded", StartedAt: start,
118+
InputJSON: `{}`, TenantID: "t1", ThreadID: "th1", ActorID: "target", RequestID: "r1", Source: "cli"},
119+
{RunID: "b", WorkflowName: "wf", Env: "local", Status: "succeeded", StartedAt: start,
120+
InputJSON: `{}`, TenantID: "t1", ThreadID: "th1", ActorID: "other", RequestID: "r2", Source: "cli"},
121+
} {
122+
if err := st.StartRun(ctx, r); err != nil {
123+
t.Fatal(err)
124+
}
125+
}
126+
127+
root := runProjRoot(t)
128+
ResetGlobalsForTest()
129+
var out bytes.Buffer
130+
cmd := NewRootCmd()
131+
cmd.SetOut(&out)
132+
cmd.SetErr(&out)
133+
cmd.SetArgs([]string{"logs", "--actor-id", "target", "--project", root, "--state", db, "-o", "json"})
134+
if err := cmd.Execute(); err != nil {
135+
t.Fatal(err)
136+
}
137+
var payload struct {
138+
Runs []struct {
139+
RunID string `json:"runId"`
140+
} `json:"runs"`
141+
}
142+
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
143+
t.Fatal(err)
144+
}
145+
if len(payload.Runs) != 1 || payload.Runs[0].RunID != "a" {
146+
t.Fatalf("runs: %+v", payload.Runs)
147+
}
148+
}
149+
106150
func TestLogs_rejectsRunWithTenantFilter(t *testing.T) {
107151
ResetGlobalsForTest()
108152
cmd := NewRootCmd()

internal/cli/run.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func newRunCmd() *cobra.Command {
3737
var requestID string
3838
var idempotencyKey string
3939
var source string
40+
var requireAttribution bool
4041

4142
cmd := &cobra.Command{
4243
Use: "run workflow/<name>",
@@ -54,8 +55,10 @@ When a run pauses for human approval, resume with --decision and related flags,
5455
--auto-approve / AGENTCTL_AUTO_APPROVE=1 for non-interactive approval.
5556
5657
Attribution flags (--tenant-id, --thread-id, --actor-id) scope runs for multi-tenant logs and
57-
compliance. When omitted, local defaults apply (tenant-1 / thread-1 / user-1). Never rely on
58-
defaults in CI or production; pass real actor ids and include tenant/env in thread_id.
58+
compliance. When omitted, local defaults apply (tenant-1 / thread-1 / user-1) with a stderr
59+
warning. Never rely on defaults in CI or production; pass real actor ids, set
60+
AGENTCTL_REQUIRE_ATTRIBUTION=1, or use --require-attribution. Env overrides: AGENTCTL_TENANT_ID,
61+
AGENTCTL_THREAD_ID, AGENTCTL_ACTOR_ID. The idempotency-key field is stored metadata only (no dedupe yet).
5962
6063
Examples:
6164
agentctl run workflow/demo --input topic=hello
@@ -92,7 +95,7 @@ Exit codes (section 11.2):
9295
}
9396
}
9497
return runRun(cmd, wfName, resumeRunID, inputFile, inputPairs, approves, autoApprove, decision, decisionEditJSON, decisionSwitchTarget,
95-
tenantID, threadID, actorID, parentRunID, requestID, idempotencyKey, source)
98+
tenantID, threadID, actorID, parentRunID, requestID, idempotencyKey, source, requireAttribution)
9699
},
97100
}
98101
cmd.Flags().StringVar(&inputFile, "input-file", "", "path to JSON file with workflow input object")
@@ -108,8 +111,9 @@ Exit codes (section 11.2):
108111
cmd.Flags().StringVar(&actorID, "actor-id", "", "who triggered this run (default: user-1; use a real principal in CI/prod)")
109112
cmd.Flags().StringVar(&parentRunID, "parent-run-id", "", "origin run for sub-runs (not used for --resume of the same run)")
110113
cmd.Flags().StringVar(&requestID, "request-id", "", "per-invocation correlation id (generated when omitted)")
111-
cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "optional dedupe key for accidental re-triggers")
114+
cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "client reference key stored on the run (dedupe not enforced yet)")
112115
cmd.Flags().StringVar(&source, "source", "", "run origin label (default: cli)")
116+
cmd.Flags().BoolVar(&requireAttribution, "require-attribution", false, "require explicit --tenant-id, --thread-id, and --actor-id (or set AGENTCTL_REQUIRE_ATTRIBUTION=1)")
113117
return cmd
114118
}
115119

@@ -172,6 +176,9 @@ func classifyRunError(err error) int {
172176
if _, ok := policy.AsDenied(err); ok {
173177
return ExitPolicyDenied
174178
}
179+
if errors.Is(err, state.ErrAttributionRequired) {
180+
return ExitValidationError
181+
}
175182
msg := err.Error()
176183
switch {
177184
case strings.Contains(msg, "validate project"):
@@ -198,7 +205,7 @@ func classifyRunError(err error) int {
198205
}
199206

200207
func runRun(cmd *cobra.Command, wfName, resumeRunID, inputFile string, inputPairs, approves []string, autoApprove bool, decision, decisionEditJSON, decisionSwitchTarget string,
201-
tenantID, threadID, actorID, parentRunID, requestID, idempotencyKey, source string) error {
208+
tenantID, threadID, actorID, parentRunID, requestID, idempotencyKey, source string, requireAttribution bool) error {
202209
ctx := context.Background()
203210
g := Globals()
204211

@@ -248,13 +255,10 @@ func runRun(cmd *cobra.Command, wfName, resumeRunID, inputFile string, inputPair
248255
RunID: resumeID,
249256
}
250257
if resumeID == "" {
251-
opts.TenantID = strings.TrimSpace(tenantID)
252-
opts.ThreadID = strings.TrimSpace(threadID)
253-
opts.ActorID = strings.TrimSpace(actorID)
254-
opts.ParentRunID = strings.TrimSpace(parentRunID)
255-
opts.RequestID = strings.TrimSpace(requestID)
256-
opts.IdempotencyKey = strings.TrimSpace(idempotencyKey)
257-
opts.Source = strings.TrimSpace(source)
258+
applyRunAttributionOpts(&opts, tenantID, threadID, actorID, parentRunID, requestID, idempotencyKey, source, requireAttribution)
259+
warnAttributionDefaults(cmd.ErrOrStderr(), state.RunAttribution{
260+
TenantID: opts.TenantID, ThreadID: opts.ThreadID, ActorID: opts.ActorID,
261+
})
258262
}
259263
if err := applyHitlRunOptions(&opts, resumeID != "", autoApprove, decision, decisionEditJSON, decisionSwitchTarget); err != nil {
260264
return NewExitError(ExitValidationError, err)

0 commit comments

Comments
 (0)