Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/agent/core/policy_test.go
3433 views
1
package core_test
2
3
import (
4
"github.com/kardolus/chatgpt-cli/agent/core"
5
"github.com/kardolus/chatgpt-cli/agent/types"
6
"os"
7
"testing"
8
9
. "github.com/onsi/gomega"
10
"github.com/sclevine/spec"
11
"github.com/sclevine/spec/report"
12
)
13
14
func TestUnitPolicy(t *testing.T) {
15
spec.Run(t, "Testing policy", testPolicy, spec.Report(report.Terminal{}))
16
}
17
18
func testPolicy(t *testing.T, when spec.G, it spec.S) {
19
it.Before(func() {
20
RegisterTestingT(t)
21
})
22
23
when("DefaultPolicy.AllowStep()", func() {
24
it("denies unsupported step types", func() {
25
p := core.NewDefaultPolicy(core.PolicyLimits{})
26
27
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
28
Type: "wat",
29
Description: "unknown",
30
})
31
32
Expect(err).To(HaveOccurred())
33
var pe core.PolicyDeniedError
34
Expect(err).To(BeAssignableToTypeOf(pe))
35
Expect(err.Error()).To(ContainSubstring("policy denied"))
36
Expect(err.Error()).To(ContainSubstring("unsupported step type"))
37
})
38
39
it("enforces AllowedTools allowlist when set", func() {
40
p := core.NewDefaultPolicy(core.PolicyLimits{
41
AllowedTools: []types.ToolKind{types.ToolShell},
42
})
43
44
// shell is allowed
45
Expect(p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
46
Type: types.ToolShell,
47
Command: "echo",
48
Args: []string{"hi"},
49
})).To(Succeed())
50
51
// llm is denied
52
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
53
Type: types.ToolLLM,
54
Prompt: "hello",
55
})
56
Expect(err).To(HaveOccurred())
57
Expect(err.Error()).To(ContainSubstring("tool not allowed"))
58
Expect(err.Error()).To(ContainSubstring(string(types.ToolLLM)))
59
})
60
61
when("shell steps", func() {
62
it("denies missing/blank Command", func() {
63
p := core.NewDefaultPolicy(core.PolicyLimits{})
64
65
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
66
Type: types.ToolShell,
67
Command: " ",
68
})
69
Expect(err).To(HaveOccurred())
70
Expect(err.Error()).To(ContainSubstring("shell step requires Command"))
71
})
72
73
it("denies shell commands present in DeniedShellCommands", func() {
74
p := core.NewDefaultPolicy(core.PolicyLimits{
75
DeniedShellCommands: []string{"rm", "sudo"},
76
})
77
78
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
79
Type: types.ToolShell,
80
Command: "rm",
81
Args: []string{"-rf", "/"},
82
})
83
84
Expect(err).To(HaveOccurred())
85
Expect(err.Error()).To(ContainSubstring("shell command denied"))
86
Expect(err.Error()).To(ContainSubstring("rm"))
87
})
88
89
it("allows shell commands not in denylist", func() {
90
p := core.NewDefaultPolicy(core.PolicyLimits{
91
DeniedShellCommands: []string{"rm"},
92
})
93
94
Expect(p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
95
Type: types.ToolShell,
96
Command: "echo",
97
Args: []string{"ok"},
98
})).To(Succeed())
99
})
100
101
it("allows shell args that are not paths when RestrictFilesToWorkDir is enabled", func() {
102
p := core.NewDefaultPolicy(core.PolicyLimits{
103
RestrictFilesToWorkDir: true,
104
})
105
106
tmp := t.TempDir()
107
old, err := os.Getwd()
108
Expect(err).NotTo(HaveOccurred())
109
Expect(os.Chdir(tmp)).To(Succeed())
110
t.Cleanup(func() { _ = os.Chdir(old) })
111
112
Expect(p.AllowStep(types.Config{WorkDir: "."}, types.Step{
113
Type: types.ToolShell,
114
Command: "ls",
115
Args: []string{"-la", "api"},
116
})).To(Succeed())
117
})
118
119
it("denies shell args that are absolute paths outside WorkDir when RestrictFilesToWorkDir is enabled", func() {
120
p := core.NewDefaultPolicy(core.PolicyLimits{
121
RestrictFilesToWorkDir: true,
122
})
123
124
tmp := t.TempDir()
125
old, err := os.Getwd()
126
Expect(err).NotTo(HaveOccurred())
127
Expect(os.Chdir(tmp)).To(Succeed())
128
t.Cleanup(func() { _ = os.Chdir(old) })
129
130
err = p.AllowStep(types.Config{WorkDir: "."}, types.Step{
131
Type: types.ToolShell,
132
Command: "ls",
133
Args: []string{"/tmp"},
134
})
135
Expect(err).To(HaveOccurred())
136
Expect(err.Error()).To(ContainSubstring("shell arg escapes workdir"))
137
})
138
139
it("denies shell args that use ~ when RestrictFilesToWorkDir is enabled", func() {
140
p := core.NewDefaultPolicy(core.PolicyLimits{
141
RestrictFilesToWorkDir: true,
142
})
143
144
tmp := t.TempDir()
145
old, err := os.Getwd()
146
Expect(err).NotTo(HaveOccurred())
147
Expect(os.Chdir(tmp)).To(Succeed())
148
t.Cleanup(func() { _ = os.Chdir(old) })
149
150
err = p.AllowStep(types.Config{WorkDir: "."}, types.Step{
151
Type: types.ToolShell,
152
Command: "cat",
153
Args: []string{"~/secrets.txt"},
154
})
155
Expect(err).To(HaveOccurred())
156
Expect(err.Error()).To(ContainSubstring("shell arg escapes workdir"))
157
})
158
159
it("denies shell args that escape via .. when RestrictFilesToWorkDir is enabled", func() {
160
p := core.NewDefaultPolicy(core.PolicyLimits{
161
RestrictFilesToWorkDir: true,
162
})
163
164
tmp := t.TempDir()
165
old, err := os.Getwd()
166
Expect(err).NotTo(HaveOccurred())
167
Expect(os.Chdir(tmp)).To(Succeed())
168
t.Cleanup(func() { _ = os.Chdir(old) })
169
170
err = p.AllowStep(types.Config{WorkDir: "."}, types.Step{
171
Type: types.ToolShell,
172
Command: "cat",
173
Args: []string{"../outside.txt"},
174
})
175
Expect(err).To(HaveOccurred())
176
Expect(err.Error()).To(ContainSubstring("shell arg escapes workdir"))
177
})
178
179
it("does not treat non-path flags containing '..' as path escapes", func() {
180
p := core.NewDefaultPolicy(core.PolicyLimits{
181
RestrictFilesToWorkDir: true,
182
})
183
184
tmp := t.TempDir()
185
old, err := os.Getwd()
186
Expect(err).NotTo(HaveOccurred())
187
Expect(os.Chdir(tmp)).To(Succeed())
188
t.Cleanup(func() { _ = os.Chdir(old) })
189
190
// This is not really a filesystem path, but it contains ".."
191
err = p.AllowStep(types.Config{WorkDir: "."}, types.Step{
192
Type: types.ToolShell,
193
Command: "echo",
194
Args: []string{"--pattern=.."},
195
})
196
Expect(err).NotTo(HaveOccurred())
197
})
198
})
199
200
when("llm steps", func() {
201
it("denies missing/blank Prompt", func() {
202
p := core.NewDefaultPolicy(core.PolicyLimits{})
203
204
err := p.AllowStep(types.Config{}, types.Step{
205
Type: types.ToolLLM,
206
Prompt: " \n\t",
207
})
208
Expect(err).To(HaveOccurred())
209
Expect(err.Error()).To(ContainSubstring("llm step requires Prompt"))
210
})
211
212
it("allows non-empty Prompt", func() {
213
p := core.NewDefaultPolicy(core.PolicyLimits{})
214
215
Expect(p.AllowStep(types.Config{}, types.Step{
216
Type: types.ToolLLM,
217
Prompt: "say hi",
218
})).To(Succeed())
219
})
220
})
221
222
when("file steps", func() {
223
it("denies missing Op", func() {
224
p := core.NewDefaultPolicy(core.PolicyLimits{})
225
226
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
227
Type: types.ToolFiles,
228
Op: " ",
229
Path: "a.txt",
230
})
231
Expect(err).To(HaveOccurred())
232
Expect(err.Error()).To(ContainSubstring("file step requires Op"))
233
})
234
235
it("denies missing Path", func() {
236
p := core.NewDefaultPolicy(core.PolicyLimits{})
237
238
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
239
Type: types.ToolFiles,
240
Op: "read",
241
Path: " ",
242
})
243
Expect(err).To(HaveOccurred())
244
Expect(err.Error()).To(ContainSubstring("file step requires Path"))
245
})
246
247
it("enforces AllowedFileOps (case/whitespace-normalized)", func() {
248
p := core.NewDefaultPolicy(core.PolicyLimits{
249
AllowedFileOps: []string{"read"},
250
})
251
252
// "ReAd" should be treated as "read"
253
Expect(p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
254
Type: types.ToolFiles,
255
Op: " ReAd ",
256
Path: "a.txt",
257
})).To(Succeed())
258
259
// write is denied
260
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
261
Type: types.ToolFiles,
262
Op: "write",
263
Path: "a.txt",
264
Data: "x",
265
})
266
Expect(err).To(HaveOccurred())
267
Expect(err.Error()).To(ContainSubstring("file op not allowed"))
268
Expect(err.Error()).To(ContainSubstring("write"))
269
})
270
271
it("restricts file paths to WorkDir when RestrictFilesToWorkDir is enabled (relative escape)", func() {
272
p := core.NewDefaultPolicy(core.PolicyLimits{
273
RestrictFilesToWorkDir: true,
274
})
275
276
err := p.AllowStep(types.Config{WorkDir: "/repo"}, types.Step{
277
Type: types.ToolFiles,
278
Op: "read",
279
Path: "../etc/passwd",
280
})
281
Expect(err).To(HaveOccurred())
282
Expect(err.Error()).To(ContainSubstring("path escapes workdir"))
283
})
284
285
it("restricts file paths to WorkDir when RestrictFilesToWorkDir is enabled (absolute escape)", func() {
286
p := core.NewDefaultPolicy(core.PolicyLimits{
287
RestrictFilesToWorkDir: true,
288
})
289
290
err := p.AllowStep(types.Config{WorkDir: "/repo"}, types.Step{
291
Type: types.ToolFiles,
292
Op: "read",
293
Path: "/etc/passwd",
294
})
295
Expect(err).To(HaveOccurred())
296
Expect(err.Error()).To(ContainSubstring("path escapes workdir"))
297
})
298
299
it("allows paths inside WorkDir when RestrictFilesToWorkDir is enabled", func() {
300
p := core.NewDefaultPolicy(core.PolicyLimits{
301
RestrictFilesToWorkDir: true,
302
})
303
304
// relative inside
305
Expect(p.AllowStep(types.Config{WorkDir: "/repo"}, types.Step{
306
Type: types.ToolFiles,
307
Op: "read",
308
Path: "dir/file.txt",
309
})).To(Succeed())
310
311
// absolute inside
312
Expect(p.AllowStep(types.Config{WorkDir: "/repo"}, types.Step{
313
Type: types.ToolFiles,
314
Op: "read",
315
Path: "/repo/dir/file.txt",
316
})).To(Succeed())
317
318
// exactly the workdir itself should not count as escape
319
Expect(p.AllowStep(types.Config{WorkDir: "/repo"}, types.Step{
320
Type: types.ToolFiles,
321
Op: "read",
322
Path: "/repo",
323
})).To(Succeed())
324
})
325
326
it("allows relative paths inside workdir when WorkDir is '.' (regression)", func() {
327
p := core.NewDefaultPolicy(core.PolicyLimits{
328
RestrictFilesToWorkDir: true,
329
})
330
331
// Make cwd deterministic for filepath.Abs()
332
tmp := t.TempDir()
333
old, err := os.Getwd()
334
Expect(err).NotTo(HaveOccurred())
335
Expect(os.Chdir(tmp)).To(Succeed())
336
t.Cleanup(func() { _ = os.Chdir(old) })
337
338
// Path is clearly within "." (cwd)
339
Expect(p.AllowStep(types.Config{WorkDir: "."}, types.Step{
340
Type: types.ToolFiles,
341
Op: "read",
342
Path: "api/completions.go",
343
})).To(Succeed())
344
345
// Also allow "./..." form
346
Expect(p.AllowStep(types.Config{WorkDir: "."}, types.Step{
347
Type: types.ToolFiles,
348
Op: "read",
349
Path: "./api/completions.go",
350
})).To(Succeed())
351
})
352
353
it("denies relative escape paths when WorkDir is '.'", func() {
354
p := core.NewDefaultPolicy(core.PolicyLimits{
355
RestrictFilesToWorkDir: true,
356
})
357
358
tmp := t.TempDir()
359
old, err := os.Getwd()
360
Expect(err).NotTo(HaveOccurred())
361
Expect(os.Chdir(tmp)).To(Succeed())
362
t.Cleanup(func() { _ = os.Chdir(old) })
363
364
err = p.AllowStep(types.Config{WorkDir: "."}, types.Step{
365
Type: types.ToolFiles,
366
Op: "read",
367
Path: "../secrets.txt",
368
})
369
Expect(err).To(HaveOccurred())
370
Expect(err.Error()).To(ContainSubstring("path escapes workdir"))
371
})
372
373
it("allows relative workdir like './' (normalized) for in-tree paths", func() {
374
p := core.NewDefaultPolicy(core.PolicyLimits{
375
RestrictFilesToWorkDir: true,
376
})
377
378
tmp := t.TempDir()
379
old, err := os.Getwd()
380
Expect(err).NotTo(HaveOccurred())
381
Expect(os.Chdir(tmp)).To(Succeed())
382
t.Cleanup(func() { _ = os.Chdir(old) })
383
384
Expect(p.AllowStep(types.Config{WorkDir: "./"}, types.Step{
385
Type: types.ToolFiles,
386
Op: "read",
387
Path: "api/file.go",
388
})).To(Succeed())
389
})
390
391
it("denies patch when Data (unified diff) is missing/blank", func() {
392
p := core.NewDefaultPolicy(core.PolicyLimits{})
393
394
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
395
Type: types.ToolFiles,
396
Op: "patch",
397
Path: "a.txt",
398
Data: " \n\t",
399
})
400
Expect(err).To(HaveOccurred())
401
Expect(err.Error()).To(ContainSubstring("patch requires Data"))
402
})
403
404
it("allows patch when Data is non-empty", func() {
405
p := core.NewDefaultPolicy(core.PolicyLimits{})
406
407
Expect(p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
408
Type: types.ToolFiles,
409
Op: "patch",
410
Path: "a.txt",
411
Data: "@@ -1 +1 @@\n-a\n+b\n",
412
})).To(Succeed())
413
})
414
415
it("denies replace when Old pattern is missing/empty", func() {
416
p := core.NewDefaultPolicy(core.PolicyLimits{})
417
418
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
419
Type: types.ToolFiles,
420
Op: "replace",
421
Path: "a.txt",
422
Old: "",
423
New: "x",
424
N: -1,
425
})
426
Expect(err).To(HaveOccurred())
427
Expect(err.Error()).To(ContainSubstring("replace requires Old pattern"))
428
})
429
430
it("allows replace when Old pattern is provided (New may be empty for deletions)", func() {
431
p := core.NewDefaultPolicy(core.PolicyLimits{})
432
433
Expect(p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
434
Type: types.ToolFiles,
435
Op: "replace",
436
Path: "a.txt",
437
Old: "hello",
438
New: "",
439
N: -1,
440
})).To(Succeed())
441
})
442
443
it("enforces AllowedFileOps for patch (denied when only read is allowed)", func() {
444
p := core.NewDefaultPolicy(core.PolicyLimits{
445
AllowedFileOps: []string{"read"},
446
})
447
448
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
449
Type: types.ToolFiles,
450
Op: "patch",
451
Path: "a.txt",
452
Data: "@@ -1 +1 @@\n-a\n+b\n",
453
})
454
Expect(err).To(HaveOccurred())
455
Expect(err.Error()).To(ContainSubstring("file op not allowed"))
456
Expect(err.Error()).To(ContainSubstring("patch"))
457
})
458
459
it("enforces AllowedFileOps for replace (denied when only read is allowed)", func() {
460
p := core.NewDefaultPolicy(core.PolicyLimits{
461
AllowedFileOps: []string{"read"},
462
})
463
464
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
465
Type: types.ToolFiles,
466
Op: "replace",
467
Path: "a.txt",
468
Old: "a",
469
New: "b",
470
N: 1,
471
})
472
Expect(err).To(HaveOccurred())
473
Expect(err.Error()).To(ContainSubstring("file op not allowed"))
474
Expect(err.Error()).To(ContainSubstring("replace"))
475
})
476
477
it("allows patch when AllowedFileOps includes write (write implies patch)", func() {
478
p := core.NewDefaultPolicy(core.PolicyLimits{
479
AllowedFileOps: []string{"write"},
480
})
481
482
Expect(p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
483
Type: types.ToolFiles,
484
Op: "patch",
485
Path: "a.txt",
486
Data: "@@ -1 +1 @@\n-a\n+b\n",
487
})).To(Succeed())
488
})
489
490
it("allows replace when AllowedFileOps includes write (write implies replace)", func() {
491
p := core.NewDefaultPolicy(core.PolicyLimits{
492
AllowedFileOps: []string{"write"},
493
})
494
495
Expect(p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
496
Type: types.ToolFiles,
497
Op: "replace",
498
Path: "a.txt",
499
Old: "a",
500
New: "b",
501
N: -1,
502
})).To(Succeed())
503
})
504
505
it("still denies patch/replace when AllowedFileOps is set but does not include write/patch/replace", func() {
506
p := core.NewDefaultPolicy(core.PolicyLimits{
507
AllowedFileOps: []string{"read"}, // explicitly set
508
})
509
510
err := p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
511
Type: types.ToolFiles,
512
Op: "replace",
513
Path: "a.txt",
514
Old: "a",
515
New: "b",
516
N: -1,
517
})
518
Expect(err).To(HaveOccurred())
519
Expect(err.Error()).To(ContainSubstring("file op not allowed"))
520
521
err = p.AllowStep(types.Config{WorkDir: "/tmp"}, types.Step{
522
Type: types.ToolFiles,
523
Op: "patch",
524
Path: "a.txt",
525
Data: "@@ -1 +1 @@\n-a\n+b\n",
526
})
527
Expect(err).To(HaveOccurred())
528
Expect(err.Error()).To(ContainSubstring("file op not allowed"))
529
})
530
})
531
})
532
}
533
534