@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,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
|
+
}
|