@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,214 @@
|
|
|
1
|
+
package jev_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
jev "github.com/MichaelKinsy/pigpen/jev"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// The /jev command (src/index.ts registerCommand).
|
|
10
|
+
|
|
11
|
+
func cmdHost(t *testing.T, body any, extra map[string]any) (*Host, *fakeJev, *hostState) {
|
|
12
|
+
t.Helper()
|
|
13
|
+
e := newEnv(t)
|
|
14
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
15
|
+
srv := newFakeJev(t, always(body))
|
|
16
|
+
e.writeGlobal(t, jevConfig(srv, extra))
|
|
17
|
+
hs := newHostState()
|
|
18
|
+
return start(t, e, hs, HostOptions{}), srv, hs
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func lastNote(h *Host) string {
|
|
22
|
+
n := h.notifications()
|
|
23
|
+
if len(n) == 0 {
|
|
24
|
+
return "<none>"
|
|
25
|
+
}
|
|
26
|
+
return n[len(n)-1]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func TestCommand_OffAndOnToggleBothJudges(t *testing.T) {
|
|
30
|
+
h, srv, hs := cmdHost(t, clearGate(), nil)
|
|
31
|
+
hs.confirm = true
|
|
32
|
+
h.Command("jev", "off")
|
|
33
|
+
if got := lastNote(h); got != "info: pi-jev: gate off" || h.lastStatus() != "jev: off" {
|
|
34
|
+
t.Errorf("off: note %q status %q", got, h.lastStatus())
|
|
35
|
+
}
|
|
36
|
+
h.toolCall("bash", bash("x"))
|
|
37
|
+
h.toolResult("bash", bash("x"), text("y"), false)
|
|
38
|
+
if srv.count() != 0 {
|
|
39
|
+
t.Fatal("judged while off")
|
|
40
|
+
}
|
|
41
|
+
h.Command("jev", "on")
|
|
42
|
+
if got := lastNote(h); got != "info: pi-jev: gate on" || h.lastStatus() != "jev: shadow" {
|
|
43
|
+
t.Errorf("on: note %q status %q", got, h.lastStatus())
|
|
44
|
+
}
|
|
45
|
+
h.toolCall("bash", bash("x"))
|
|
46
|
+
if srv.count() != 1 {
|
|
47
|
+
t.Fatalf("requests = %d", srv.count())
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestCommand_ModeSwitch(t *testing.T) {
|
|
52
|
+
h, _, _ := cmdHost(t, gateBody(0.99, 0, 0, 0, 0.9), nil)
|
|
53
|
+
h.Command("jev", "mode enforce")
|
|
54
|
+
if got := lastNote(h); got != "info: pi-jev: enforce (asks before running flagged calls)" {
|
|
55
|
+
t.Errorf("note = %q", got)
|
|
56
|
+
}
|
|
57
|
+
if block, _ := h.toolCall("bash", bash("x")); !block {
|
|
58
|
+
t.Error("enforce did not ask/decline")
|
|
59
|
+
}
|
|
60
|
+
h.Command("jev", "mode shadow")
|
|
61
|
+
if got := lastNote(h); got != "info: pi-jev: shadow (reports, never blocks)" {
|
|
62
|
+
t.Errorf("note = %q", got)
|
|
63
|
+
}
|
|
64
|
+
h.Command("jev", "mode bogus")
|
|
65
|
+
if got := lastNote(h); got != "warning: pi-jev: mode is shadow (usage: /jev mode shadow|enforce)" {
|
|
66
|
+
t.Errorf("note = %q", got)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestCommand_LastVerdict(t *testing.T) {
|
|
71
|
+
h, _, _ := cmdHost(t, flaggedGate(), nil)
|
|
72
|
+
h.Command("jev", "last")
|
|
73
|
+
if got := lastNote(h); got != "info: pi-jev: no verdicts yet" {
|
|
74
|
+
t.Errorf("note = %q", got)
|
|
75
|
+
}
|
|
76
|
+
h.toolCall("bash", bash("rm -rf src"))
|
|
77
|
+
h.Command("jev", "last")
|
|
78
|
+
mustContain(t, "last", lastNote(h), "pi-jev: bash - destructive 0.99",
|
|
79
|
+
"destructive=yes 0.99", "exfiltration=yes 0.79", "beyond_scope=yes 0.98", "impact=3.00/3 (conf 0.91)")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestCommand_OutputVerdict(t *testing.T) {
|
|
83
|
+
h, _, _ := cmdHost(t, outBody(0.94, "transient", 0.8), nil)
|
|
84
|
+
h.Command("jev", "output")
|
|
85
|
+
if got := lastNote(h); got != "info: pi-jev: no tool output judged yet" {
|
|
86
|
+
t.Errorf("note = %q", got)
|
|
87
|
+
}
|
|
88
|
+
h.toolResult("bash", bash("x"), text("out"), false)
|
|
89
|
+
h.Command("jev", "output")
|
|
90
|
+
if got := lastNote(h); got != "info: pi-jev output: bash - leak | leak 0.94 | class transient at 0.80" {
|
|
91
|
+
t.Errorf("note = %q", got)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// C9: the original recorded only verdicts that carried a notice, so a clean
|
|
96
|
+
// output was reported as "no tool output judged yet".
|
|
97
|
+
func TestCorrection_CleanOutputIsRecordedToo(t *testing.T) {
|
|
98
|
+
h, _, _ := cmdHost(t, outBody(0.01, "no_failure", 0.99), nil)
|
|
99
|
+
h.toolResult("bash", bash("x"), text("fine"), false)
|
|
100
|
+
h.Command("jev", "output")
|
|
101
|
+
if got := lastNote(h); got != "info: pi-jev output: bash - none | leak 0.01 | class no_failure at 0.99" {
|
|
102
|
+
t.Errorf("note = %q", got)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func TestCommand_CheckRunsTheGateQuestionsOnSuppliedText(t *testing.T) {
|
|
107
|
+
h, srv, _ := cmdHost(t, gateBody(0.99, 0.1, 0.1, 1.0, 0.9), nil)
|
|
108
|
+
h.Command("jev", "check Rm -RF /tmp/Build")
|
|
109
|
+
reqs := srv.requests()
|
|
110
|
+
if len(reqs) != 1 || reqs[0].Body["state"] != "Rm -RF /tmp/Build" {
|
|
111
|
+
t.Fatalf("requests = %+v", reqs)
|
|
112
|
+
}
|
|
113
|
+
mustContain(t, "check", lastNote(h), "pi-jev check: destructive 0.99 | destructive=yes 0.99")
|
|
114
|
+
h.Command("jev", "last")
|
|
115
|
+
mustContain(t, "last after check", lastNote(h), "pi-jev: check - destructive 0.99")
|
|
116
|
+
h.Command("jev", "check")
|
|
117
|
+
if got := lastNote(h); got != "warning: pi-jev: usage /jev check <text>" {
|
|
118
|
+
t.Errorf("note = %q", got)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func TestCommand_CheckErrorIsReportedAsError(t *testing.T) {
|
|
123
|
+
e := newEnv(t)
|
|
124
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
125
|
+
srv := newFakeJev(t, func(int, recordedRequest) jevReply { return jevReply{status: 422, body: "bad"} })
|
|
126
|
+
e.writeGlobal(t, jevConfig(srv, nil))
|
|
127
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
128
|
+
h.Command("jev", "check hello")
|
|
129
|
+
mustContain(t, "check error", lastNote(h), "error: pi-jev: ", "422")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func TestCommand_StatusShowsPolicyAndDestination(t *testing.T) {
|
|
133
|
+
h, srv, _ := cmdHost(t, clearGate(), map[string]any{"display": "rich"})
|
|
134
|
+
h.Command("jev", "")
|
|
135
|
+
note := lastNote(h)
|
|
136
|
+
mustContain(t, "status", note, "shadow", "jev-test", srv.srv.URL, "fails open", "bash", "TYPESAFE_API_KEY")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestCommand_StatusIsTruthfulAboutTheKeySource(t *testing.T) {
|
|
140
|
+
h, _, _ := cmdHost(t, clearGate(), map[string]any{"display": "rich"})
|
|
141
|
+
h.Command("jev", "")
|
|
142
|
+
if strings.Contains(lastNote(h), "inline") {
|
|
143
|
+
t.Errorf("an environment key reported as inline: %q", lastNote(h))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func TestCommand_StatusWhenOffExplainsOptIn(t *testing.T) {
|
|
148
|
+
e := newEnv(t)
|
|
149
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
150
|
+
h.Command("jev", "")
|
|
151
|
+
mustContain(t, "status", lastNote(h), "off", "/jev on", "leave this machine")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Opt-in at run time: /jev on asks first and says what leaves the machine.
|
|
155
|
+
func TestOptIn_SlashOnAsksForConsentWithDisclosure(t *testing.T) {
|
|
156
|
+
e := newEnv(t)
|
|
157
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
158
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
159
|
+
cfg := jevConfig(srv, map[string]any{"display": "rich"})
|
|
160
|
+
delete(cfg, "enabled")
|
|
161
|
+
e.writeGlobal(t, cfg)
|
|
162
|
+
hs := newHostState()
|
|
163
|
+
h := start(t, e, hs, HostOptions{})
|
|
164
|
+
h.Command("jev", "on") // declined: hs.confirm is false
|
|
165
|
+
c := hs.confirmCalls()
|
|
166
|
+
if len(c) != 1 {
|
|
167
|
+
t.Fatalf("confirm calls = %d", len(c))
|
|
168
|
+
}
|
|
169
|
+
mustContain(t, "confirm message", c[0]["message"].(string), "working directory", "tool arguments", srv.srv.URL)
|
|
170
|
+
h.toolCall("bash", bash("x"))
|
|
171
|
+
if srv.count() != 0 {
|
|
172
|
+
t.Fatal("declined consent still judged")
|
|
173
|
+
}
|
|
174
|
+
hs.mu.Lock()
|
|
175
|
+
hs.confirm = true
|
|
176
|
+
hs.mu.Unlock()
|
|
177
|
+
h.Command("jev", "on")
|
|
178
|
+
h.toolCall("bash", bash("x"))
|
|
179
|
+
if srv.count() != 1 {
|
|
180
|
+
t.Fatalf("requests after consent = %d", srv.count())
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func TestOptIn_ConsentIsPerSessionAndNeverWritesConfig(t *testing.T) {
|
|
185
|
+
e := newEnv(t)
|
|
186
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
187
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
188
|
+
cfg := jevConfig(srv, nil)
|
|
189
|
+
delete(cfg, "enabled")
|
|
190
|
+
e.writeGlobal(t, cfg)
|
|
191
|
+
hs := newHostState()
|
|
192
|
+
hs.confirm = true
|
|
193
|
+
h := start(t, e, hs, HostOptions{})
|
|
194
|
+
h.Command("jev", "on")
|
|
195
|
+
h2 := StartHost(t, jev.Extension(), HostOptions{Cwd: e.cwd, OnCall: hs.onCall})
|
|
196
|
+
h2.Fire("session_start", map[string]any{"reason": "startup"})
|
|
197
|
+
h2.toolCall("bash", bash("x"))
|
|
198
|
+
if srv.count() != 0 {
|
|
199
|
+
t.Error("consent leaked into another session")
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func TestCommand_PlainStatusNamesTheKeySource(t *testing.T) {
|
|
204
|
+
h, _, _ := cmdHost(t, clearGate(), nil)
|
|
205
|
+
h.Command("jev", "")
|
|
206
|
+
mustContain(t, "status", lastNote(h), "key env TYPESAFE_API_KEY", "model jev-test", "judging bash/write/edit", "out tools bash")
|
|
207
|
+
|
|
208
|
+
e := newEnv(t)
|
|
209
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
210
|
+
e.writeGlobal(t, jevConfig(srv, nil))
|
|
211
|
+
h2 := start(t, e, newHostState(), HostOptions{})
|
|
212
|
+
h2.Command("jev", "")
|
|
213
|
+
mustContain(t, "status without a key", lastNote(h2), "key missing (TYPESAFE_API_KEY)")
|
|
214
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
package jev
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"math"
|
|
8
|
+
"net"
|
|
9
|
+
"net/url"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"slices"
|
|
13
|
+
"strconv"
|
|
14
|
+
"strings"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const (
|
|
18
|
+
configFile = "pi-jev.json"
|
|
19
|
+
apiKeyEnv = "TYPESAFE_API_KEY"
|
|
20
|
+
|
|
21
|
+
backendModel = "model"
|
|
22
|
+
backendTypeSafe = "typesafe"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
type gateThresholds struct {
|
|
26
|
+
Destructive, Exfiltration, BeyondScope, Impact float64
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type gateConfig struct {
|
|
30
|
+
Enabled bool
|
|
31
|
+
Mode string // shadow or enforce
|
|
32
|
+
Tools []string
|
|
33
|
+
ArgumentChars int
|
|
34
|
+
CacheSeconds int
|
|
35
|
+
BlockOn gateThresholds
|
|
36
|
+
MinConfidence float64
|
|
37
|
+
BlockWithoutUI bool
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type outputConfig struct {
|
|
41
|
+
Enabled bool
|
|
42
|
+
Tools []string
|
|
43
|
+
OutputChars int
|
|
44
|
+
LeakThreshold float64
|
|
45
|
+
MinConfidence float64
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type config struct {
|
|
49
|
+
// Consent and destination. Only the user's own config can set these.
|
|
50
|
+
Enabled bool
|
|
51
|
+
Acknowledged bool
|
|
52
|
+
Backend string
|
|
53
|
+
Endpoint string
|
|
54
|
+
Model string
|
|
55
|
+
APIKey string
|
|
56
|
+
APIKeyFile string
|
|
57
|
+
Display string // rich or plain
|
|
58
|
+
TimeoutMs int
|
|
59
|
+
Retries int
|
|
60
|
+
|
|
61
|
+
MaxStateChars int
|
|
62
|
+
Gate gateConfig
|
|
63
|
+
Output outputConfig
|
|
64
|
+
|
|
65
|
+
// KeySource says where the key came from, for /jev.
|
|
66
|
+
KeySource string
|
|
67
|
+
Key string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func defaultConfig() config {
|
|
71
|
+
return config{
|
|
72
|
+
Backend: backendModel,
|
|
73
|
+
Display: "rich",
|
|
74
|
+
TimeoutMs: 20000,
|
|
75
|
+
Retries: 2,
|
|
76
|
+
MaxStateChars: 8000,
|
|
77
|
+
Gate: gateConfig{
|
|
78
|
+
Enabled: true, Mode: "shadow", Tools: []string{"bash", "write", "edit"},
|
|
79
|
+
ArgumentChars: 400, CacheSeconds: 120, MinConfidence: 0.5,
|
|
80
|
+
BlockOn: gateThresholds{Destructive: 0.9, Exfiltration: 0.7, BeyondScope: 0.85, Impact: 2.5},
|
|
81
|
+
},
|
|
82
|
+
Output: outputConfig{Enabled: true, Tools: []string{"bash"}, OutputChars: 2000, LeakThreshold: 0.9, MinConfidence: 0.6},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// parsed is a config file with only the keys it declared.
|
|
87
|
+
type parsed struct {
|
|
88
|
+
m map[string]any
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (p parsed) has(k string) bool { _, ok := p.m[k]; return ok }
|
|
92
|
+
|
|
93
|
+
func asString(v any) (string, bool) {
|
|
94
|
+
s, ok := v.(string)
|
|
95
|
+
s = strings.TrimSpace(s)
|
|
96
|
+
return s, ok && s != ""
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func isInt(v any) (int, bool) {
|
|
100
|
+
f, ok := v.(float64)
|
|
101
|
+
if !ok || f != math.Trunc(f) || math.IsInf(f, 0) || f > 1<<53 {
|
|
102
|
+
return 0, false
|
|
103
|
+
}
|
|
104
|
+
return int(f), true
|
|
105
|
+
}
|
|
106
|
+
func asPositiveInt(v any) (int, bool) { n, ok := isInt(v); return n, ok && n > 0 }
|
|
107
|
+
func asNonNegativeInt(v any) (int, bool) { n, ok := isInt(v); return n, ok && n >= 0 }
|
|
108
|
+
func asRatio(v any) (float64, bool) {
|
|
109
|
+
f, ok := v.(float64)
|
|
110
|
+
return f, ok && !math.IsNaN(f) && f >= 0 && f <= 1
|
|
111
|
+
}
|
|
112
|
+
func asToolNames(v any) ([]string, bool) {
|
|
113
|
+
arr, ok := v.([]any)
|
|
114
|
+
if !ok {
|
|
115
|
+
return nil, false
|
|
116
|
+
}
|
|
117
|
+
var out []string
|
|
118
|
+
for _, item := range arr {
|
|
119
|
+
if s, ok := item.(string); ok && s != "" {
|
|
120
|
+
out = append(out, s)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out, len(out) > 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// agentDir is PiG's agent directory: PIG_CODING_AGENT_DIR, else <config home>/agent.
|
|
127
|
+
func agentDir(configHome string) string {
|
|
128
|
+
if d := os.Getenv("PIG_CODING_AGENT_DIR"); d != "" {
|
|
129
|
+
return d
|
|
130
|
+
}
|
|
131
|
+
return filepath.Join(configHome, "agent")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func readConfigFile(path string, warnings *[]string) (map[string]any, bool) {
|
|
135
|
+
raw, err := os.ReadFile(path)
|
|
136
|
+
if err != nil {
|
|
137
|
+
// A missing file is the normal case; anything else is worth reporting.
|
|
138
|
+
if !errors.Is(err, os.ErrNotExist) {
|
|
139
|
+
*warnings = append(*warnings, fmt.Sprintf("%s: %v", path, err))
|
|
140
|
+
}
|
|
141
|
+
return nil, false
|
|
142
|
+
}
|
|
143
|
+
var v any
|
|
144
|
+
if err := json.Unmarshal(raw, &v); err != nil {
|
|
145
|
+
*warnings = append(*warnings, fmt.Sprintf("%s: invalid JSON (%v)", path, err))
|
|
146
|
+
return nil, false
|
|
147
|
+
}
|
|
148
|
+
obj, ok := v.(map[string]any)
|
|
149
|
+
if !ok {
|
|
150
|
+
*warnings = append(*warnings, fmt.Sprintf("%s: expected a JSON object", path))
|
|
151
|
+
return nil, false
|
|
152
|
+
}
|
|
153
|
+
return obj, true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// applyFile lays the keys a file declares over c. project files are held to the
|
|
157
|
+
// trust rules: they cannot consent, redirect, or send more than the user allowed.
|
|
158
|
+
func applyFile(c *config, m map[string]any, project bool) (ignored []string) {
|
|
159
|
+
key := func(k string) bool { // returns true when the key may be applied
|
|
160
|
+
if _, ok := m[k]; !ok {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
if project {
|
|
164
|
+
ignored = append(ignored, k)
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
if key("enabled") {
|
|
170
|
+
if b, ok := m["enabled"].(bool); ok {
|
|
171
|
+
c.Enabled = b
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if key("acknowledged") {
|
|
175
|
+
if b, ok := m["acknowledged"].(bool); ok {
|
|
176
|
+
c.Acknowledged = b
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if key("backend") {
|
|
180
|
+
if s, ok := asString(m["backend"]); ok && (s == backendModel || s == backendTypeSafe) {
|
|
181
|
+
c.Backend = s
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if key("display") {
|
|
185
|
+
if s, ok := asString(m["display"]); ok && (s == "rich" || s == "plain") {
|
|
186
|
+
c.Display = s
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if key("endpoint") {
|
|
190
|
+
if s, ok := asString(m["endpoint"]); ok {
|
|
191
|
+
c.Endpoint = s
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if key("model") {
|
|
195
|
+
if s, ok := asString(m["model"]); ok {
|
|
196
|
+
c.Model = s
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if key("apiKey") {
|
|
200
|
+
if s, ok := asString(m["apiKey"]); ok {
|
|
201
|
+
c.APIKey = s
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if key("apiKeyFile") {
|
|
205
|
+
if s, ok := asString(m["apiKeyFile"]); ok {
|
|
206
|
+
c.APIKeyFile = s
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if key("timeoutMs") {
|
|
210
|
+
if n, ok := asPositiveInt(m["timeoutMs"]); ok {
|
|
211
|
+
c.TimeoutMs = n
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if key("retries") {
|
|
215
|
+
if n, ok := asNonNegativeInt(m["retries"]); ok {
|
|
216
|
+
c.Retries = n
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if v, ok := m["maxStateChars"]; ok {
|
|
220
|
+
if n, ok := asPositiveInt(v); ok && (!project || n < c.MaxStateChars) {
|
|
221
|
+
c.MaxStateChars = n
|
|
222
|
+
} else if ok {
|
|
223
|
+
ignored = append(ignored, "maxStateChars (a project can only lower it)")
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if g, ok := m["gate"].(map[string]any); ok {
|
|
227
|
+
if b, ok := g["enabled"].(bool); ok {
|
|
228
|
+
c.Gate.Enabled = b
|
|
229
|
+
}
|
|
230
|
+
if s, ok := g["mode"].(string); ok && (s == "shadow" || s == "enforce") {
|
|
231
|
+
c.Gate.Mode = s
|
|
232
|
+
}
|
|
233
|
+
if names, ok := asToolNames(g["tools"]); ok {
|
|
234
|
+
c.Gate.Tools = applyTools(c.Gate.Tools, names, project, "gate.tools", &ignored)
|
|
235
|
+
}
|
|
236
|
+
if n, ok := asNonNegativeInt(g["cacheSeconds"]); ok {
|
|
237
|
+
c.Gate.CacheSeconds = n
|
|
238
|
+
}
|
|
239
|
+
if n, ok := asPositiveInt(g["argumentChars"]); ok && (!project || n < c.Gate.ArgumentChars) {
|
|
240
|
+
c.Gate.ArgumentChars = n
|
|
241
|
+
} else if ok && project {
|
|
242
|
+
ignored = append(ignored, "gate.argumentChars (a project can only lower it)")
|
|
243
|
+
}
|
|
244
|
+
if b, ok := g["blockWithoutUI"].(bool); ok {
|
|
245
|
+
c.Gate.BlockWithoutUI = b
|
|
246
|
+
}
|
|
247
|
+
if f, ok := asRatio(g["minConfidence"]); ok {
|
|
248
|
+
c.Gate.MinConfidence = f
|
|
249
|
+
}
|
|
250
|
+
if bo, ok := g["blockOn"].(map[string]any); ok {
|
|
251
|
+
if f, ok := asRatio(bo["destructive"]); ok {
|
|
252
|
+
c.Gate.BlockOn.Destructive = f
|
|
253
|
+
}
|
|
254
|
+
if f, ok := asRatio(bo["exfiltration"]); ok {
|
|
255
|
+
c.Gate.BlockOn.Exfiltration = f
|
|
256
|
+
}
|
|
257
|
+
if f, ok := asRatio(bo["beyondScope"]); ok {
|
|
258
|
+
c.Gate.BlockOn.BeyondScope = f
|
|
259
|
+
}
|
|
260
|
+
if f, ok := bo["impact"].(float64); ok && !math.IsNaN(f) && !math.IsInf(f, 0) && f >= 0 {
|
|
261
|
+
c.Gate.BlockOn.Impact = f
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if o, ok := m["output"].(map[string]any); ok {
|
|
266
|
+
if b, ok := o["enabled"].(bool); ok {
|
|
267
|
+
c.Output.Enabled = b
|
|
268
|
+
}
|
|
269
|
+
if names, ok := asToolNames(o["tools"]); ok {
|
|
270
|
+
c.Output.Tools = applyTools(c.Output.Tools, names, project, "output.tools", &ignored)
|
|
271
|
+
}
|
|
272
|
+
if n, ok := asPositiveInt(o["outputChars"]); ok && (!project || n < c.Output.OutputChars) {
|
|
273
|
+
c.Output.OutputChars = n
|
|
274
|
+
} else if ok && project {
|
|
275
|
+
ignored = append(ignored, "output.outputChars (a project can only lower it)")
|
|
276
|
+
}
|
|
277
|
+
if f, ok := asRatio(o["leakThreshold"]); ok {
|
|
278
|
+
c.Output.LeakThreshold = f
|
|
279
|
+
}
|
|
280
|
+
if f, ok := asRatio(o["minConfidence"]); ok {
|
|
281
|
+
c.Output.MinConfidence = f
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return ignored
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// relaxations lists the project settings that weaken the protection the user's own
|
|
288
|
+
// config set up: a gate or output judge turned off, enforce turned into shadow, a
|
|
289
|
+
// higher threshold, fewer judged tools. They are applied (a project may send less),
|
|
290
|
+
// but never silently: a repository can otherwise switch off the gate a user relies on.
|
|
291
|
+
func relaxations(user, merged config) []string {
|
|
292
|
+
var out []string
|
|
293
|
+
num := func(f float64) string { return strconv.FormatFloat(f, 'g', -1, 64) }
|
|
294
|
+
if user.Gate.Enabled && !merged.Gate.Enabled {
|
|
295
|
+
out = append(out, "gate.enabled false")
|
|
296
|
+
}
|
|
297
|
+
if user.Gate.Mode == "enforce" && merged.Gate.Mode != "enforce" {
|
|
298
|
+
out = append(out, "gate.mode "+merged.Gate.Mode)
|
|
299
|
+
}
|
|
300
|
+
if user.Gate.BlockWithoutUI && !merged.Gate.BlockWithoutUI {
|
|
301
|
+
out = append(out, "gate.blockWithoutUI false")
|
|
302
|
+
}
|
|
303
|
+
for _, t := range []struct {
|
|
304
|
+
name string
|
|
305
|
+
was, is float64
|
|
306
|
+
}{
|
|
307
|
+
{"gate.blockOn.destructive", user.Gate.BlockOn.Destructive, merged.Gate.BlockOn.Destructive},
|
|
308
|
+
{"gate.blockOn.exfiltration", user.Gate.BlockOn.Exfiltration, merged.Gate.BlockOn.Exfiltration},
|
|
309
|
+
{"gate.blockOn.beyondScope", user.Gate.BlockOn.BeyondScope, merged.Gate.BlockOn.BeyondScope},
|
|
310
|
+
{"gate.blockOn.impact", user.Gate.BlockOn.Impact, merged.Gate.BlockOn.Impact},
|
|
311
|
+
{"gate.minConfidence", user.Gate.MinConfidence, merged.Gate.MinConfidence},
|
|
312
|
+
} {
|
|
313
|
+
if t.is > t.was {
|
|
314
|
+
out = append(out, t.name+" "+num(t.is))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if dropped := droppedTools(user.Gate.Tools, merged.Gate.Tools); len(dropped) > 0 {
|
|
318
|
+
out = append(out, "gate.tools without "+strings.Join(dropped, "/"))
|
|
319
|
+
}
|
|
320
|
+
if user.Output.Enabled && !merged.Output.Enabled {
|
|
321
|
+
out = append(out, "output.enabled false")
|
|
322
|
+
}
|
|
323
|
+
if merged.Output.LeakThreshold > user.Output.LeakThreshold {
|
|
324
|
+
out = append(out, "output.leakThreshold "+num(merged.Output.LeakThreshold))
|
|
325
|
+
}
|
|
326
|
+
if dropped := droppedTools(user.Output.Tools, merged.Output.Tools); len(dropped) > 0 {
|
|
327
|
+
out = append(out, "output.tools without "+strings.Join(dropped, "/"))
|
|
328
|
+
}
|
|
329
|
+
return out
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
func droppedTools(was, is []string) []string {
|
|
333
|
+
var out []string
|
|
334
|
+
for _, t := range was {
|
|
335
|
+
if !slices.Contains(is, t) {
|
|
336
|
+
out = append(out, t)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// applyTools replaces the tool list; a project can only choose among tools the
|
|
343
|
+
// user's own config already judges.
|
|
344
|
+
func applyTools(cur, names []string, project bool, what string, ignored *[]string) []string {
|
|
345
|
+
if !project {
|
|
346
|
+
return names
|
|
347
|
+
}
|
|
348
|
+
var out []string
|
|
349
|
+
for _, n := range names {
|
|
350
|
+
if slices.Contains(cur, n) {
|
|
351
|
+
out = append(out, n)
|
|
352
|
+
} else {
|
|
353
|
+
*ignored = append(*ignored, what+" "+n)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return out
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
type loaded struct {
|
|
360
|
+
Config config
|
|
361
|
+
Warnings []string
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// loadConfig layers built-in defaults, <agent dir>/pi-jev.json and
|
|
365
|
+
// <cwd>/.pig/pi-jev.json. Project values win only where they narrow: they cannot
|
|
366
|
+
// opt in, name a backend, endpoint, model or key, or send more than the user
|
|
367
|
+
// configured. See PORT.md C2.
|
|
368
|
+
func loadConfig(configHome, cwd string) loaded {
|
|
369
|
+
c := defaultConfig()
|
|
370
|
+
var warnings []string
|
|
371
|
+
if g, ok := readConfigFile(filepath.Join(agentDir(configHome), configFile), &warnings); ok {
|
|
372
|
+
applyFile(&c, g, false)
|
|
373
|
+
}
|
|
374
|
+
projectPath := filepath.Join(cwd, ".pig", configFile)
|
|
375
|
+
if p, ok := readConfigFile(projectPath, &warnings); ok {
|
|
376
|
+
before := c
|
|
377
|
+
if ignored := applyFile(&c, p, true); len(ignored) > 0 {
|
|
378
|
+
warnings = append(warnings, fmt.Sprintf("%s: ignored project setting %s (only your own %s can set these)",
|
|
379
|
+
projectPath, strings.Join(ignored, ", "), filepath.Join(agentDir(configHome), configFile)))
|
|
380
|
+
}
|
|
381
|
+
if relaxed := relaxations(before, c); len(relaxed) > 0 {
|
|
382
|
+
warnings = append(warnings, fmt.Sprintf("%s relaxes the gate your own %s sets: %s",
|
|
383
|
+
projectPath, filepath.Join(agentDir(configHome), configFile), strings.Join(relaxed, ", ")))
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
c.Key, c.KeySource = resolveKey(c, &warnings)
|
|
387
|
+
return loaded{Config: c, Warnings: warnings}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// resolveKey: the environment wins over config, so a host can inject the key
|
|
391
|
+
// without touching files. apiKeyFile supports "~/".
|
|
392
|
+
func resolveKey(c config, warnings *[]string) (key, source string) {
|
|
393
|
+
if v := strings.TrimSpace(os.Getenv(apiKeyEnv)); v != "" {
|
|
394
|
+
return v, "env " + apiKeyEnv
|
|
395
|
+
}
|
|
396
|
+
if v := strings.TrimSpace(c.APIKey); v != "" {
|
|
397
|
+
return v, "apiKey in pi-jev.json"
|
|
398
|
+
}
|
|
399
|
+
if c.APIKeyFile == "" {
|
|
400
|
+
return "", ""
|
|
401
|
+
}
|
|
402
|
+
path := expandHome(c.APIKeyFile)
|
|
403
|
+
b, err := os.ReadFile(path)
|
|
404
|
+
if err != nil {
|
|
405
|
+
*warnings = append(*warnings, fmt.Sprintf("%s: %v", path, err))
|
|
406
|
+
return "", ""
|
|
407
|
+
}
|
|
408
|
+
if v := strings.TrimSpace(string(b)); v != "" {
|
|
409
|
+
return v, "apiKeyFile " + c.APIKeyFile
|
|
410
|
+
}
|
|
411
|
+
return "", ""
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
func expandHome(p string) string {
|
|
415
|
+
home, _ := os.UserHomeDir()
|
|
416
|
+
if p == "~" {
|
|
417
|
+
return home
|
|
418
|
+
}
|
|
419
|
+
if strings.HasPrefix(p, "~/") {
|
|
420
|
+
return filepath.Join(home, p[2:])
|
|
421
|
+
}
|
|
422
|
+
return p
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// loopback reports whether host is this machine.
|
|
426
|
+
func loopback(host string) bool {
|
|
427
|
+
if host == "localhost" {
|
|
428
|
+
return true
|
|
429
|
+
}
|
|
430
|
+
ip := net.ParseIP(host)
|
|
431
|
+
return ip != nil && ip.IsLoopback()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// checkEndpoint accepts https, or http to this machine only: the API key travels
|
|
435
|
+
// in the Authorization header.
|
|
436
|
+
func checkEndpoint(raw string) error {
|
|
437
|
+
u, err := url.Parse(raw)
|
|
438
|
+
if err != nil || u.Host == "" {
|
|
439
|
+
return fmt.Errorf("endpoint %q is not a URL", raw)
|
|
440
|
+
}
|
|
441
|
+
switch u.Scheme {
|
|
442
|
+
case "https":
|
|
443
|
+
return nil
|
|
444
|
+
case "http":
|
|
445
|
+
if loopback(u.Hostname()) {
|
|
446
|
+
return nil
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return fmt.Errorf("endpoint %q must use https (http is accepted only for localhost)", raw)
|
|
450
|
+
}
|