@calltelemetry/openclaw-linear 0.8.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +194 -91
  2. package/index.ts +36 -4
  3. package/package.json +1 -1
  4. package/src/__test__/webhook-scenarios.test.ts +1 -1
  5. package/src/gateway/dispatch-methods.test.ts +9 -9
  6. package/src/infra/commands.test.ts +5 -5
  7. package/src/infra/config-paths.test.ts +246 -0
  8. package/src/infra/doctor.ts +45 -36
  9. package/src/infra/notify.test.ts +49 -0
  10. package/src/infra/notify.ts +7 -2
  11. package/src/infra/observability.ts +1 -0
  12. package/src/infra/shared-profiles.test.ts +262 -0
  13. package/src/infra/shared-profiles.ts +116 -0
  14. package/src/infra/template.test.ts +86 -0
  15. package/src/infra/template.ts +18 -0
  16. package/src/infra/validation.test.ts +175 -0
  17. package/src/infra/validation.ts +52 -0
  18. package/src/pipeline/active-session.test.ts +2 -2
  19. package/src/pipeline/agent-end-hook.test.ts +305 -0
  20. package/src/pipeline/artifacts.test.ts +3 -3
  21. package/src/pipeline/dispatch-state.test.ts +111 -8
  22. package/src/pipeline/dispatch-state.ts +48 -13
  23. package/src/pipeline/e2e-dispatch.test.ts +2 -2
  24. package/src/pipeline/intent-classify.test.ts +20 -2
  25. package/src/pipeline/intent-classify.ts +14 -24
  26. package/src/pipeline/pipeline.ts +28 -11
  27. package/src/pipeline/planner.ts +1 -8
  28. package/src/pipeline/planning-state.ts +9 -0
  29. package/src/pipeline/tier-assess.test.ts +39 -39
  30. package/src/pipeline/tier-assess.ts +15 -33
  31. package/src/pipeline/webhook.test.ts +149 -1
  32. package/src/pipeline/webhook.ts +90 -62
  33. package/src/tools/dispatch-history-tool.test.ts +21 -20
  34. package/src/tools/dispatch-history-tool.ts +1 -1
  35. package/src/tools/linear-issues-tool.test.ts +115 -0
  36. package/src/tools/linear-issues-tool.ts +25 -0
@@ -45,6 +45,7 @@ vi.mock("../api/linear-api.js", () => ({
45
45
  }));
46
46
 
47
47
  import { createLinearIssuesTool } from "./linear-issues-tool.js";
48
+ import { isValidIssueId as isValidLinearId } from "../infra/validation.js";
48
49
 
49
50
  // ---------------------------------------------------------------------------
50
51
  // Helpers
@@ -91,6 +92,55 @@ beforeEach(() => {
91
92
  });
92
93
  });
93
94
 
95
+ describe("isValidLinearId", () => {
96
+ it("accepts TEAM-123 format identifiers", () => {
97
+ expect(isValidLinearId("ENG-123")).toBe(true);
98
+ expect(isValidLinearId("API-1")).toBe(true);
99
+ expect(isValidLinearId("CT-42")).toBe(true);
100
+ expect(isValidLinearId("A-1")).toBe(true);
101
+ expect(isValidLinearId("Team2-999")).toBe(true);
102
+ });
103
+
104
+ it("accepts UUID format identifiers", () => {
105
+ expect(isValidLinearId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
106
+ expect(isValidLinearId("08cba264-d774-4afd-bc93-ee8213d12ef8")).toBe(true);
107
+ expect(isValidLinearId("ABCDEF01-2345-6789-ABCD-EF0123456789")).toBe(true);
108
+ });
109
+
110
+ it("rejects empty strings", () => {
111
+ expect(isValidLinearId("")).toBe(false);
112
+ });
113
+
114
+ it("rejects SQL injection attempts", () => {
115
+ expect(isValidLinearId("ENG-123'; DROP TABLE issues; --")).toBe(false);
116
+ expect(isValidLinearId("' OR 1=1 --")).toBe(false);
117
+ });
118
+
119
+ it("rejects GraphQL injection attempts", () => {
120
+ expect(isValidLinearId('{ __schema { types { name } } }')).toBe(false);
121
+ expect(isValidLinearId("ENG-123\n{malicious}")).toBe(false);
122
+ });
123
+
124
+ it("rejects path traversal", () => {
125
+ expect(isValidLinearId("../../../etc/passwd")).toBe(false);
126
+ expect(isValidLinearId("..\\..\\..\\windows")).toBe(false);
127
+ });
128
+
129
+ it("rejects strings that look like IDs but have extra characters", () => {
130
+ expect(isValidLinearId("ENG-123-extra")).toBe(false);
131
+ expect(isValidLinearId("-123")).toBe(false);
132
+ expect(isValidLinearId("123-ENG")).toBe(false);
133
+ expect(isValidLinearId("ENG-")).toBe(false);
134
+ expect(isValidLinearId("ENG")).toBe(false);
135
+ });
136
+
137
+ it("rejects UUIDs with wrong format", () => {
138
+ expect(isValidLinearId("550e8400-e29b-41d4-a716")).toBe(false);
139
+ expect(isValidLinearId("not-a-uuid-at-all-nope")).toBe(false);
140
+ expect(isValidLinearId("550e8400e29b41d4a716446655440000")).toBe(false); // missing dashes
141
+ });
142
+ });
143
+
94
144
  describe("linear_issues tool", () => {
95
145
  describe("read action", () => {
96
146
  it("returns formatted issue details", async () => {
@@ -427,6 +477,71 @@ describe("linear_issues tool", () => {
427
477
  });
428
478
  });
429
479
 
480
+ describe("input validation", () => {
481
+ it("rejects invalid issueId format on read", async () => {
482
+ const result = parseResult(await executeTool({ action: "read", issueId: "'; DROP TABLE --" }));
483
+ expect(result.error).toMatch(/Invalid issueId format/);
484
+ expect(mockGetIssueDetails).not.toHaveBeenCalled();
485
+ });
486
+
487
+ it("rejects invalid issueId format on update", async () => {
488
+ const result = parseResult(await executeTool({ action: "update", issueId: "bad id!", status: "Done" }));
489
+ expect(result.error).toMatch(/Invalid issueId format/);
490
+ expect(mockGetIssueDetails).not.toHaveBeenCalled();
491
+ });
492
+
493
+ it("rejects invalid issueId format on comment", async () => {
494
+ const result = parseResult(await executeTool({ action: "comment", issueId: "{graphql}", body: "test" }));
495
+ expect(result.error).toMatch(/Invalid issueId format/);
496
+ expect(mockCreateComment).not.toHaveBeenCalled();
497
+ });
498
+
499
+ it("rejects invalid teamId format on create", async () => {
500
+ const result = parseResult(await executeTool({ action: "create", title: "Test", teamId: "invalid team!" }));
501
+ expect(result.error).toMatch(/Invalid teamId format/);
502
+ expect(mockCreateIssue).not.toHaveBeenCalled();
503
+ });
504
+
505
+ it("rejects invalid parentIssueId format on create", async () => {
506
+ const result = parseResult(await executeTool({ action: "create", title: "Test", parentIssueId: "not/valid" }));
507
+ expect(result.error).toMatch(/Invalid parentIssueId format/);
508
+ expect(mockGetIssueDetails).not.toHaveBeenCalled();
509
+ });
510
+
511
+ it("rejects invalid projectId format on create", async () => {
512
+ const result = parseResult(await executeTool({ action: "create", title: "Test", teamId: "team-1", projectId: "bad project" }));
513
+ expect(result.error).toMatch(/Invalid projectId format/);
514
+ expect(mockCreateIssue).not.toHaveBeenCalled();
515
+ });
516
+
517
+ it("rejects invalid teamId format on list_states", async () => {
518
+ const result = parseResult(await executeTool({ action: "list_states", teamId: "../../../etc/passwd" }));
519
+ expect(result.error).toMatch(/Invalid teamId format/);
520
+ expect(mockGetTeamStates).not.toHaveBeenCalled();
521
+ });
522
+
523
+ it("rejects invalid teamId format on list_labels", async () => {
524
+ const result = parseResult(await executeTool({ action: "list_labels", teamId: "' OR 1=1" }));
525
+ expect(result.error).toMatch(/Invalid teamId format/);
526
+ expect(mockGetTeamLabels).not.toHaveBeenCalled();
527
+ });
528
+
529
+ it("accepts valid TEAM-123 issueId", async () => {
530
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
531
+ const result = parseResult(await executeTool({ action: "read", issueId: "ENG-123" }));
532
+ expect(result.error).toBeUndefined();
533
+ expect(mockGetIssueDetails).toHaveBeenCalledWith("ENG-123");
534
+ });
535
+
536
+ it("accepts valid UUID issueId", async () => {
537
+ const uuid = "08cba264-d774-4afd-bc93-ee8213d12ef8";
538
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
539
+ const result = parseResult(await executeTool({ action: "read", issueId: uuid }));
540
+ expect(result.error).toBeUndefined();
541
+ expect(mockGetIssueDetails).toHaveBeenCalledWith(uuid);
542
+ });
543
+ });
544
+
430
545
  describe("error handling", () => {
431
546
  it("returns error when no token available", async () => {
432
547
  mockResolveLinearToken.mockReturnValueOnce({
@@ -1,6 +1,7 @@
1
1
  import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { jsonResult } from "openclaw/plugin-sdk";
3
3
  import { LinearAgentApi, resolveLinearToken } from "../api/linear-api.js";
4
+ import { isValidIssueId as isValidLinearId } from "../infra/validation.js";
4
5
 
5
6
  type Action = "read" | "create" | "update" | "comment" | "list_states" | "list_labels";
6
7
 
@@ -38,6 +39,9 @@ async function handleRead(api: LinearAgentApi, params: ToolParams) {
38
39
  if (!params.issueId) {
39
40
  return jsonResult({ error: "issueId is required for action='read'" });
40
41
  }
42
+ if (!isValidLinearId(params.issueId)) {
43
+ return jsonResult({ error: `Invalid issueId format: "${params.issueId}". Expected TEAM-123 or UUID.` });
44
+ }
41
45
  const issue = await api.getIssueDetails(params.issueId);
42
46
  return jsonResult({
43
47
  identifier: issue.identifier,
@@ -69,6 +73,15 @@ async function handleCreate(api: LinearAgentApi, params: ToolParams) {
69
73
  if (!params.title) {
70
74
  return jsonResult({ error: "title is required for action='create'" });
71
75
  }
76
+ if (params.teamId && !isValidLinearId(params.teamId)) {
77
+ return jsonResult({ error: `Invalid teamId format: "${params.teamId}". Expected TEAM-123 or UUID.` });
78
+ }
79
+ if (params.parentIssueId && !isValidLinearId(params.parentIssueId)) {
80
+ return jsonResult({ error: `Invalid parentIssueId format: "${params.parentIssueId}". Expected TEAM-123 or UUID.` });
81
+ }
82
+ if (params.projectId && !isValidLinearId(params.projectId)) {
83
+ return jsonResult({ error: `Invalid projectId format: "${params.projectId}". Expected TEAM-123 or UUID.` });
84
+ }
72
85
 
73
86
  // Resolve teamId: explicit param, or derive from parent issue
74
87
  let teamId = params.teamId;
@@ -133,6 +146,9 @@ async function handleUpdate(api: LinearAgentApi, params: ToolParams) {
133
146
  if (!params.issueId) {
134
147
  return jsonResult({ error: "issueId is required for action='update'" });
135
148
  }
149
+ if (!isValidLinearId(params.issueId)) {
150
+ return jsonResult({ error: `Invalid issueId format: "${params.issueId}". Expected TEAM-123 or UUID.` });
151
+ }
136
152
 
137
153
  const hasFields = params.status || params.priority != null || params.estimate != null || params.labels || params.title;
138
154
  if (!hasFields) {
@@ -209,6 +225,9 @@ async function handleComment(api: LinearAgentApi, params: ToolParams) {
209
225
  if (!params.issueId) {
210
226
  return jsonResult({ error: "issueId is required for action='comment'" });
211
227
  }
228
+ if (!isValidLinearId(params.issueId)) {
229
+ return jsonResult({ error: `Invalid issueId format: "${params.issueId}". Expected TEAM-123 or UUID.` });
230
+ }
212
231
  if (!params.body) {
213
232
  return jsonResult({ error: "body is required for action='comment'" });
214
233
  }
@@ -220,6 +239,9 @@ async function handleListStates(api: LinearAgentApi, params: ToolParams) {
220
239
  if (!params.teamId) {
221
240
  return jsonResult({ error: "teamId is required for action='list_states'" });
222
241
  }
242
+ if (!isValidLinearId(params.teamId)) {
243
+ return jsonResult({ error: `Invalid teamId format: "${params.teamId}". Expected TEAM-123 or UUID.` });
244
+ }
223
245
  const states = await api.getTeamStates(params.teamId);
224
246
  return jsonResult({ states });
225
247
  }
@@ -228,6 +250,9 @@ async function handleListLabels(api: LinearAgentApi, params: ToolParams) {
228
250
  if (!params.teamId) {
229
251
  return jsonResult({ error: "teamId is required for action='list_labels'" });
230
252
  }
253
+ if (!isValidLinearId(params.teamId)) {
254
+ return jsonResult({ error: `Invalid teamId format: "${params.teamId}". Expected TEAM-123 or UUID.` });
255
+ }
231
256
  const labels = await api.getTeamLabels(params.teamId);
232
257
  return jsonResult({ labels });
233
258
  }