Harden prompt debug redaction
This commit is contained in:
@@ -29,6 +29,10 @@ Profiles that require a direct API key are unsupported; a profile that reports `
|
|||||||
|
|
||||||
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. The package contains only reviewed prompt-facing warning summaries, never source transport or provenance details. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
|
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. The package contains only reviewed prompt-facing warning summaries, never source transport or provenance details. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
|
||||||
|
|
||||||
|
When capture is enabled, its preparation artifact projects a provider endpoint
|
||||||
|
to its scheme and host and retains only reviewed execution settings. Provider
|
||||||
|
extras and URL user information, paths, queries, and fragments are omitted.
|
||||||
|
|
||||||
## Comparison Execution
|
## Comparison Execution
|
||||||
|
|
||||||
For `compare`, Weatherreporter validates one exact prompt and every explicitly
|
For `compare`, Weatherreporter validates one exact prompt and every explicitly
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ remain distinct. Normal output, summaries, and routine logs omit that sensitive
|
|||||||
content. Debug capture is never created for an ordinary command without
|
content. Debug capture is never created for an ordinary command without
|
||||||
`--llm-debug-dir`.
|
`--llm-debug-dir`.
|
||||||
|
|
||||||
|
Preparation captures retain only the provider endpoint origin and reviewed
|
||||||
|
execution settings. URL user information, paths, queries, fragments, and
|
||||||
|
unrecognized provider parameters are omitted.
|
||||||
|
|
||||||
If capture creation or writing fails, the affected run fails rather than
|
If capture creation or writing fails, the affected run fails rather than
|
||||||
silently continuing without the requested diagnostics.
|
silently continuing without the requested diagnostics.
|
||||||
|
|
||||||
|
|||||||
@@ -253,7 +253,6 @@ func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
|||||||
TimeoutSeconds int `json:"timeout_seconds"`
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
ServiceTier string `json:"service_tier"`
|
ServiceTier string `json:"service_tier"`
|
||||||
ReasoningEffort string `json:"reasoning_effort"`
|
ReasoningEffort string `json:"reasoning_effort"`
|
||||||
ExtraParams map[string]any `json:"extra_params"`
|
|
||||||
}{
|
}{
|
||||||
Temperature: value.Temperature,
|
Temperature: value.Temperature,
|
||||||
MaxTokens: value.MaxTokens,
|
MaxTokens: value.MaxTokens,
|
||||||
@@ -261,7 +260,6 @@ func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
|||||||
TimeoutSeconds: value.TimeoutSeconds,
|
TimeoutSeconds: value.TimeoutSeconds,
|
||||||
ServiceTier: value.ServiceTier,
|
ServiceTier: value.ServiceTier,
|
||||||
ReasoningEffort: value.ReasoningEffort,
|
ReasoningEffort: value.ReasoningEffort,
|
||||||
ExtraParams: value.ExtraParams,
|
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(parameters)
|
data, _ := json.Marshal(parameters)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -316,6 +316,30 @@ func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarshalDebugParametersOmitsProviderExtras(t *testing.T) {
|
||||||
|
const marker = "private-debug-marker"
|
||||||
|
parameters := string(marshalDebugParameters(promptkit.ExecutionTarget{
|
||||||
|
Temperature: 0.2,
|
||||||
|
MaxTokens: 400,
|
||||||
|
TopP: 0.9,
|
||||||
|
TimeoutSeconds: 30,
|
||||||
|
ServiceTier: "flex",
|
||||||
|
ReasoningEffort: "high",
|
||||||
|
ExtraParams: map[string]any{
|
||||||
|
"access-key": marker,
|
||||||
|
"signature": marker,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if strings.Contains(parameters, marker) || strings.Contains(parameters, "extra_params") {
|
||||||
|
t.Fatalf("debug parameters leaked provider extras: %s", parameters)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"temperature":0.2`, `"max_tokens":400`, `"top_p":0.9`, `"timeout_seconds":30`, `"service_tier":"flex"`, `"reasoning_effort":"high"`} {
|
||||||
|
if !strings.Contains(parameters, want) {
|
||||||
|
t.Fatalf("debug parameters missing safe value %q: %s", want, parameters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
||||||
client := &fakeClient{response: validResponse()}
|
client := &fakeClient{response: validResponse()}
|
||||||
adapter := newTestAdapter(t, client)
|
adapter := newTestAdapter(t, client)
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func (w *PromptDebugWriter) WritePreparation(ref PromptDebugRef, preparation pro
|
|||||||
artifact.RenderedMessages = promptDebugMessages(debug.RenderedMessages)
|
artifact.RenderedMessages = promptDebugMessages(debug.RenderedMessages)
|
||||||
artifact.StructuredSchema = copyRawJSON(debug.StructuredSchema)
|
artifact.StructuredSchema = copyRawJSON(debug.StructuredSchema)
|
||||||
artifact.Endpoint = safePromptDebugEndpoint(debug.Endpoint)
|
artifact.Endpoint = safePromptDebugEndpoint(debug.Endpoint)
|
||||||
parameters, err := redactPromptDebugParameters(debug.ParametersJSON)
|
parameters, err := safePromptDebugParameters(debug.ParametersJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -404,55 +404,55 @@ func copyRawJSON(value []byte) json.RawMessage {
|
|||||||
|
|
||||||
func safePromptDebugEndpoint(value string) string {
|
func safePromptDebugEndpoint(value string) string {
|
||||||
parsed, err := url.Parse(value)
|
parsed, err := url.Parse(value)
|
||||||
if err != nil {
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
parsed.User = nil
|
return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String()
|
||||||
parameters := parsed.Query()
|
|
||||||
for key := range parameters {
|
|
||||||
if isPromptDebugSecretKey(key) {
|
|
||||||
parameters[key] = []string{"[redacted]"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parsed.RawQuery = parameters.Encode()
|
|
||||||
parsed.Fragment = ""
|
|
||||||
return parsed.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactPromptDebugParameters(value []byte) (json.RawMessage, error) {
|
func safePromptDebugParameters(value []byte) (json.RawMessage, error) {
|
||||||
if len(value) == 0 {
|
if len(value) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var decoded any
|
var decoded map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(value, &decoded); err != nil {
|
if err := json.Unmarshal(value, &decoded); err != nil {
|
||||||
return nil, fmt.Errorf("decode prompt debug parameters: %w", err)
|
return nil, fmt.Errorf("prompt debug parameters must be a JSON object")
|
||||||
}
|
}
|
||||||
redactPromptDebugValue(decoded)
|
parameters := make(map[string]json.RawMessage)
|
||||||
encoded, err := json.Marshal(decoded)
|
for _, field := range []struct {
|
||||||
|
name string
|
||||||
|
value func(json.RawMessage) bool
|
||||||
|
}{
|
||||||
|
{name: "temperature", value: promptDebugJSONNumber},
|
||||||
|
{name: "max_tokens", value: promptDebugJSONInteger},
|
||||||
|
{name: "top_p", value: promptDebugJSONNumber},
|
||||||
|
{name: "timeout_seconds", value: promptDebugJSONInteger},
|
||||||
|
{name: "service_tier", value: promptDebugJSONString},
|
||||||
|
{name: "reasoning_effort", value: promptDebugJSONString},
|
||||||
|
} {
|
||||||
|
value, ok := decoded[field.name]
|
||||||
|
if ok && field.value(value) {
|
||||||
|
parameters[field.name] = append(json.RawMessage(nil), value...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(parameters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encode prompt debug parameters: %w", err)
|
return nil, fmt.Errorf("encode safe prompt debug parameters: %w", err)
|
||||||
}
|
}
|
||||||
return encoded, nil
|
return encoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactPromptDebugValue(value any) {
|
func promptDebugJSONNumber(value json.RawMessage) bool {
|
||||||
switch typed := value.(type) {
|
var decoded float64
|
||||||
case map[string]any:
|
return json.Unmarshal(value, &decoded) == nil
|
||||||
for key, item := range typed {
|
|
||||||
if isPromptDebugSecretKey(key) {
|
|
||||||
typed[key] = "[redacted]"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
redactPromptDebugValue(item)
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for _, item := range typed {
|
|
||||||
redactPromptDebugValue(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPromptDebugSecretKey(key string) bool {
|
func promptDebugJSONInteger(value json.RawMessage) bool {
|
||||||
normalized := strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(key))
|
var decoded int
|
||||||
return strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret") || strings.Contains(normalized, "password") || strings.Contains(normalized, "token") || strings.Contains(normalized, "apikey") || strings.Contains(normalized, "authorization")
|
return json.Unmarshal(value, &decoded) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptDebugJSONString(value json.RawMessage) bool {
|
||||||
|
var decoded string
|
||||||
|
return json.Unmarshal(value, &decoded) == nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestPromptDebugWriterWritesIsolatedArtifacts(t *testing.T) {
|
|||||||
t.Fatalf("WriteExecution() directory = %q, want %q", executionDir, preparationDir)
|
t.Fatalf("WriteExecution() directory = %q, want %q", executionDir, preparationDir)
|
||||||
}
|
}
|
||||||
preparationData := readPromptDebugFile(t, filepath.Join(preparationDir, "preparation.json"))
|
preparationData := readPromptDebugFile(t, filepath.Join(preparationDir, "preparation.json"))
|
||||||
for _, want := range []string{"weatherreporter.prompt_preparation_debug.v2", "Use the supplied weather facts.", `"type": "object"`, "https://llm.example.test/v1/chat?api_key=%5Bredacted%5D", `"temperature": 0.2`, `"api_key": "[redacted]"`} {
|
for _, want := range []string{"weatherreporter.prompt_preparation_debug.v2", "Use the supplied weather facts.", `"type": "object"`, "https://llm.example.test", `"temperature": 0.2`} {
|
||||||
if !strings.Contains(string(preparationData), want) {
|
if !strings.Contains(string(preparationData), want) {
|
||||||
t.Fatalf("preparation debug artifact missing %q:\n%s", want, preparationData)
|
t.Fatalf("preparation debug artifact missing %q:\n%s", want, preparationData)
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,79 @@ func TestPromptDebugWriterWritesIsolatedArtifacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPromptDebugWriterProjectsProviderConfigurationSafely(t *testing.T) {
|
||||||
|
const marker = "private-debug-marker"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "user info signed path and access key query",
|
||||||
|
endpoint: "https://operator:" + marker + "@llm.example.test/signed/" + marker + "?access-key=" + marker + "&Signature=" + marker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case and separator query aliases",
|
||||||
|
endpoint: "https://llm.example.test/" + marker + "?X-Amz-Signature=" + marker + "&AUTH=" + marker + "&session_cookie=" + marker,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
parameters := []byte(`{
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": 400,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
"service_tier": "flex",
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"access-key": "private-debug-marker",
|
||||||
|
"signature": "private-debug-marker",
|
||||||
|
"auth": "private-debug-marker",
|
||||||
|
"cookie": "private-debug-marker",
|
||||||
|
"nested": {"X Api Key": "private-debug-marker"},
|
||||||
|
"extra_params": {"authorization": "private-debug-marker"}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
for index, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
writer, err := NewPromptDebugWriter(filepath.Join(t.TempDir(), "debug"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||||
|
}
|
||||||
|
ref := promptDebugRef()
|
||||||
|
ref.RunID = fmt.Sprintf("run-%d", index)
|
||||||
|
directory, err := writer.WritePreparation(ref, promptDebugPreparationFixture(), &promptexec.PreparationDebug{
|
||||||
|
Endpoint: tt.endpoint,
|
||||||
|
ParametersJSON: parameters,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WritePreparation() error = %v", err)
|
||||||
|
}
|
||||||
|
data := readPromptDebugFile(t, filepath.Join(directory, "preparation.json"))
|
||||||
|
text := string(data)
|
||||||
|
if strings.Contains(text, marker) || strings.Contains(text, "signed") || strings.Contains(text, "access-key") || strings.Contains(text, "signature") || strings.Contains(text, "cookie") {
|
||||||
|
t.Fatalf("preparation debug artifact leaked provider configuration:\n%s", data)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"https://llm.example.test", `"temperature": 0.2`, `"max_tokens": 400`, `"top_p": 0.9`, `"timeout_seconds": 30`, `"service_tier": "flex"`, `"reasoning_effort": "high"`} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("preparation debug artifact missing safe value %q:\n%s", want, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptDebugWriterDoesNotProjectParameterValuesInErrors(t *testing.T) {
|
||||||
|
const marker = "private-debug-marker"
|
||||||
|
writer, err := NewPromptDebugWriter(filepath.Join(t.TempDir(), "debug"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = writer.WritePreparation(promptDebugRef(), promptDebugPreparationFixture(), &promptexec.PreparationDebug{
|
||||||
|
ParametersJSON: []byte(`{"access-key":"` + marker),
|
||||||
|
})
|
||||||
|
if err == nil || strings.Contains(err.Error(), marker) {
|
||||||
|
t.Fatalf("WritePreparation() error = %v, want safe parameter projection error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPromptDebugWriterAtomicallyReplacesArtifacts(t *testing.T) {
|
func TestPromptDebugWriterAtomicallyReplacesArtifacts(t *testing.T) {
|
||||||
writer, err := NewPromptDebugWriter(filepath.Join(t.TempDir(), "debug"))
|
writer, err := NewPromptDebugWriter(filepath.Join(t.TempDir(), "debug"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user