@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.
- package/CREDITS.md +22 -0
- package/LICENSE +22 -0
- package/README.md +237 -0
- package/extensions/jev/ask.go +166 -0
- package/extensions/jev/ask_test.go +218 -0
- package/extensions/jev/backend.go +128 -0
- package/extensions/jev/bench_test.go +64 -0
- package/extensions/jev/boundaries_test.go +159 -0
- package/extensions/jev/command.go +224 -0
- package/extensions/jev/commands_test.go +214 -0
- package/extensions/jev/config.go +450 -0
- package/extensions/jev/errors_test.go +191 -0
- package/extensions/jev/extension.go +391 -0
- package/extensions/jev/fakehost_test.go +548 -0
- package/extensions/jev/gate.go +125 -0
- package/extensions/jev/gate_test.go +610 -0
- package/extensions/jev/gatekey_test.go +24 -0
- package/extensions/jev/go.mod +9 -0
- package/extensions/jev/go.sum +2 -0
- package/extensions/jev/go.work +10 -0
- package/extensions/jev/helpers_test.go +404 -0
- package/extensions/jev/memo.go +88 -0
- package/extensions/jev/output.go +89 -0
- package/extensions/jev/output_test.go +187 -0
- package/extensions/jev/ownmodel_test.go +118 -0
- package/extensions/jev/render.go +136 -0
- package/extensions/jev/review_test.go +310 -0
- package/extensions/jev/source_test.go +57 -0
- package/extensions/jev/text.go +174 -0
- package/extensions/jev/trust_test.go +335 -0
- package/extensions/jev/types.go +227 -0
- package/libs/typesafe/CONTRACT.md +125 -0
- package/libs/typesafe/CREDITS.md +37 -0
- package/libs/typesafe/LICENSE +23 -0
- package/libs/typesafe/README.md +19 -0
- package/libs/typesafe/go.mod +3 -0
- package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
- package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
- package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
- package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
- package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
- package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
- package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
- package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
- package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
- package/libs/typesafe/libraries/ownmodel/run.go +288 -0
- package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
- package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
- package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
- package/libs/typesafe/libraries/typesafe/answers.go +268 -0
- package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
- package/libs/typesafe/libraries/typesafe/batch.go +80 -0
- package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
- package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
- package/libs/typesafe/libraries/typesafe/client.go +561 -0
- package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
- package/libs/typesafe/libraries/typesafe/doc.go +27 -0
- package/libs/typesafe/libraries/typesafe/entry.go +142 -0
- package/libs/typesafe/libraries/typesafe/env.go +11 -0
- package/libs/typesafe/libraries/typesafe/errors.go +310 -0
- package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
- package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
- package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
- package/libs/typesafe/libraries/typesafe/logging.go +160 -0
- package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
- package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
- package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
- package/libs/typesafe/libraries/typesafe/questions.go +490 -0
- package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
- package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
- package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
- package/libs/typesafe/libraries/typesafe/retry.go +350 -0
- package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
- package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
- package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
- package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
- package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
- package/libs/typesafe/libraries/typesafe/version.go +10 -0
- package/libs/typesafe/package.json +37 -0
- package/libs/typesafe/provenance.json +49 -0
- package/package.json +42 -0
- package/port/PORT.md +107 -0
- package/port/e2e/gate-and-output.py +35 -0
- package/port/e2e/jev-ask.py +36 -0
- package/port/e2e/model-switch.py +44 -0
- package/port/e2e/off-by-default.py +34 -0
- package/port/gen-scenarios.py +103 -0
- package/port/golden/cache-identical-calls.jsonl +30 -0
- package/port/golden/clear.jsonl +22 -0
- package/port/golden/commands.jsonl +43 -0
- package/port/golden/enforce-accept.jsonl +23 -0
- package/port/golden/enforce-decline.jsonl +22 -0
- package/port/golden/jev-ask.jsonl +20 -0
- package/port/golden/output-advice.jsonl +23 -0
- package/port/golden/output-leak.jsonl +24 -0
- package/port/golden/output-low-confidence.jsonl +22 -0
- package/port/golden/shadow-flagged.jsonl +23 -0
- package/port/golden/unjudged-tools.jsonl +19 -0
- package/port/golden/write-elision.jsonl +21 -0
- package/port/mutate-unit.py +63 -0
- package/port/mutations.json +578 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +181 -0
- package/port/oracle/SHA256SUMS +8 -0
- package/port/oracle/package.json +43 -0
- package/port/oracle/src/client.ts +409 -0
- package/port/oracle/src/config.ts +363 -0
- package/port/oracle/src/gate.ts +229 -0
- package/port/oracle/src/index.ts +649 -0
- package/port/oracle/src/output.ts +163 -0
- package/port/red-run.log +309 -0
- package/port/scenarios/cache-identical-calls.json +71 -0
- package/port/scenarios/clear.json +61 -0
- package/port/scenarios/commands.json +119 -0
- package/port/scenarios/enforce-accept.json +66 -0
- package/port/scenarios/enforce-decline.json +57 -0
- package/port/scenarios/jev-ask.json +83 -0
- package/port/scenarios/output-advice.json +61 -0
- package/port/scenarios/output-leak.json +61 -0
- package/port/scenarios/output-low-confidence.json +61 -0
- package/port/scenarios/shadow-flagged.json +61 -0
- package/port/scenarios/unjudged-tools.json +55 -0
- package/port/scenarios/write-elision.json +53 -0
- package/provenance.json +18 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
package jev_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strconv"
|
|
7
|
+
"strings"
|
|
8
|
+
"sync"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Cases added by the adversarial review (rev-pigpen-jev), findings R1 to R4 in
|
|
13
|
+
// port/PORT.md. Each was red before its fix.
|
|
14
|
+
|
|
15
|
+
// R1. In enforce mode a flagged call runs only when the user allows it. When the
|
|
16
|
+
// confirmation cannot be shown (the host UI call fails), the original's handler
|
|
17
|
+
// throws and Pi blocks the call (agent-session.ts _installAgentToolHooks: "Extension
|
|
18
|
+
// failed, blocking execution"); the port failed open and ran the flagged call.
|
|
19
|
+
func TestReview_EnforceConfirmFailureBlocks(t *testing.T) {
|
|
20
|
+
e := newEnv(t)
|
|
21
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
22
|
+
srv := newFakeJev(t, always(gateBody(0.99, 0.04, 0.4, 0.5, 0.9)))
|
|
23
|
+
e.writeGlobal(t, jevConfig(srv, map[string]any{"gate": map[string]any{"mode": "enforce"}}))
|
|
24
|
+
hs := newHostState()
|
|
25
|
+
h := start(t, e, hs, HostOptions{OnCall: func(method string, args map[string]any) (map[string]any, string) {
|
|
26
|
+
if method == "ui.confirm" {
|
|
27
|
+
return nil, "ui_error: the terminal is gone"
|
|
28
|
+
}
|
|
29
|
+
return hs.onCall(method, args)
|
|
30
|
+
}})
|
|
31
|
+
block, reason := h.toolCall("bash", bash("rm -rf src"))
|
|
32
|
+
if !block {
|
|
33
|
+
t.Fatal("enforce mode ran a flagged call the user never approved")
|
|
34
|
+
}
|
|
35
|
+
mustContain(t, "reason", reason, "pi-jev: destructive 0.99", "not confirmed", "the terminal is gone")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// R2. The default judge is "the model PiG is configured with", named in the
|
|
39
|
+
// disclosure as the provider that already receives the conversation. After the
|
|
40
|
+
// user switches models, judging must follow: staying on the old provider keeps
|
|
41
|
+
// sending content to a provider that no longer receives the conversation.
|
|
42
|
+
func TestReview_DefaultJudgeFollowsTheSelectedModel(t *testing.T) {
|
|
43
|
+
e := newEnv(t)
|
|
44
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "rich"})
|
|
45
|
+
hs := newHostState()
|
|
46
|
+
var mu sync.Mutex
|
|
47
|
+
var models []map[string]any
|
|
48
|
+
h := start(t, e, hs, HostOptions{ModelStream: func(model, _ map[string]any) []map[string]any {
|
|
49
|
+
mu.Lock()
|
|
50
|
+
models = append(models, model)
|
|
51
|
+
mu.Unlock()
|
|
52
|
+
return ModelText(gateAnswers(0, 0, 0, 0))
|
|
53
|
+
}})
|
|
54
|
+
next := map[string]any{"id": "private-1", "name": "private-1", "provider": "local"}
|
|
55
|
+
hs.mu.Lock()
|
|
56
|
+
hs.model = next
|
|
57
|
+
hs.mu.Unlock()
|
|
58
|
+
h.Fire("model_select", map[string]any{"model": next, "previousModel": map[string]any{"id": "judge-1", "provider": "acme"}, "source": "set"})
|
|
59
|
+
h.toolCall("bash", bash("ls"))
|
|
60
|
+
mu.Lock()
|
|
61
|
+
defer mu.Unlock()
|
|
62
|
+
if len(models) != 1 || models[0]["provider"] != "local" {
|
|
63
|
+
t.Fatalf("judge after switching to local/private-1 = %v", models)
|
|
64
|
+
}
|
|
65
|
+
h.Command("jev", "")
|
|
66
|
+
mustContain(t, "status", lastNote(h), "local/private-1")
|
|
67
|
+
if strings.Contains(lastNote(h), "acme/judge-1") {
|
|
68
|
+
t.Errorf("status still names the old model: %q", lastNote(h))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// R2b. A model named in the user's own config is not replaced by a model switch.
|
|
73
|
+
func TestReview_ConfiguredJudgeIgnoresModelSwitch(t *testing.T) {
|
|
74
|
+
e := newEnv(t)
|
|
75
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain", "model": "other/cheap-1"})
|
|
76
|
+
hs := newHostState()
|
|
77
|
+
var models []map[string]any
|
|
78
|
+
var mu sync.Mutex
|
|
79
|
+
h := start(t, e, hs, HostOptions{ModelStream: func(model, _ map[string]any) []map[string]any {
|
|
80
|
+
mu.Lock()
|
|
81
|
+
models = append(models, model)
|
|
82
|
+
mu.Unlock()
|
|
83
|
+
return ModelText(gateAnswers(0, 0, 0, 0))
|
|
84
|
+
}})
|
|
85
|
+
h.Fire("model_select", map[string]any{"model": map[string]any{"id": "private-1", "provider": "local"}, "source": "set"})
|
|
86
|
+
h.toolCall("bash", bash("ls"))
|
|
87
|
+
mu.Lock()
|
|
88
|
+
defer mu.Unlock()
|
|
89
|
+
if len(models) != 1 || models[0]["provider"] != "other" {
|
|
90
|
+
t.Fatalf("models = %v", models)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// R2c. With no model at start, the judge becomes available once one is selected.
|
|
95
|
+
func TestReview_ModelSelectedLaterEnablesTheDefaultJudge(t *testing.T) {
|
|
96
|
+
e := newEnv(t)
|
|
97
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain"})
|
|
98
|
+
hs := newHostState()
|
|
99
|
+
hs.model = map[string]any{}
|
|
100
|
+
var n int
|
|
101
|
+
var mu sync.Mutex
|
|
102
|
+
h := start(t, e, hs, HostOptions{ModelStream: func(_, _ map[string]any) []map[string]any {
|
|
103
|
+
mu.Lock()
|
|
104
|
+
n++
|
|
105
|
+
mu.Unlock()
|
|
106
|
+
return ModelText(gateAnswers(0, 0, 0, 0))
|
|
107
|
+
}})
|
|
108
|
+
next := map[string]any{"id": "judge-2", "name": "judge-2", "provider": "acme"}
|
|
109
|
+
hs.mu.Lock()
|
|
110
|
+
hs.model = next
|
|
111
|
+
hs.mu.Unlock()
|
|
112
|
+
h.Fire("model_select", map[string]any{"model": next, "source": "set"})
|
|
113
|
+
h.toolCall("bash", bash("ls"))
|
|
114
|
+
mu.Lock()
|
|
115
|
+
defer mu.Unlock()
|
|
116
|
+
if n != 1 {
|
|
117
|
+
t.Fatalf("judge requests after a model was selected = %d", n)
|
|
118
|
+
}
|
|
119
|
+
registered := false
|
|
120
|
+
for _, c := range h.CallsTo("registerTool") {
|
|
121
|
+
registered = registered || c.Args["name"] == "jev_ask"
|
|
122
|
+
}
|
|
123
|
+
if !registered {
|
|
124
|
+
t.Error("jev_ask was not registered once a judge became available")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// R3. The disclosure lists what leaves the machine. jev_ask sends text the model
|
|
129
|
+
// chooses (up to maxStateChars) and /jev check sends the user's text; neither was
|
|
130
|
+
// listed.
|
|
131
|
+
func TestReview_DisclosureNamesJevAskAndCheck(t *testing.T) {
|
|
132
|
+
e := newEnv(t)
|
|
133
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
134
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
135
|
+
cfg := jevConfig(srv, map[string]any{"display": "rich"})
|
|
136
|
+
delete(cfg, "enabled")
|
|
137
|
+
e.writeGlobal(t, cfg)
|
|
138
|
+
hs := newHostState()
|
|
139
|
+
h := start(t, e, hs, HostOptions{})
|
|
140
|
+
h.Command("jev", "on")
|
|
141
|
+
c := hs.confirmCalls()
|
|
142
|
+
if len(c) != 1 {
|
|
143
|
+
t.Fatalf("confirm calls = %d", len(c))
|
|
144
|
+
}
|
|
145
|
+
mustContain(t, "disclosure", c[0]["message"].(string), "jev_ask", "8000", "/jev check")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// R4. A repository's .pig/pi-jev.json may tune the gate, but relaxing the
|
|
149
|
+
// protection the user configured (turning the gate off, enforce to shadow, higher
|
|
150
|
+
// thresholds) must not be silent.
|
|
151
|
+
func TestReview_ProjectThatRelaxesTheGateIsReported(t *testing.T) {
|
|
152
|
+
e := newEnv(t)
|
|
153
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
154
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
155
|
+
e.writeGlobal(t, jevConfig(srv, map[string]any{"gate": map[string]any{"mode": "enforce", "blockWithoutUI": true}}))
|
|
156
|
+
e.writeProject(t, map[string]any{
|
|
157
|
+
"gate": map[string]any{"enabled": false, "mode": "shadow", "blockWithoutUI": false, "blockOn": map[string]any{"destructive": 1, "impact": 3}},
|
|
158
|
+
"output": map[string]any{"enabled": false, "leakThreshold": 1},
|
|
159
|
+
})
|
|
160
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
161
|
+
var warn string
|
|
162
|
+
for _, n := range h.notifications() {
|
|
163
|
+
if strings.Contains(n, "relaxes") {
|
|
164
|
+
warn = n
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if warn == "" {
|
|
168
|
+
t.Fatalf("no warning for a project that turned the gate off: %q", h.notifications())
|
|
169
|
+
}
|
|
170
|
+
mustContain(t, "warning", warn, "gate.enabled false", "gate.mode shadow", "gate.blockWithoutUI false",
|
|
171
|
+
"gate.blockOn.destructive 1", "gate.blockOn.impact 3", "output.enabled false", "output.leakThreshold 1")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func TestReview_StricterProjectIsNotReported(t *testing.T) {
|
|
175
|
+
e := newEnv(t)
|
|
176
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
177
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
178
|
+
e.writeGlobal(t, jevConfig(srv, nil))
|
|
179
|
+
e.writeProject(t, map[string]any{"gate": map[string]any{"mode": "enforce", "blockOn": map[string]any{"destructive": 0.4}}})
|
|
180
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
181
|
+
if h.anyNotification("relaxes") {
|
|
182
|
+
t.Errorf("a stricter project was reported as relaxing: %q", h.notifications())
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// R5. Guard tests for the trust boundary (PORT.md C2). The existing test sets every
|
|
187
|
+
// destination key in one project file against a user config that already names the
|
|
188
|
+
// backend, model and key, so a project-applied apiKeyFile, backend, model or
|
|
189
|
+
// acknowledged survived as mutants (x-project-*). Each key is tested alone here.
|
|
190
|
+
|
|
191
|
+
func TestReview_ProjectCannotChooseTheKeyFile(t *testing.T) {
|
|
192
|
+
e := newEnv(t)
|
|
193
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
194
|
+
e.writeGlobal(t, jevConfig(srv, nil)) // no key of the user's own
|
|
195
|
+
secret := filepath.Join(e.cwd, "not-a-key.txt")
|
|
196
|
+
if err := os.WriteFile(secret, []byte("contents-of-a-private-file"), 0o600); err != nil {
|
|
197
|
+
t.Fatal(err)
|
|
198
|
+
}
|
|
199
|
+
e.writeProject(t, map[string]any{"apiKeyFile": secret})
|
|
200
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
201
|
+
h.toolCall("bash", bash("ls"))
|
|
202
|
+
for _, r := range srv.requests() {
|
|
203
|
+
if strings.Contains(r.Auth, "contents-of-a-private-file") {
|
|
204
|
+
t.Fatalf("a project file chose the file sent as the API key: %q", r.Auth)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if !h.anyNotification("ignored project setting apiKeyFile") {
|
|
208
|
+
t.Errorf("notifications = %q", h.notifications())
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func TestReview_ProjectCannotSwitchTheBackend(t *testing.T) {
|
|
213
|
+
e := newEnv(t)
|
|
214
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
215
|
+
attacker := newFakeJev(t, always(clearGate()))
|
|
216
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain", "endpoint": attacker.srv.URL, "model": "acme/judge-1"})
|
|
217
|
+
e.writeProject(t, map[string]any{"backend": "typesafe"})
|
|
218
|
+
var n int
|
|
219
|
+
var mu sync.Mutex
|
|
220
|
+
h := start(t, e, newHostState(), HostOptions{ModelStream: func(_, _ map[string]any) []map[string]any {
|
|
221
|
+
mu.Lock()
|
|
222
|
+
n++
|
|
223
|
+
mu.Unlock()
|
|
224
|
+
return ModelText(gateAnswers(0, 0, 0, 0))
|
|
225
|
+
}})
|
|
226
|
+
h.toolCall("bash", bash("ls"))
|
|
227
|
+
mu.Lock()
|
|
228
|
+
defer mu.Unlock()
|
|
229
|
+
if attacker.count() != 0 || n != 1 {
|
|
230
|
+
t.Fatalf("project backend applied: %d HTTP requests, %d model requests", attacker.count(), n)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func TestReview_ProjectCannotChooseTheModel(t *testing.T) {
|
|
235
|
+
e := newEnv(t)
|
|
236
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain"})
|
|
237
|
+
e.writeProject(t, map[string]any{"model": "elsewhere/judge"})
|
|
238
|
+
var models []map[string]any
|
|
239
|
+
var mu sync.Mutex
|
|
240
|
+
h := start(t, e, newHostState(), HostOptions{ModelStream: func(model, _ map[string]any) []map[string]any {
|
|
241
|
+
mu.Lock()
|
|
242
|
+
models = append(models, model)
|
|
243
|
+
mu.Unlock()
|
|
244
|
+
return ModelText(gateAnswers(0, 0, 0, 0))
|
|
245
|
+
}})
|
|
246
|
+
h.toolCall("bash", bash("ls"))
|
|
247
|
+
mu.Lock()
|
|
248
|
+
defer mu.Unlock()
|
|
249
|
+
if len(models) != 1 || models[0]["provider"] != "acme" {
|
|
250
|
+
t.Fatalf("a project file chose the judge model: %v", models)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func TestReview_ProjectCannotHideTheDisclosure(t *testing.T) {
|
|
255
|
+
e := newEnv(t)
|
|
256
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "display": "rich"})
|
|
257
|
+
e.writeProject(t, map[string]any{"acknowledged": true})
|
|
258
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
259
|
+
if !h.anyNotification("What leaves this machine") {
|
|
260
|
+
t.Errorf("a project file acknowledged the disclosure for the user: %q", h.notifications())
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// R5b. http is accepted only for this machine; an IP literal is not "localhost".
|
|
265
|
+
func TestReview_PlainHTTPToARemoteIPIsRefused(t *testing.T) {
|
|
266
|
+
e := newEnv(t)
|
|
267
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
268
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "backend": "typesafe", "display": "plain",
|
|
269
|
+
"endpoint": "http://192.0.2.10/v1/systemone", "model": "m"})
|
|
270
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
271
|
+
if !h.anyNotification("must use https") {
|
|
272
|
+
t.Errorf("http:// to a remote IP was accepted: %q", h.notifications())
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// R5c. The original's output cache key is tool and output (output.ts outputKey).
|
|
277
|
+
func TestReview_OutputCacheIsPerTool(t *testing.T) {
|
|
278
|
+
e := newEnv(t)
|
|
279
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
280
|
+
srv := newFakeJev(t, always(outBody(0.01, "no_failure", 0.99)))
|
|
281
|
+
e.writeGlobal(t, jevConfig(srv, map[string]any{"output": map[string]any{"tools": []any{"bash", "read"}}}))
|
|
282
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
283
|
+
h.toolResult("bash", bash("cat x"), text("same text"), false)
|
|
284
|
+
h.toolResult("read", map[string]any{"path": "x"}, text("same text"), false)
|
|
285
|
+
if srv.count() != 2 {
|
|
286
|
+
t.Errorf("requests = %d, want one per tool", srv.count())
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// R5d. PORT.md C3: when shortening is not enough, the arguments go before the
|
|
291
|
+
// user's request (which by then is shortened too).
|
|
292
|
+
func TestReview_StateCapDropsArgumentsBeforeTheRequest(t *testing.T) {
|
|
293
|
+
e := newEnv(t)
|
|
294
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
295
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
296
|
+
e.writeGlobal(t, jevConfig(srv, map[string]any{"maxStateChars": 300}))
|
|
297
|
+
hs := newHostState()
|
|
298
|
+
hs.user = "tidy the build"
|
|
299
|
+
h := start(t, e, hs, HostOptions{})
|
|
300
|
+
in := map[string]any{}
|
|
301
|
+
for i := 0; i < 40; i++ {
|
|
302
|
+
in["k"+strconv.Itoa(i)] = i
|
|
303
|
+
}
|
|
304
|
+
h.toolCall("bash", in)
|
|
305
|
+
st := stateOf(t, srv.first(t))
|
|
306
|
+
user, _ := st["user_request"].(string)
|
|
307
|
+
if st["arguments"] != "…[elided to fit maxStateChars]" || !strings.HasPrefix(user, "tidy the") {
|
|
308
|
+
t.Errorf("state = %v", st)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package jev_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func goSources(t *testing.T) map[string]string {
|
|
11
|
+
t.Helper()
|
|
12
|
+
files, _ := filepath.Glob("*.go")
|
|
13
|
+
out := map[string]string{}
|
|
14
|
+
for _, f := range files {
|
|
15
|
+
if strings.HasSuffix(f, "_test.go") {
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
b, err := os.ReadFile(f)
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
out[f] = string(b)
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The task: "use the model from PiG configuration, no hardcoded provider".
|
|
28
|
+
func TestNoHardcodedProviderOrEndpoint(t *testing.T) {
|
|
29
|
+
for name, src := range goSources(t) {
|
|
30
|
+
for _, bad := range []string{"api.typesafe.ai", "jev-latest", "api.openai.com", "api.anthropic.com", "openrouter.ai"} {
|
|
31
|
+
if strings.Contains(src, bad) {
|
|
32
|
+
t.Errorf("%s hardcodes %q", name, bad)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Public PiG Go SDK only: no PiG-internal imports, no Node.
|
|
39
|
+
func TestOnlyPublicSDKImports(t *testing.T) {
|
|
40
|
+
for name, src := range goSources(t) {
|
|
41
|
+
for _, line := range strings.Split(src, "\n") {
|
|
42
|
+
l := strings.TrimSpace(line)
|
|
43
|
+
if strings.HasPrefix(l, `"github.com/MichaelKinsy/PiG/`) && !strings.HasPrefix(l, `"github.com/MichaelKinsy/PiG/extensions/sdk`) {
|
|
44
|
+
t.Errorf("%s imports %s", name, l)
|
|
45
|
+
}
|
|
46
|
+
if strings.Contains(l, "os/exec") {
|
|
47
|
+
t.Errorf("%s starts a process (%s)", name, l)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Named gaps (Skill step 3: a gap is a named skipped test, never silent).
|
|
54
|
+
|
|
55
|
+
func TestUserRequestJoinsTextBlocksWithNewline_GAP(t *testing.T) {
|
|
56
|
+
t.Skip("upstream joins a user message's text blocks with \\n (index.ts messageText); the Go SDK's BranchEntry flattens them with no separator; a prompt sent over RPC is one block, so no scenario differs")
|
|
57
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
package jev
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"math"
|
|
8
|
+
"math/big"
|
|
9
|
+
"runtime"
|
|
10
|
+
"sort"
|
|
11
|
+
"strings"
|
|
12
|
+
"unicode/utf8"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// The original measures strings in UTF-16 code units (JavaScript .length and
|
|
16
|
+
// .slice). Every limit here uses the same unit so a configured 400 keeps meaning
|
|
17
|
+
// what the README says.
|
|
18
|
+
|
|
19
|
+
func utf16Len(s string) int {
|
|
20
|
+
n := 0
|
|
21
|
+
for _, r := range s {
|
|
22
|
+
n++
|
|
23
|
+
if r >= 0x10000 {
|
|
24
|
+
n++
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return n
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// sliceUTF16 returns the first n UTF-16 units of s. JavaScript can cut a
|
|
31
|
+
// surrogate pair in two and send a lone surrogate; a Go string cannot hold one,
|
|
32
|
+
// so a cut through a pair yields U+FFFD in its place.
|
|
33
|
+
func sliceUTF16(s string, n int) string {
|
|
34
|
+
if n <= 0 {
|
|
35
|
+
return ""
|
|
36
|
+
}
|
|
37
|
+
units := 0
|
|
38
|
+
for i, r := range s {
|
|
39
|
+
w := 1
|
|
40
|
+
if r >= 0x10000 {
|
|
41
|
+
w = 2
|
|
42
|
+
}
|
|
43
|
+
if units+w > n {
|
|
44
|
+
if units < n { // half of a pair
|
|
45
|
+
return s[:i] + "\uFFFD"
|
|
46
|
+
}
|
|
47
|
+
return s[:i]
|
|
48
|
+
}
|
|
49
|
+
units += w
|
|
50
|
+
}
|
|
51
|
+
return s
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// elide keeps the first max units and states how many were cut, as
|
|
55
|
+
// `${value.slice(0, max)}…[N chars elided]`.
|
|
56
|
+
func elide(s string, max int) string {
|
|
57
|
+
total := utf16Len(s)
|
|
58
|
+
if total <= max {
|
|
59
|
+
return s
|
|
60
|
+
}
|
|
61
|
+
return fmt.Sprintf("%s…[%d chars elided]", sliceUTF16(s, max), total-max)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// truncateText is the gate's user-request cut: `…[truncated]`.
|
|
65
|
+
func truncateText(s string, max int) string {
|
|
66
|
+
if utf16Len(s) <= max {
|
|
67
|
+
return s
|
|
68
|
+
}
|
|
69
|
+
return sliceUTF16(s, max) + "…[truncated]"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// toFixed2 is JavaScript's Number.prototype.toFixed(2): the exact decimal value
|
|
73
|
+
// of the double, a tie rounds up (0.125 gives 0.13, where Go's %.2f gives 0.12).
|
|
74
|
+
func toFixed2(x float64) string {
|
|
75
|
+
switch {
|
|
76
|
+
case math.IsNaN(x):
|
|
77
|
+
return "NaN"
|
|
78
|
+
case math.IsInf(x, 1):
|
|
79
|
+
return "Infinity"
|
|
80
|
+
case math.IsInf(x, -1):
|
|
81
|
+
return "-Infinity"
|
|
82
|
+
}
|
|
83
|
+
neg := x < 0
|
|
84
|
+
r := new(big.Rat).SetFloat64(math.Abs(x))
|
|
85
|
+
r.Mul(r, big.NewRat(100, 1))
|
|
86
|
+
r.Add(r, big.NewRat(1, 2))
|
|
87
|
+
n := new(big.Int).Div(r.Num(), r.Denom())
|
|
88
|
+
digits := n.String()
|
|
89
|
+
for len(digits) < 3 {
|
|
90
|
+
digits = "0" + digits
|
|
91
|
+
}
|
|
92
|
+
out := digits[:len(digits)-2] + "." + digits[len(digits)-2:]
|
|
93
|
+
if neg {
|
|
94
|
+
out = "-" + out
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// jsonString encodes s without HTML escaping.
|
|
100
|
+
func jsonString(s string) []byte {
|
|
101
|
+
var buf bytes.Buffer
|
|
102
|
+
enc := json.NewEncoder(&buf)
|
|
103
|
+
enc.SetEscapeHTML(false)
|
|
104
|
+
_ = enc.Encode(s)
|
|
105
|
+
return bytes.TrimRight(buf.Bytes(), "\n")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func jsonValue(v any) []byte {
|
|
109
|
+
var buf bytes.Buffer
|
|
110
|
+
enc := json.NewEncoder(&buf)
|
|
111
|
+
enc.SetEscapeHTML(false)
|
|
112
|
+
if err := enc.Encode(v); err != nil {
|
|
113
|
+
return []byte("null")
|
|
114
|
+
}
|
|
115
|
+
return bytes.TrimRight(buf.Bytes(), "\n")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const maxNesting = 64
|
|
119
|
+
|
|
120
|
+
// summarize copies a decoded JSON value, eliding every string longer than max
|
|
121
|
+
// (at any depth: the original stopped at depth 4 and let deeper strings out
|
|
122
|
+
// whole, see PORT.md C7). Keys are written sorted; the SDK hands events over as
|
|
123
|
+
// Go maps, so the wire order of the original's argument keys is not available.
|
|
124
|
+
func summarize(v any, max, depth int) any {
|
|
125
|
+
if depth > maxNesting {
|
|
126
|
+
return "…[nested value elided]"
|
|
127
|
+
}
|
|
128
|
+
switch t := v.(type) {
|
|
129
|
+
case string:
|
|
130
|
+
return elide(t, max)
|
|
131
|
+
case []any:
|
|
132
|
+
out := make([]any, len(t))
|
|
133
|
+
for i, item := range t {
|
|
134
|
+
out[i] = summarize(item, max, depth+1)
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
case map[string]any:
|
|
138
|
+
out := make(map[string]any, len(t))
|
|
139
|
+
for k, item := range t {
|
|
140
|
+
out[k] = summarize(item, max, depth+1)
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
return v
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// platform is process.platform as Node spells it.
|
|
148
|
+
func platform() string {
|
|
149
|
+
if runtime.GOOS == "windows" {
|
|
150
|
+
return "win32"
|
|
151
|
+
}
|
|
152
|
+
return runtime.GOOS
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func sortedKeys[V any](m map[string]V) []string {
|
|
156
|
+
keys := make([]string, 0, len(m))
|
|
157
|
+
for k := range m {
|
|
158
|
+
keys = append(keys, k)
|
|
159
|
+
}
|
|
160
|
+
sort.Strings(keys)
|
|
161
|
+
return keys
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// collapse folds whitespace runs and trims, as the original's error text does.
|
|
165
|
+
func collapse(s string) string {
|
|
166
|
+
return strings.Join(strings.Fields(s), " ")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func cut(s string, max int) string {
|
|
170
|
+
if utf8.RuneCountInString(s) <= max {
|
|
171
|
+
return s
|
|
172
|
+
}
|
|
173
|
+
return string([]rune(s)[:max]) + "…"
|
|
174
|
+
}
|