@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,187 @@
|
|
|
1
|
+
package jev_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// Output judge: src/index.ts tool_result, src/output.ts.
|
|
9
|
+
|
|
10
|
+
func outputHost(t *testing.T, body any, extra map[string]any) (*Host, *fakeJev) {
|
|
11
|
+
t.Helper()
|
|
12
|
+
e := newEnv(t)
|
|
13
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
14
|
+
srv := newFakeJev(t, always(body))
|
|
15
|
+
e.writeGlobal(t, jevConfig(srv, extra))
|
|
16
|
+
return start(t, e, newHostState(), HostOptions{}), srv
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func lastText(t *testing.T, patched []string) string {
|
|
20
|
+
t.Helper()
|
|
21
|
+
if len(patched) == 0 {
|
|
22
|
+
t.Fatal("result was not patched")
|
|
23
|
+
}
|
|
24
|
+
return patched[len(patched)-1]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestOutput_LeakAppendsNoticeAndWarns(t *testing.T) {
|
|
28
|
+
h, _ := outputHost(t, outBody(0.94, "no_failure", 0.9), nil)
|
|
29
|
+
patched, none := h.toolResult("bash", bash("cat .env"), text("API_KEY=abc"), false)
|
|
30
|
+
if none {
|
|
31
|
+
t.Fatal("result not patched")
|
|
32
|
+
}
|
|
33
|
+
if patched[0] != "API_KEY=abc" {
|
|
34
|
+
t.Errorf("original block changed: %q", patched)
|
|
35
|
+
}
|
|
36
|
+
want := "[pi-jev] Jev flagged this output as containing a secret (0.94). Do not repeat the value in a reply, a file, or a command; refer to it by name instead."
|
|
37
|
+
if got := lastText(t, patched); got != want {
|
|
38
|
+
t.Errorf("notice = %q", got)
|
|
39
|
+
}
|
|
40
|
+
if !h.anyNotification("warning: jev: bash output may carry a secret (0.94)") {
|
|
41
|
+
t.Errorf("notifications = %q", h.notifications())
|
|
42
|
+
}
|
|
43
|
+
if h.lastStatus() != "jev: leak (bash)" {
|
|
44
|
+
t.Errorf("status = %q", h.lastStatus())
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestOutput_FailureClassAdviceTable(t *testing.T) {
|
|
49
|
+
advice := map[string]string{
|
|
50
|
+
"transient": "retrying the same command unchanged is reasonable",
|
|
51
|
+
"environment": "fix the environment (missing tool, port, or service) before retrying",
|
|
52
|
+
"code_bug": "fix the code or types; retrying unchanged will not help",
|
|
53
|
+
"permission": "access was denied; change what is being accessed or ask the user",
|
|
54
|
+
"user_error": "the invocation itself was wrong; fix the command",
|
|
55
|
+
}
|
|
56
|
+
for class, line := range advice {
|
|
57
|
+
t.Run(class, func(t *testing.T) {
|
|
58
|
+
h, _ := outputHost(t, outBody(0.01, class, 1.0), nil)
|
|
59
|
+
patched, none := h.toolResult("bash", bash("x"), text("boom"), true)
|
|
60
|
+
if none {
|
|
61
|
+
t.Fatal("no advice")
|
|
62
|
+
}
|
|
63
|
+
want := "[pi-jev] Jev read this as a " + class + " failure (confidence 1.00): " + line + "."
|
|
64
|
+
if got := lastText(t, patched); got != want {
|
|
65
|
+
t.Errorf("notice = %q, want %q", got, want)
|
|
66
|
+
}
|
|
67
|
+
if len(h.notifications()) != 0 {
|
|
68
|
+
t.Errorf("advice notified: %q", h.notifications())
|
|
69
|
+
}
|
|
70
|
+
if h.lastStatus() != "jev: advice (bash)" {
|
|
71
|
+
t.Errorf("status = %q", h.lastStatus())
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func TestOutput_NoFailureAndUnknownClassSayNothing(t *testing.T) {
|
|
78
|
+
for _, class := range []string{"no_failure", "brand_new_class"} {
|
|
79
|
+
h, _ := outputHost(t, outBody(0.01, class, 1.0), nil)
|
|
80
|
+
if _, none := h.toolResult("bash", bash("x"), text("ok"), false); !none {
|
|
81
|
+
t.Errorf("class %q patched the result", class)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func TestOutput_LowClassConfidenceIsSilent(t *testing.T) {
|
|
87
|
+
h, _ := outputHost(t, outBody(0.01, "environment", 0.42), nil)
|
|
88
|
+
if _, none := h.toolResult("bash", bash("x"), text("fatal: not a git repository"), true); !none {
|
|
89
|
+
t.Fatal("advice below output.minConfidence")
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func TestOutput_LeakThresholdIsInclusiveAndLeakWinsOverClass(t *testing.T) {
|
|
94
|
+
h, _ := outputHost(t, outBody(0.9, "transient", 1.0), nil)
|
|
95
|
+
patched, _ := h.toolResult("bash", bash("x"), text("t"), false)
|
|
96
|
+
mustContain(t, "notice", lastText(t, patched), "containing a secret (0.90)")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestOutput_OnlyConfiguredToolsAreJudged(t *testing.T) {
|
|
100
|
+
h, srv := outputHost(t, outBody(0.99, "no_failure", 1), nil)
|
|
101
|
+
h.toolResult("read", map[string]any{"path": "a"}, text("secret"), false)
|
|
102
|
+
if srv.count() != 0 {
|
|
103
|
+
t.Fatal("judged the output of read")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func TestOutput_EmptyOrNonTextResultIsNotJudged(t *testing.T) {
|
|
108
|
+
h, srv := outputHost(t, outBody(0.99, "no_failure", 1), nil)
|
|
109
|
+
h.toolResult("bash", bash("x"), text(" \n "), false)
|
|
110
|
+
h.toolResult("bash", bash("x"), []any{map[string]any{"type": "image", "data": "AAAA"}}, false)
|
|
111
|
+
if srv.count() != 0 {
|
|
112
|
+
t.Fatalf("requests = %d", srv.count())
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func TestOutput_JoinsTextBlocksWithNewline(t *testing.T) {
|
|
117
|
+
h, srv := outputHost(t, outBody(0.01, "no_failure", 1), nil)
|
|
118
|
+
h.toolResult("bash", bash("x"), []any{map[string]any{"type": "text", "text": "one"}, map[string]any{"type": "text", "text": "two"}}, false)
|
|
119
|
+
st := stateOf(t, srv.first(t))
|
|
120
|
+
if st["output"] != "one\ntwo" || st["is_error"] != false || st["tool"] != "bash" {
|
|
121
|
+
t.Errorf("state = %v", st)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func TestOutput_RequestQuestions(t *testing.T) {
|
|
126
|
+
h, srv := outputHost(t, outBody(0.01, "no_failure", 1), nil)
|
|
127
|
+
h.toolResult("bash", bash("x"), text("hi"), false)
|
|
128
|
+
qs := srv.first(t).Body["questions"].(map[string]any)
|
|
129
|
+
leak := qs["leaks_secret"].(map[string]any)
|
|
130
|
+
if leak["type"] != "noul" || leak["instructions"] != "Does `output` contain a secret or credential that must not be written to a session transcript?" {
|
|
131
|
+
t.Errorf("leaks_secret = %v", leak)
|
|
132
|
+
}
|
|
133
|
+
fc := qs["failure_class"].(map[string]any)
|
|
134
|
+
crit := fc["criteria"].(map[string]any)
|
|
135
|
+
if fc["type"] != "choice" || len(crit) != 6 || crit["transient"] != "A network or resource hiccup that may succeed on a retry" {
|
|
136
|
+
t.Errorf("failure_class = %v", fc)
|
|
137
|
+
}
|
|
138
|
+
if got := strings.Join(questionOrder(srv.first(t).Raw), ","); got != "leaks_secret,failure_class" {
|
|
139
|
+
t.Errorf("order = %s", got)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func TestOutput_LongOutputIsElided(t *testing.T) {
|
|
144
|
+
h, srv := outputHost(t, outBody(0.01, "no_failure", 1), nil)
|
|
145
|
+
h.toolResult("bash", bash("x"), text(strings.Repeat("o", 2600)), false)
|
|
146
|
+
want := strings.Repeat("o", 2000) + "…[600 chars elided]"
|
|
147
|
+
if got := stateOf(t, srv.first(t))["output"]; got != want {
|
|
148
|
+
t.Errorf("output not elided: len %d", len(got.(string)))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func TestOutput_ArgumentsAreElidedAt400(t *testing.T) {
|
|
153
|
+
h, srv := outputHost(t, outBody(0.01, "no_failure", 1), nil)
|
|
154
|
+
h.toolResult("bash", map[string]any{"command": strings.Repeat("c", 500)}, text("hi"), false)
|
|
155
|
+
args := stateOf(t, srv.first(t))["arguments"].(map[string]any)
|
|
156
|
+
if args["command"] != strings.Repeat("c", 400)+"…[100 chars elided]" {
|
|
157
|
+
t.Errorf("command = %v", args["command"])
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func TestOutput_IdenticalOutputIsJudgedOnce(t *testing.T) {
|
|
162
|
+
h, srv := outputHost(t, outBody(0.01, "no_failure", 1), nil)
|
|
163
|
+
h.toolResult("bash", bash("a"), text("same"), false)
|
|
164
|
+
h.toolResult("bash", bash("b"), text("same"), false)
|
|
165
|
+
if srv.count() != 1 {
|
|
166
|
+
t.Fatalf("requests = %d, want 1", srv.count())
|
|
167
|
+
}
|
|
168
|
+
h.toolResult("bash", bash("c"), text("different"), false)
|
|
169
|
+
if srv.count() != 2 {
|
|
170
|
+
t.Fatalf("requests = %d, want 2", srv.count())
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func TestOutput_DisabledSkips(t *testing.T) {
|
|
175
|
+
h, srv := outputHost(t, outBody(0.99, "no_failure", 1), map[string]any{"output": map[string]any{"enabled": false}})
|
|
176
|
+
h.toolResult("bash", bash("x"), text("secret"), false)
|
|
177
|
+
if srv.count() != 0 {
|
|
178
|
+
t.Fatal("judged with output.enabled=false")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func TestOutput_LeakThresholdConfigurable(t *testing.T) {
|
|
183
|
+
h, _ := outputHost(t, outBody(0.5, "no_failure", 1), map[string]any{"output": map[string]any{"leakThreshold": 0.4}})
|
|
184
|
+
if _, none := h.toolResult("bash", bash("x"), text("t"), false); none {
|
|
185
|
+
t.Fatal("0.5 < configured 0.4 threshold not flagged")
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package jev_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// The default judge: the model PiG is configured with, through the shared client's
|
|
10
|
+
// own-model backend (prompted JSON answers, validated strictly). No API key and no
|
|
11
|
+
// endpoint of ours: the host authenticates and calls the provider.
|
|
12
|
+
|
|
13
|
+
func gateAnswers(d, e, b float64, impactLevel int) string {
|
|
14
|
+
probs := map[string]float64{"0": 0, "1": 0, "2": 0, "3": 0}
|
|
15
|
+
probs[string(rune('0'+impactLevel))] = 1
|
|
16
|
+
a, _ := json.Marshal(map[string]any{"answers": map[string]any{"destructive": d, "exfiltration": e, "beyond_scope": b, "impact": probs}})
|
|
17
|
+
return string(a)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func outAnswers(leak float64, class string) string {
|
|
21
|
+
probs := map[string]float64{"transient": 0, "environment": 0, "code_bug": 0, "permission": 0, "user_error": 0, "no_failure": 0}
|
|
22
|
+
probs[class] = 1
|
|
23
|
+
a, _ := json.Marshal(map[string]any{"answers": map[string]any{"leaks_secret": leak, "failure_class": probs}})
|
|
24
|
+
return string(a)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type modelCalls struct{ models, requests []map[string]any }
|
|
28
|
+
|
|
29
|
+
func modelHost(t *testing.T, reply func(request map[string]any) []map[string]any, cfg map[string]any) (*Host, *modelCalls, *env) {
|
|
30
|
+
t.Helper()
|
|
31
|
+
e := newEnv(t)
|
|
32
|
+
c := map[string]any{"enabled": true, "acknowledged": true, "display": "plain"}
|
|
33
|
+
for k, v := range cfg {
|
|
34
|
+
c[k] = v
|
|
35
|
+
}
|
|
36
|
+
e.writeGlobal(t, c)
|
|
37
|
+
mc := &modelCalls{}
|
|
38
|
+
hs := newHostState()
|
|
39
|
+
h := start(t, e, hs, HostOptions{ModelStream: func(model, request map[string]any) []map[string]any {
|
|
40
|
+
mc.models, mc.requests = append(mc.models, model), append(mc.requests, request)
|
|
41
|
+
return reply(request)
|
|
42
|
+
}})
|
|
43
|
+
return h, mc, e
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestOwnModel_GateJudgesWithTheSessionModel(t *testing.T) {
|
|
47
|
+
h, mc, e := modelHost(t, func(map[string]any) []map[string]any { return ModelText(gateAnswers(0.99, 0.1, 0.1, 3)) }, nil)
|
|
48
|
+
block, _ := h.toolCall("bash", bash("rm -rf src"))
|
|
49
|
+
if block {
|
|
50
|
+
t.Fatal("shadow mode blocked")
|
|
51
|
+
}
|
|
52
|
+
if !h.anyNotification("warning: jev shadow: bash - destructive 0.99, impact 3.00/3 at confidence 1.00") {
|
|
53
|
+
t.Errorf("notifications = %q", h.notifications())
|
|
54
|
+
}
|
|
55
|
+
if len(mc.models) != 1 || mc.models[0]["provider"] != "acme" || (mc.models[0]["modelId"] != "judge-1" && mc.models[0]["id"] != "judge-1") {
|
|
56
|
+
t.Errorf("the judge is not the session model: %v", mc.models)
|
|
57
|
+
}
|
|
58
|
+
all, _ := json.Marshal(mc.requests[0])
|
|
59
|
+
mustContain(t, "model request", string(all), "rm -rf src", e.cwd, "Is this action destructive?")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestOwnModel_OutputJudgeAppendsTheNotice(t *testing.T) {
|
|
63
|
+
h, _, _ := modelHost(t, func(map[string]any) []map[string]any { return ModelText(outAnswers(0.95, "no_failure")) }, nil)
|
|
64
|
+
patched, none := h.toolResult("bash", bash("cat .env"), text("API_KEY=abc"), false)
|
|
65
|
+
if none || !strings.Contains(patched[len(patched)-1], "containing a secret (0.95)") {
|
|
66
|
+
t.Errorf("patched = %q", patched)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestOwnModel_ConfiguredModelOverridesTheSessionModel(t *testing.T) {
|
|
71
|
+
h, mc, _ := modelHost(t, func(map[string]any) []map[string]any { return ModelText(gateAnswers(0, 0, 0, 0)) }, map[string]any{"model": "other/cheap-1"})
|
|
72
|
+
h.toolCall("bash", bash("ls"))
|
|
73
|
+
if len(mc.models) != 1 || mc.models[0]["provider"] != "other" {
|
|
74
|
+
t.Errorf("models = %v", mc.models)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func TestOwnModel_MalformedAnswerFailsOpen(t *testing.T) {
|
|
79
|
+
h, _, _ := modelHost(t, func(map[string]any) []map[string]any { return ModelText("I think this is fine.") }, nil)
|
|
80
|
+
if block, _ := h.toolCall("bash", bash("rm -rf /")); block {
|
|
81
|
+
t.Fatal("an unusable answer blocked a call")
|
|
82
|
+
}
|
|
83
|
+
if !h.anyNotification("(failing open)") || strings.Contains(h.lastStatus(), "clear") {
|
|
84
|
+
t.Errorf("notifications %q status %q", h.notifications(), h.lastStatus())
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func TestOwnModel_ProviderErrorFailsOpen(t *testing.T) {
|
|
89
|
+
h, _, _ := modelHost(t, func(map[string]any) []map[string]any { return ModelError("provider down") }, nil)
|
|
90
|
+
if block, _ := h.toolCall("bash", bash("ls")); block {
|
|
91
|
+
t.Fatal("blocked")
|
|
92
|
+
}
|
|
93
|
+
if !h.anyNotification("provider down") || h.lastStatus() != "jev: unavailable (failing open)" {
|
|
94
|
+
t.Errorf("notifications %q status %q", h.notifications(), h.lastStatus())
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func TestOwnModel_JevAskUsesTheSessionModel(t *testing.T) {
|
|
99
|
+
h, mc, _ := modelHost(t, func(map[string]any) []map[string]any {
|
|
100
|
+
return ModelText(`{"answers":{"relevant":0.9}}`)
|
|
101
|
+
}, nil)
|
|
102
|
+
got, details := askResult(t, h, map[string]any{"state": "the diff", "questions": []any{map[string]any{"id": "relevant", "type": "noul", "instructions": "Is this relevant?"}}})
|
|
103
|
+
if details["ok"] != true || !strings.Contains(got, "relevant: yes 0.90") || len(mc.models) != 1 {
|
|
104
|
+
t.Errorf("got %q %v (%d model calls)", got, details, len(mc.models))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestOwnModel_NoModelNoJudge(t *testing.T) {
|
|
109
|
+
e := newEnv(t)
|
|
110
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain"})
|
|
111
|
+
hs := newHostState()
|
|
112
|
+
hs.model = map[string]any{}
|
|
113
|
+
h := start(t, e, hs, HostOptions{})
|
|
114
|
+
h.toolCall("bash", bash("ls"))
|
|
115
|
+
if !h.anyNotification("needs a model") {
|
|
116
|
+
t.Errorf("notifications = %q", h.notifications())
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
package jev
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// Two display styles. "plain" is the original's wording, character for character
|
|
9
|
+
// (the equivalence scenarios run in it); "rich" is the default: one glyph per state,
|
|
10
|
+
// a verdict card with a bar per question, and the destination named wherever content
|
|
11
|
+
// is about to leave. Only wording and layout differ, never behavior.
|
|
12
|
+
|
|
13
|
+
const statusKey = "jev"
|
|
14
|
+
|
|
15
|
+
func (x *ext) plain() bool { return x.cfgDisplay() == "plain" }
|
|
16
|
+
|
|
17
|
+
func (x *ext) cfgDisplay() string {
|
|
18
|
+
x.mu.Lock()
|
|
19
|
+
defer x.mu.Unlock()
|
|
20
|
+
return x.cfg.Display
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// statusText renders a footer status. rest is the plain text after "jev: ".
|
|
24
|
+
func statusText(plain bool, glyph, rest string) string {
|
|
25
|
+
if plain {
|
|
26
|
+
return "jev: " + rest
|
|
27
|
+
}
|
|
28
|
+
if glyph == "" {
|
|
29
|
+
return "⚖ jev: " + rest
|
|
30
|
+
}
|
|
31
|
+
return "⚖ jev " + glyph + " " + rest
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func bar(p float64) string {
|
|
35
|
+
const width = 10
|
|
36
|
+
n := int(p*width + 0.5)
|
|
37
|
+
n = max(0, min(width, n))
|
|
38
|
+
return strings.Repeat("█", n) + strings.Repeat("·", width-n)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// card is the verdict card of the gate: one line per question with a bar, the
|
|
42
|
+
// value, its threshold and a mark on the ones that crossed it.
|
|
43
|
+
func card(v gateVerdict, c config) string {
|
|
44
|
+
t := c.Gate.BlockOn
|
|
45
|
+
line := func(label string, p, limit float64) string {
|
|
46
|
+
mark := " "
|
|
47
|
+
cmp := "<"
|
|
48
|
+
if p >= limit {
|
|
49
|
+
mark, cmp = "⚠ ", "≥"
|
|
50
|
+
}
|
|
51
|
+
return fmt.Sprintf(" %s%-13s %s %s %s %s", mark, label, bar(p), toFixed2(p), cmp, toFixed2(limit))
|
|
52
|
+
}
|
|
53
|
+
lines := []string{
|
|
54
|
+
line("destructive", v.Destructive, t.Destructive),
|
|
55
|
+
line("exfiltration", v.Exfiltration, t.Exfiltration),
|
|
56
|
+
line("beyond scope", v.BeyondScope, t.BeyondScope),
|
|
57
|
+
}
|
|
58
|
+
if v.HasImpact {
|
|
59
|
+
mark, cmp := " ", "<"
|
|
60
|
+
if v.Impact >= t.Impact && v.ImpactConfidence >= c.Gate.MinConfidence {
|
|
61
|
+
mark, cmp = "⚠ ", "≥"
|
|
62
|
+
}
|
|
63
|
+
lines = append(lines, fmt.Sprintf(" %s%-13s %s %s/3 %s %s (confidence %s)", mark, "impact", bar(v.Impact/3), toFixed2(v.Impact), cmp, toFixed2(t.Impact), toFixed2(v.ImpactConfidence)))
|
|
64
|
+
}
|
|
65
|
+
return strings.Join(lines, "\n")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// preview names what a tool call touches, for the confirmation dialog.
|
|
69
|
+
func preview(tool string, input any) string {
|
|
70
|
+
m, _ := input.(map[string]any)
|
|
71
|
+
for _, k := range []string{"command", "path", "file_path", "filePath"} {
|
|
72
|
+
if s, ok := m[k].(string); ok && s != "" {
|
|
73
|
+
return cut(collapse(s), 120)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return ""
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func (x *ext) shadowNote(tool string, v gateVerdict, c config) string {
|
|
80
|
+
if c.Display == "plain" {
|
|
81
|
+
return fmt.Sprintf("jev shadow: %s - %s", tool, v.summary())
|
|
82
|
+
}
|
|
83
|
+
return fmt.Sprintf("⚠ Jev flagged %s (shadow mode: reported, not blocked)\n%s", tool, card(v, c))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (x *ext) headlessNote(tool string, v gateVerdict, c config) string {
|
|
87
|
+
tail := "(headless: not blocking; set gate.blockWithoutUI to block)"
|
|
88
|
+
if c.Display == "plain" {
|
|
89
|
+
return fmt.Sprintf("jev: %s - %s %s", tool, v.summary(), tail)
|
|
90
|
+
}
|
|
91
|
+
return fmt.Sprintf("⚠ Jev flagged %s: %s %s", tool, v.summary(), tail)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func (x *ext) confirmMessage(tool string, input any, v gateVerdict, c config, dest string) string {
|
|
95
|
+
if c.Display == "plain" {
|
|
96
|
+
return fmt.Sprintf("%s\n%s\n\nRun it anyway?", tool, v.summary())
|
|
97
|
+
}
|
|
98
|
+
head := tool
|
|
99
|
+
if p := preview(tool, input); p != "" {
|
|
100
|
+
head += " " + p
|
|
101
|
+
}
|
|
102
|
+
return fmt.Sprintf("%s\n\n%s\n\nJudged by %s\nRun it anyway?", head, card(v, c), dest)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func (x *ext) leakNote(tool string, leak float64, c config) string {
|
|
106
|
+
if c.Display == "plain" {
|
|
107
|
+
return fmt.Sprintf("jev: %s output may carry a secret (%s)", tool, toFixed2(leak))
|
|
108
|
+
}
|
|
109
|
+
return fmt.Sprintf("⚠ Jev: %s output may carry a secret (%s). The model was told not to repeat it.", tool, toFixed2(leak))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func (x *ext) failureNote(msg string, c config) string {
|
|
113
|
+
if c.Display == "plain" {
|
|
114
|
+
return fmt.Sprintf("pi-jev: %s (failing open)", msg)
|
|
115
|
+
}
|
|
116
|
+
return fmt.Sprintf("✗ Jev could not judge: %s\nThe tool call ran unjudged (failing open).", msg)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// disclosure says what leaves the machine and where it goes. It is shown until the
|
|
120
|
+
// user sets "acknowledged", and by /jev on before consent.
|
|
121
|
+
func disclosure(c config, dest string) string {
|
|
122
|
+
var b strings.Builder
|
|
123
|
+
b.WriteString("What leaves this machine for each judgment:\n")
|
|
124
|
+
b.WriteString(" • the working directory and the tool name\n")
|
|
125
|
+
fmt.Fprintf(&b, " • your last message (first %d characters)\n", userRequestChars)
|
|
126
|
+
fmt.Fprintf(&b, " • the tool arguments (%s; long fields are cut at %d characters, and write/edit arguments are file content)\n", strings.Join(c.Gate.Tools, "/"), c.Gate.ArgumentChars)
|
|
127
|
+
fmt.Fprintf(&b, " • %s output (first %d characters)\n", strings.Join(c.Output.Tools, "/"), c.Output.OutputChars)
|
|
128
|
+
fmt.Fprintf(&b, "Also sent: text the model passes to jev_ask (up to %d characters) and text you pass to /jev check.\n", c.MaxStateChars)
|
|
129
|
+
fmt.Fprintf(&b, "Destination: %s\n", dest)
|
|
130
|
+
b.WriteString("If the judge is unavailable, tool calls run unjudged (it fails open). A judgment is probabilistic advice, not a sandbox.")
|
|
131
|
+
return b.String()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func startupDisclosure(c config, dest string) string {
|
|
135
|
+
return fmt.Sprintf("Jev is ON (%s mode). %s\nSet \"acknowledged\": true in pi-jev.json to hide this notice.", c.Gate.Mode, disclosure(c, dest))
|
|
136
|
+
}
|