@pi-in-go/pigpen-jev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CREDITS.md +22 -0
  2. package/LICENSE +22 -0
  3. package/README.md +237 -0
  4. package/extensions/jev/ask.go +166 -0
  5. package/extensions/jev/ask_test.go +218 -0
  6. package/extensions/jev/backend.go +128 -0
  7. package/extensions/jev/bench_test.go +64 -0
  8. package/extensions/jev/boundaries_test.go +159 -0
  9. package/extensions/jev/command.go +224 -0
  10. package/extensions/jev/commands_test.go +214 -0
  11. package/extensions/jev/config.go +450 -0
  12. package/extensions/jev/errors_test.go +191 -0
  13. package/extensions/jev/extension.go +391 -0
  14. package/extensions/jev/fakehost_test.go +548 -0
  15. package/extensions/jev/gate.go +125 -0
  16. package/extensions/jev/gate_test.go +610 -0
  17. package/extensions/jev/gatekey_test.go +24 -0
  18. package/extensions/jev/go.mod +9 -0
  19. package/extensions/jev/go.sum +2 -0
  20. package/extensions/jev/go.work +10 -0
  21. package/extensions/jev/helpers_test.go +404 -0
  22. package/extensions/jev/memo.go +88 -0
  23. package/extensions/jev/output.go +89 -0
  24. package/extensions/jev/output_test.go +187 -0
  25. package/extensions/jev/ownmodel_test.go +118 -0
  26. package/extensions/jev/render.go +136 -0
  27. package/extensions/jev/review_test.go +310 -0
  28. package/extensions/jev/source_test.go +57 -0
  29. package/extensions/jev/text.go +174 -0
  30. package/extensions/jev/trust_test.go +335 -0
  31. package/extensions/jev/types.go +227 -0
  32. package/libs/typesafe/CONTRACT.md +125 -0
  33. package/libs/typesafe/CREDITS.md +37 -0
  34. package/libs/typesafe/LICENSE +23 -0
  35. package/libs/typesafe/README.md +19 -0
  36. package/libs/typesafe/go.mod +3 -0
  37. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  38. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  39. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  40. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  41. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  42. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  43. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  44. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  45. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  46. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  47. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  48. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  49. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  50. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  51. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  52. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  53. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  54. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  55. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  56. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  57. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  58. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  59. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  60. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  61. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  62. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  63. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  64. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  65. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  66. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  67. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  68. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  69. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  70. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  71. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  72. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  73. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  74. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  75. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  76. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  77. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  78. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  79. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  80. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  81. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  82. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  83. package/libs/typesafe/package.json +37 -0
  84. package/libs/typesafe/provenance.json +49 -0
  85. package/package.json +42 -0
  86. package/port/PORT.md +107 -0
  87. package/port/e2e/gate-and-output.py +35 -0
  88. package/port/e2e/jev-ask.py +36 -0
  89. package/port/e2e/model-switch.py +44 -0
  90. package/port/e2e/off-by-default.py +34 -0
  91. package/port/gen-scenarios.py +103 -0
  92. package/port/golden/cache-identical-calls.jsonl +30 -0
  93. package/port/golden/clear.jsonl +22 -0
  94. package/port/golden/commands.jsonl +43 -0
  95. package/port/golden/enforce-accept.jsonl +23 -0
  96. package/port/golden/enforce-decline.jsonl +22 -0
  97. package/port/golden/jev-ask.jsonl +20 -0
  98. package/port/golden/output-advice.jsonl +23 -0
  99. package/port/golden/output-leak.jsonl +24 -0
  100. package/port/golden/output-low-confidence.jsonl +22 -0
  101. package/port/golden/shadow-flagged.jsonl +23 -0
  102. package/port/golden/unjudged-tools.jsonl +19 -0
  103. package/port/golden/write-elision.jsonl +21 -0
  104. package/port/mutate-unit.py +63 -0
  105. package/port/mutations.json +578 -0
  106. package/port/oracle/LICENSE +21 -0
  107. package/port/oracle/README.md +181 -0
  108. package/port/oracle/SHA256SUMS +8 -0
  109. package/port/oracle/package.json +43 -0
  110. package/port/oracle/src/client.ts +409 -0
  111. package/port/oracle/src/config.ts +363 -0
  112. package/port/oracle/src/gate.ts +229 -0
  113. package/port/oracle/src/index.ts +649 -0
  114. package/port/oracle/src/output.ts +163 -0
  115. package/port/red-run.log +309 -0
  116. package/port/scenarios/cache-identical-calls.json +71 -0
  117. package/port/scenarios/clear.json +61 -0
  118. package/port/scenarios/commands.json +119 -0
  119. package/port/scenarios/enforce-accept.json +66 -0
  120. package/port/scenarios/enforce-decline.json +57 -0
  121. package/port/scenarios/jev-ask.json +83 -0
  122. package/port/scenarios/output-advice.json +61 -0
  123. package/port/scenarios/output-leak.json +61 -0
  124. package/port/scenarios/output-low-confidence.json +61 -0
  125. package/port/scenarios/shadow-flagged.json +61 -0
  126. package/port/scenarios/unjudged-tools.json +55 -0
  127. package/port/scenarios/write-elision.json +53 -0
  128. package/provenance.json +18 -0
@@ -0,0 +1,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
+ }