@pi-in-go/pigpen-jev 0.1.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 (128) hide show
  1. package/CREDITS.md +22 -0
  2. package/LICENSE +22 -0
  3. package/README.md +237 -0
  4. package/extensions/jev/ask.go +166 -0
  5. package/extensions/jev/ask_test.go +218 -0
  6. package/extensions/jev/backend.go +128 -0
  7. package/extensions/jev/bench_test.go +64 -0
  8. package/extensions/jev/boundaries_test.go +159 -0
  9. package/extensions/jev/command.go +224 -0
  10. package/extensions/jev/commands_test.go +214 -0
  11. package/extensions/jev/config.go +450 -0
  12. package/extensions/jev/errors_test.go +191 -0
  13. package/extensions/jev/extension.go +391 -0
  14. package/extensions/jev/fakehost_test.go +548 -0
  15. package/extensions/jev/gate.go +125 -0
  16. package/extensions/jev/gate_test.go +610 -0
  17. package/extensions/jev/gatekey_test.go +24 -0
  18. package/extensions/jev/go.mod +9 -0
  19. package/extensions/jev/go.sum +2 -0
  20. package/extensions/jev/go.work +10 -0
  21. package/extensions/jev/helpers_test.go +404 -0
  22. package/extensions/jev/memo.go +88 -0
  23. package/extensions/jev/output.go +89 -0
  24. package/extensions/jev/output_test.go +187 -0
  25. package/extensions/jev/ownmodel_test.go +118 -0
  26. package/extensions/jev/render.go +136 -0
  27. package/extensions/jev/review_test.go +310 -0
  28. package/extensions/jev/source_test.go +57 -0
  29. package/extensions/jev/text.go +174 -0
  30. package/extensions/jev/trust_test.go +335 -0
  31. package/extensions/jev/types.go +227 -0
  32. package/libs/typesafe/CONTRACT.md +125 -0
  33. package/libs/typesafe/CREDITS.md +37 -0
  34. package/libs/typesafe/LICENSE +23 -0
  35. package/libs/typesafe/README.md +19 -0
  36. package/libs/typesafe/go.mod +3 -0
  37. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  38. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  39. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  40. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  41. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  42. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  43. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  44. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  45. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  46. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  47. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  48. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  49. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  50. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  51. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  52. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  53. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  54. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  55. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  56. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  57. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  58. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  59. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  60. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  61. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  62. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  63. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  64. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  65. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  66. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  67. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  68. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  69. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  70. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  71. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  72. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  73. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  74. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  75. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  76. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  77. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  78. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  79. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  80. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  81. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  82. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  83. package/libs/typesafe/package.json +37 -0
  84. package/libs/typesafe/provenance.json +49 -0
  85. package/package.json +42 -0
  86. package/port/PORT.md +107 -0
  87. package/port/e2e/gate-and-output.py +35 -0
  88. package/port/e2e/jev-ask.py +36 -0
  89. package/port/e2e/model-switch.py +44 -0
  90. package/port/e2e/off-by-default.py +34 -0
  91. package/port/gen-scenarios.py +103 -0
  92. package/port/golden/cache-identical-calls.jsonl +30 -0
  93. package/port/golden/clear.jsonl +22 -0
  94. package/port/golden/commands.jsonl +43 -0
  95. package/port/golden/enforce-accept.jsonl +23 -0
  96. package/port/golden/enforce-decline.jsonl +22 -0
  97. package/port/golden/jev-ask.jsonl +20 -0
  98. package/port/golden/output-advice.jsonl +23 -0
  99. package/port/golden/output-leak.jsonl +24 -0
  100. package/port/golden/output-low-confidence.jsonl +22 -0
  101. package/port/golden/shadow-flagged.jsonl +23 -0
  102. package/port/golden/unjudged-tools.jsonl +19 -0
  103. package/port/golden/write-elision.jsonl +21 -0
  104. package/port/mutate-unit.py +63 -0
  105. package/port/mutations.json +578 -0
  106. package/port/oracle/LICENSE +21 -0
  107. package/port/oracle/README.md +181 -0
  108. package/port/oracle/SHA256SUMS +8 -0
  109. package/port/oracle/package.json +43 -0
  110. package/port/oracle/src/client.ts +409 -0
  111. package/port/oracle/src/config.ts +363 -0
  112. package/port/oracle/src/gate.ts +229 -0
  113. package/port/oracle/src/index.ts +649 -0
  114. package/port/oracle/src/output.ts +163 -0
  115. package/port/red-run.log +309 -0
  116. package/port/scenarios/cache-identical-calls.json +71 -0
  117. package/port/scenarios/clear.json +61 -0
  118. package/port/scenarios/commands.json +119 -0
  119. package/port/scenarios/enforce-accept.json +66 -0
  120. package/port/scenarios/enforce-decline.json +57 -0
  121. package/port/scenarios/jev-ask.json +83 -0
  122. package/port/scenarios/output-advice.json +61 -0
  123. package/port/scenarios/output-leak.json +61 -0
  124. package/port/scenarios/output-low-confidence.json +61 -0
  125. package/port/scenarios/shadow-flagged.json +61 -0
  126. package/port/scenarios/unjudged-tools.json +55 -0
  127. package/port/scenarios/write-elision.json +53 -0
  128. package/provenance.json +18 -0
@@ -0,0 +1,128 @@
1
+ package jev
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+ "time"
10
+
11
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
12
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/ownmodel"
13
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/pigmodel"
14
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
15
+ )
16
+
17
+ // A backend answers typed questions about a state. Two exist, both in the shared
18
+ // client (components/typesafe): the TypeSafe API, and the model PiG is configured
19
+ // with. The extension decides what to ask, what leaves the machine and what a
20
+ // verdict means; it does not speak HTTP or build model prompts.
21
+ type backend struct {
22
+ kind string
23
+ dest string
24
+ client *typesafe.Client // typesafe backend
25
+ model pigmodel.Ref // own-model backend
26
+ cfg config
27
+ }
28
+
29
+ func (b *backend) Destination() string { return b.dest }
30
+
31
+ // normalizeEndpoint takes the API root. The original's setting was the full
32
+ // request URL, so a trailing /v1/systemone is accepted and removed.
33
+ func normalizeEndpoint(raw string) string {
34
+ raw = strings.TrimRight(raw, "/")
35
+ return strings.TrimSuffix(raw, "/v1/systemone")
36
+ }
37
+
38
+ // newBackend builds the backend for c, or explains why none is available. The
39
+ // TypeSafe client gets an empty environment: only the user's config chooses where
40
+ // the key travels, never TYPESAFE_BASE_URL (PORT.md C2).
41
+ func newBackend(c config, sctx sdk.Context) (*backend, error) {
42
+ if c.Backend == backendTypeSafe {
43
+ if c.Endpoint == "" {
44
+ return nil, errors.New("the typesafe backend needs \"endpoint\" in your pi-jev.json (there is no built-in default)")
45
+ }
46
+ if c.Model == "" {
47
+ return nil, errors.New("the typesafe backend needs \"model\" in your pi-jev.json")
48
+ }
49
+ base := normalizeEndpoint(c.Endpoint)
50
+ if err := checkEndpoint(base); err != nil {
51
+ return nil, err
52
+ }
53
+ if c.Key == "" {
54
+ return nil, fmt.Errorf("no key. Set %s or apiKeyFile in pi-jev.json; the gate is inactive until then.", apiKeyEnv)
55
+ }
56
+ client, err := typesafe.NewClient(typesafe.Config{
57
+ APIKey: c.Key, BaseURL: base, DefaultModel: c.Model,
58
+ Timeout: time.Duration(c.TimeoutMs) * time.Millisecond,
59
+ Retry: typesafe.RetryOverrides{MaxRetries: typesafe.Ptr(c.Retries)},
60
+ Getenv: func(string) string { return "" },
61
+ Logger: discardLogger{}, LogLevel: typesafe.LogOff,
62
+ })
63
+ if err != nil {
64
+ return nil, err
65
+ }
66
+ return &backend{kind: backendTypeSafe, dest: base + " (model " + c.Model + ")", client: client, cfg: c}, nil
67
+ }
68
+ ref := pigmodel.Ref{}
69
+ if c.Model != "" {
70
+ provider, id, ok := strings.Cut(c.Model, "/")
71
+ if !ok {
72
+ return nil, fmt.Errorf("\"model\" must be provider/model (got %q)", c.Model)
73
+ }
74
+ ref = pigmodel.Ref{Provider: provider, ID: id}
75
+ } else if info, err := sctx.GetModelInfo(); err == nil && info != nil {
76
+ ref = pigmodel.Ref{Provider: info.Provider, ID: info.ID}
77
+ }
78
+ if ref.ID == "" {
79
+ return nil, errors.New("the model backend needs a model: none is selected in PiG and none is set as \"model\" in pi-jev.json")
80
+ }
81
+ name := ref.Provider + "/" + ref.ID
82
+ return &backend{kind: backendModel, model: ref, cfg: c,
83
+ dest: "the model " + name + " (through PiG, the provider that already receives your conversation)"}, nil
84
+ }
85
+
86
+ // evaluator returns what answers questions for one call. The own-model backend is
87
+ // bound to the call's context, so it is built per call.
88
+ func (b *backend) evaluator(sctx sdk.Context) (typesafe.Evaluator, error) {
89
+ if b.kind == backendTypeSafe {
90
+ return b.client, nil
91
+ }
92
+ m, err := pigmodel.New(sctx.ModelRegistry(), b.model)
93
+ if err != nil {
94
+ return nil, err
95
+ }
96
+ return ownmodel.New(ownmodel.Options{Model: m, AnswerMode: ownmodel.Probabilities, NormalizeProbabilities: true, MalformedRetries: 1})
97
+ }
98
+
99
+ // Ask sends state (a JSON document, or text when text is true) with the questions.
100
+ func (b *backend) Ask(ctx context.Context, sctx sdk.Context, state []byte, text bool, qs []question) (*response, error) {
101
+ if err := validateQuestions(qs); err != nil {
102
+ return nil, err
103
+ }
104
+ ev, err := b.evaluator(sctx)
105
+ if err != nil {
106
+ return nil, err
107
+ }
108
+ req := typesafe.SystemOneRequest{Questions: toTypeSafe(qs)}
109
+ if text {
110
+ var s string
111
+ _ = json.Unmarshal(state, &s)
112
+ req.State = typesafe.Text(s)
113
+ } else {
114
+ req.State = typesafe.Value(json.RawMessage(state))
115
+ }
116
+ res, err := ev.SystemOne(ctx, req, nil)
117
+ if err != nil {
118
+ return nil, err
119
+ }
120
+ return fromResult(res, qs)
121
+ }
122
+
123
+ type discardLogger struct{}
124
+
125
+ func (discardLogger) Debug(string, ...any) {}
126
+ func (discardLogger) Info(string, ...any) {}
127
+ func (discardLogger) Warn(string, ...any) {}
128
+ func (discardLogger) Error(string, ...any) {}
@@ -0,0 +1,64 @@
1
+ package jev
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ // The judge path runs once per tool call when Jev is on: build the state document, ask, apply the thresholds.
9
+ // Building the document is the part that scales with the call's arguments; asking is a network call.
10
+ //
11
+ // go test -run xxx -bench . -benchmem
12
+ func benchInput(bodyBytes int) map[string]any {
13
+ return map[string]any{
14
+ "path": "/home/user/project/src/internal/module/file.go",
15
+ "content": strings.Repeat("line of source code with some words in it\n", bodyBytes/42),
16
+ "edits": []any{map[string]any{"oldText": strings.Repeat("a", 300), "newText": strings.Repeat("b", 300)}},
17
+ }
18
+ }
19
+
20
+ func BenchmarkGateStateSmallCall(b *testing.B) {
21
+ input := map[string]any{"command": "go test ./... -count=1"}
22
+ b.ReportAllocs()
23
+ for i := 0; i < b.N; i++ {
24
+ gateStateJSON("/work/repo", "bash", input, "please run the tests", 400, 8000)
25
+ }
26
+ }
27
+
28
+ func BenchmarkGateStateWrite50KB(b *testing.B) {
29
+ input := benchInput(50_000)
30
+ b.ReportAllocs()
31
+ for i := 0; i < b.N; i++ {
32
+ gateStateJSON("/work/repo", "write", input, strings.Repeat("please write this file. ", 80), 400, 8000)
33
+ }
34
+ }
35
+
36
+ // A document that never fits drives fitState through every shrink step.
37
+ func BenchmarkGateStateDoesNotFit(b *testing.B) {
38
+ wide := map[string]any{}
39
+ for i := 0; i < 200; i++ {
40
+ wide[strings.Repeat("k", i%17+1)+string(rune('a'+i%26))+strings.Repeat("z", i/26)] = strings.Repeat("v", 90)
41
+ }
42
+ b.ReportAllocs()
43
+ for i := 0; i < b.N; i++ {
44
+ gateStateJSON("/work/repo", "write", wide, "do the thing", 400, 2000)
45
+ }
46
+ }
47
+
48
+ func BenchmarkEvaluateGate(b *testing.B) {
49
+ cfg := defaultConfig()
50
+ r := &response{Answers: map[string]answer{"destructive": {Noul: 0.4}, "exfiltration": {Noul: 0.1}, "beyond_scope": {Noul: 0.2}, "impact": {Type: "score", Score: 1.2, Confidence: 0.9, HasConfidence: true}}}
51
+ b.ReportAllocs()
52
+ for i := 0; i < b.N; i++ {
53
+ evaluateGate(r, cfg)
54
+ }
55
+ }
56
+
57
+ // The memo key of one call (what is asked is memoised for CacheSeconds, so repeated calls cost one judge request).
58
+ func BenchmarkGateKeyWrite50KB(b *testing.B) {
59
+ input := benchInput(50_000)
60
+ b.ReportAllocs()
61
+ for i := 0; i < b.N; i++ {
62
+ gateKey("write", input, "/work/repo", "please write this file")
63
+ }
64
+ }
@@ -0,0 +1,159 @@
1
+ package jev_test
2
+
3
+ import (
4
+ "strconv"
5
+ "testing"
6
+ )
7
+
8
+ // Thresholds are inclusive (>=), each one, and the confidence floors too. The
9
+ // original's comparisons are all >=; a >= that became > would not show in the
10
+ // scenarios because their values sit well away from every threshold.
11
+
12
+ func TestGate_EachThresholdIsInclusive(t *testing.T) {
13
+ cases := map[string]struct {
14
+ body map[string]any
15
+ want string
16
+ }{
17
+ "destructive": {gateBody(0.9, 0, 0, 0, 0.9), "jev: destructive 0.90"},
18
+ "exfiltration": {gateBody(0, 0.7, 0, 0, 0.9), "jev: exfiltration 0.70"},
19
+ "beyond_scope": {gateBody(0, 0, 0.85, 0, 0.9), "jev: beyond_scope 0.85"},
20
+ "impact": {gateBody(0, 0, 0, 2.5, 0.9), "jev: impact 2.50/3 at confidence 0.90"},
21
+ "impact conf": {gateBody(0, 0, 0, 3, 0.5), "jev: impact 3.00/3 at confidence 0.50"},
22
+ }
23
+ for name, c := range cases {
24
+ t.Run(name, func(t *testing.T) {
25
+ e := newEnv(t)
26
+ t.Setenv("TYPESAFE_API_KEY", testKey)
27
+ srv := newFakeJev(t, always(c.body))
28
+ e.writeGlobal(t, jevConfig(srv, nil))
29
+ h := start(t, e, newHostState(), HostOptions{})
30
+ h.toolCall("bash", bash("x"))
31
+ if got := h.lastStatus(); got != c.want {
32
+ t.Errorf("status = %q, want %q", got, c.want)
33
+ }
34
+ })
35
+ }
36
+ }
37
+
38
+ func TestGate_JustBelowEachThresholdIsClear(t *testing.T) {
39
+ for name, body := range map[string]map[string]any{
40
+ "destructive": gateBody(0.89, 0, 0, 0, 0.9),
41
+ "exfiltration": gateBody(0, 0.69, 0, 0, 0.9),
42
+ "beyond_scope": gateBody(0, 0, 0.84, 0, 0.9),
43
+ "impact": gateBody(0, 0, 0, 2.49, 0.9),
44
+ } {
45
+ e := newEnv(t)
46
+ t.Setenv("TYPESAFE_API_KEY", testKey)
47
+ srv := newFakeJev(t, always(body))
48
+ e.writeGlobal(t, jevConfig(srv, nil))
49
+ h := start(t, e, newHostState(), HostOptions{})
50
+ h.toolCall("bash", bash("x"))
51
+ if got := h.lastStatus(); got != "jev: clear (shadow)" {
52
+ t.Errorf("%s: status = %q", name, got)
53
+ }
54
+ }
55
+ }
56
+
57
+ func TestOutput_ClassConfidenceFloorIsInclusive(t *testing.T) {
58
+ h, _ := outputHost(t, outBody(0.01, "environment", 0.6), nil)
59
+ if _, none := h.toolResult("bash", bash("x"), text("boom"), true); none {
60
+ t.Fatal("confidence exactly at output.minConfidence must give advice")
61
+ }
62
+ }
63
+
64
+ func TestErrors_ChoiceOutsideItsOptionsIsAnError(t *testing.T) {
65
+ h, _ := errorHost(t, always(outBody(0.01, "brand_new_class", 1.0)), nil)
66
+ h.toolResult("bash", bash("x"), text("out"), false)
67
+ if !h.anyNotification("not one of its options") || !h.anyNotification("(failing open)") {
68
+ t.Errorf("notifications = %q", h.notifications())
69
+ }
70
+ }
71
+
72
+ func TestCommand_CheckNeedsJevOn(t *testing.T) {
73
+ e := newEnv(t)
74
+ t.Setenv("TYPESAFE_API_KEY", testKey)
75
+ srv := newFakeJev(t, always(clearGate()))
76
+ cfg := jevConfig(srv, nil)
77
+ delete(cfg, "enabled")
78
+ e.writeGlobal(t, cfg)
79
+ h := start(t, e, newHostState(), HostOptions{})
80
+ h.Command("jev", "check some text")
81
+ if srv.count() != 0 {
82
+ t.Fatal("/jev check sent text while Jev is off")
83
+ }
84
+ mustContain(t, "note", lastNote(h), "off", "/jev on")
85
+ }
86
+
87
+ func TestCorrection_TypeSafeEnvironmentCannotRedirectTheKey(t *testing.T) {
88
+ e := newEnv(t)
89
+ t.Setenv("TYPESAFE_API_KEY", testKey)
90
+ trusted := newFakeJev(t, always(clearGate()))
91
+ attacker := newFakeJev(t, always(clearGate()))
92
+ t.Setenv("TYPESAFE_BASE_URL", attacker.srv.URL)
93
+ t.Setenv("TYPESAFE_DEFAULT_MODEL", "attacker-model")
94
+ e.writeGlobal(t, jevConfig(trusted, nil))
95
+ h := start(t, e, newHostState(), HostOptions{})
96
+ h.toolCall("bash", bash("ls"))
97
+ if attacker.count() != 0 || trusted.count() != 1 {
98
+ t.Fatalf("attacker got %d requests, trusted got %d", attacker.count(), trusted.count())
99
+ }
100
+ }
101
+
102
+ func TestGate_CacheHoldsSixtyFourVerdicts(t *testing.T) {
103
+ e := newEnv(t)
104
+ t.Setenv("TYPESAFE_API_KEY", testKey)
105
+ srv := newFakeJev(t, always(clearGate()))
106
+ e.writeGlobal(t, jevConfig(srv, nil))
107
+ h := start(t, e, newHostState(), HostOptions{})
108
+ for i := 0; i < 70; i++ {
109
+ h.toolCall("bash", bash("cmd "+strconv.Itoa(i)))
110
+ }
111
+ if srv.count() != 70 {
112
+ t.Fatalf("requests = %d", srv.count())
113
+ }
114
+ h.toolCall("bash", bash("cmd 69")) // recent: still cached
115
+ if srv.count() != 70 {
116
+ t.Errorf("a recent verdict was not cached: %d requests", srv.count())
117
+ }
118
+ h.toolCall("bash", bash("cmd 0")) // evicted beyond 64 entries
119
+ if srv.count() != 71 {
120
+ t.Errorf("the cache grew past its 64 entries: %d requests", srv.count())
121
+ }
122
+ }
123
+
124
+ func TestGate_ExpiredVerdictIsJudgedAgain(t *testing.T) {
125
+ e := newEnv(t)
126
+ t.Setenv("TYPESAFE_API_KEY", testKey)
127
+ srv := newFakeJev(t, always(clearGate()))
128
+ e.writeGlobal(t, jevConfig(srv, map[string]any{"gate": map[string]any{"cacheSeconds": 1}}))
129
+ h := start(t, e, newHostState(), HostOptions{})
130
+ h.toolCall("bash", bash("ls"))
131
+ sleepMs(1300)
132
+ h.toolCall("bash", bash("ls"))
133
+ if srv.count() != 2 {
134
+ t.Errorf("requests = %d, want 2 after the window", srv.count())
135
+ }
136
+ }
137
+
138
+ // The original's endpoint was the full request URL; the shared client wants the API
139
+ // root, so a trailing /v1/systemone (and slash) is accepted and removed (PORT.md D1).
140
+ func TestConfig_EndpointMayBeTheFullRequestURL(t *testing.T) {
141
+ for _, suffix := range []string{"", "/", "/v1/systemone", "/v1/systemone/"} {
142
+ e := newEnv(t)
143
+ t.Setenv("TYPESAFE_API_KEY", testKey)
144
+ srv := newFakeJev(t, always(clearGate()))
145
+ cfg := jevConfig(srv, nil)
146
+ cfg["endpoint"] = srv.srv.URL + suffix
147
+ e.writeGlobal(t, cfg)
148
+ h := start(t, e, newHostState(), HostOptions{})
149
+ h.toolCall("bash", bash("ls"))
150
+ if srv.count() != 1 || srv.first(t).Path != "/v1/systemone" {
151
+ t.Errorf("endpoint %q: %d requests, path %q", suffix, srv.count(), func() string {
152
+ if srv.count() == 0 {
153
+ return ""
154
+ }
155
+ return srv.first(t).Path
156
+ }())
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,224 @@
1
+ package jev
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+
7
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
8
+ )
9
+
10
+ func (x *ext) note(ctx sdk.Context, level, plain, rich string) {
11
+ if x.plain() {
12
+ ctx.Notify(plain, level)
13
+ return
14
+ }
15
+ ctx.Notify(rich, level)
16
+ }
17
+
18
+ func (x *ext) command(ctx sdk.Context, args string) error {
19
+ trimmed := strings.TrimSpace(args)
20
+ fields := strings.Fields(strings.ToLower(trimmed))
21
+ sub, value := "", ""
22
+ if len(fields) > 0 {
23
+ sub = fields[0]
24
+ }
25
+ if len(fields) > 1 {
26
+ value = fields[1]
27
+ }
28
+ switch sub {
29
+ case "on":
30
+ x.turnOn(ctx)
31
+ case "off":
32
+ x.mu.Lock()
33
+ x.on, x.gateOn, x.outputOn = false, false, false
34
+ x.mu.Unlock()
35
+ x.setStatus(ctx, "", "off")
36
+ x.note(ctx, "info", "pi-jev: gate off", "pi-jev: gate off")
37
+ case "mode":
38
+ x.setMode(ctx, value)
39
+ case "last":
40
+ x.showLast(ctx)
41
+ case "output":
42
+ x.showOutput(ctx)
43
+ case "check":
44
+ x.check(ctx, strings.TrimSpace(trimmed[len("check"):]))
45
+ default:
46
+ x.status(ctx)
47
+ }
48
+ return nil
49
+ }
50
+
51
+ func (x *ext) turnOn(ctx sdk.Context) {
52
+ s := x.snapshot()
53
+ if s.be == nil {
54
+ reason := "Jev cannot start"
55
+ x.mu.Lock()
56
+ if x.beEr != nil {
57
+ reason = x.beEr.Error()
58
+ }
59
+ x.mu.Unlock()
60
+ ctx.Notify("pi-jev: "+x.redact(reason), "warning")
61
+ return
62
+ }
63
+ if !s.cfg.Enabled && !s.on {
64
+ // Turning it on sends content off the machine: ask, with the disclosure.
65
+ ok, err := ctx.Confirm("Turn on Jev for this session?", disclosure(s.cfg, s.be.Destination())+"\n\nTurn Jev on?")
66
+ if err != nil || !ok {
67
+ ctx.Notify("pi-jev: still off; nothing is sent", "info")
68
+ return
69
+ }
70
+ }
71
+ x.mu.Lock()
72
+ x.on, x.gateOn, x.outputOn = true, true, true
73
+ x.mu.Unlock()
74
+ x.baseStatus(ctx)
75
+ x.registerAsk(ctx)
76
+ x.note(ctx, "info", "pi-jev: gate on", "pi-jev: gate on")
77
+ }
78
+
79
+ func (x *ext) setMode(ctx sdk.Context, value string) {
80
+ if value != "shadow" && value != "enforce" {
81
+ s := x.snapshot()
82
+ ctx.Notify(fmt.Sprintf("pi-jev: mode is %s (usage: /jev mode shadow|enforce)", s.mode), "warning")
83
+ return
84
+ }
85
+ x.mu.Lock()
86
+ x.mode = value
87
+ x.mu.Unlock()
88
+ x.baseStatus(ctx)
89
+ tail := " (asks before running flagged calls)"
90
+ if value == "shadow" {
91
+ tail = " (reports, never blocks)"
92
+ }
93
+ ctx.Notify("pi-jev: "+value+tail, "info")
94
+ }
95
+
96
+ func (x *ext) showLast(ctx sdk.Context) {
97
+ x.mu.Lock()
98
+ l, fail := x.last, x.lastFailure
99
+ x.mu.Unlock()
100
+ if l == nil {
101
+ msg := "pi-jev: no verdicts yet"
102
+ if fail != "" {
103
+ msg += " (the last attempt failed: " + fail + ")"
104
+ }
105
+ ctx.Notify(msg, "info")
106
+ return
107
+ }
108
+ ctx.Notify(fmt.Sprintf("pi-jev: %s - %s | %s", l.tool, l.verdict.summary(), describeAnswers(l.verdict.Resp, gateQuestions)), "info")
109
+ }
110
+
111
+ func (x *ext) showOutput(ctx sdk.Context) {
112
+ x.mu.Lock()
113
+ l := x.lastOutput
114
+ x.mu.Unlock()
115
+ if l == nil {
116
+ ctx.Notify("pi-jev: no tool output judged yet", "info")
117
+ return
118
+ }
119
+ v := l.verdict
120
+ class, conf := v.FailureClass, "n/a"
121
+ if class == "" {
122
+ class = "none"
123
+ }
124
+ if v.HasClass {
125
+ conf = toFixed2(v.ClassConfidence)
126
+ }
127
+ ctx.Notify(fmt.Sprintf("pi-jev output: %s - %s | leak %s | class %s at %s", l.tool, v.Kind, toFixed2(v.LeaksSecret), class, conf), "info")
128
+ }
129
+
130
+ // check runs the gate questions against text the user supplies. It sends that text
131
+ // to the judge, so it needs the same opt-in as everything else.
132
+ func (x *ext) check(ctx sdk.Context, text string) {
133
+ s := x.snapshot()
134
+ if text == "" {
135
+ ctx.Notify("pi-jev: usage /jev check <text>", "warning")
136
+ return
137
+ }
138
+ if s.be == nil || !s.on {
139
+ reason := "Jev is off (run /jev on first: /jev check sends your text to the judge)"
140
+ x.mu.Lock()
141
+ if s.on && x.beEr != nil {
142
+ reason = x.beEr.Error()
143
+ }
144
+ x.mu.Unlock()
145
+ ctx.Notify("pi-jev: "+x.redact(reason), "warning")
146
+ return
147
+ }
148
+ gctx, cancel := goContext(ctx)
149
+ defer cancel()
150
+ resp, err := s.be.Ask(gctx, ctx, jsonString(text), true, gateQuestions)
151
+ if err != nil {
152
+ ctx.Notify("pi-jev: "+x.redact(err.Error()), "error")
153
+ return
154
+ }
155
+ v := evaluateGate(resp, s.cfg)
156
+ x.mu.Lock()
157
+ x.last = &lastGate{"check", v}
158
+ x.mu.Unlock()
159
+ ctx.Notify(fmt.Sprintf("pi-jev check: %s | %s", v.summary(), describeAnswers(resp, gateQuestions)), "info")
160
+ }
161
+
162
+ // status is /jev with no argument.
163
+ func (x *ext) status(ctx sdk.Context) {
164
+ s := x.snapshot()
165
+ x.mu.Lock()
166
+ beErr, last, lastOut, fail := x.beEr, x.last, x.lastOutput, x.lastFailure
167
+ x.mu.Unlock()
168
+ onOff := func(b bool) string {
169
+ if b {
170
+ return "on"
171
+ }
172
+ return "off"
173
+ }
174
+ if s.cfg.Display == "plain" {
175
+ keySource := s.cfg.KeySource
176
+ if keySource == "" {
177
+ keySource = "missing (" + apiKeyEnv + ")"
178
+ }
179
+ ctx.Notify(fmt.Sprintf("pi-jev: %s, out %s, mode %s, model %s, key %s, judging %s, out tools %s, cache %d/%d",
180
+ onOff(s.gateOn), onOff(s.outputOn), s.mode, s.cfg.Model, keySource,
181
+ strings.Join(s.cfg.Gate.Tools, "/"), strings.Join(s.cfg.Output.Tools, "/"), x.gateMemo.size(), x.outMemo.size()), "info")
182
+ return
183
+ }
184
+ var b strings.Builder
185
+ dest := ""
186
+ if s.be != nil {
187
+ dest = s.be.Destination()
188
+ }
189
+ if !s.on {
190
+ b.WriteString("Jev is off. Nothing is judged and nothing is sent.\n")
191
+ b.WriteString("Content sent for judgment would leave this machine; /jev on shows exactly what, and where, before it turns anything on.\n")
192
+ if beErr != nil {
193
+ fmt.Fprintf(&b, "Setup: %s\n", x.redact(beErr.Error()))
194
+ } else if dest != "" {
195
+ fmt.Fprintf(&b, "Judge: %s\n", dest)
196
+ }
197
+ ctx.Notify(strings.TrimRight(b.String(), "\n"), "info")
198
+ return
199
+ }
200
+ fmt.Fprintf(&b, "Jev is ON — %s mode, output judge %s\n", s.mode, onOff(s.outputOn))
201
+ fmt.Fprintf(&b, " Judge %s\n", dest)
202
+ if s.cfg.Backend == backendTypeSafe {
203
+ ks := s.cfg.KeySource
204
+ if ks == "" {
205
+ ks = "missing"
206
+ }
207
+ fmt.Fprintf(&b, " Key %s\n", ks)
208
+ }
209
+ fmt.Fprintf(&b, " Gate %s (%s)\n", strings.Join(s.cfg.Gate.Tools, ", "), onOff(s.gateOn))
210
+ fmt.Fprintf(&b, " Output %s (%s)\n", strings.Join(s.cfg.Output.Tools, ", "), onOff(s.outputOn))
211
+ b.WriteString(" Sends working directory, tool name, your last message, tool arguments, tool output, jev_ask text — cut to your limits\n")
212
+ b.WriteString(" If down tool calls run unjudged (fails open)\n")
213
+ if last != nil {
214
+ fmt.Fprintf(&b, " Last %s: %s\n", last.tool, last.verdict.summary())
215
+ }
216
+ if lastOut != nil {
217
+ fmt.Fprintf(&b, " Output %s: %s\n", lastOut.tool, lastOut.verdict.Kind)
218
+ }
219
+ if fail != "" {
220
+ fmt.Fprintf(&b, " Failure %s\n", fail)
221
+ }
222
+ b.WriteString(" Commands /jev on|off · /jev mode shadow|enforce · /jev last · /jev output · /jev check <text>")
223
+ ctx.Notify(b.String(), "info")
224
+ }