552 lines
19 KiB
Go
552 lines
19 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
// workspaceEncodedPayload is the deterministic, self-contained payload the Edge
|
|
// produces for caller execution. The Edge never executes it and never inspects
|
|
// the workspace; it only produces it from the compiled binding and the issued
|
|
// tool call.
|
|
type workspaceEncodedPayload struct {
|
|
fingerprint string
|
|
alternative string
|
|
operation workspaceOperationKind
|
|
mode workspaceBindingMode
|
|
toolName string
|
|
// publicCallID is the IOP-issued tool call id; providerCallID is the
|
|
// provider-native id. Both are carried into receipt correlation.
|
|
publicCallID string
|
|
providerCallID string
|
|
// safePath is the lexically normalized, containment-checked relative path.
|
|
safePath string
|
|
// structuredArgs is the outgoing argument map keyed by actual tool field
|
|
// names. In structured mode it carries typed values unchanged; in command
|
|
// mode it carries only the synthesized command field.
|
|
structuredArgs map[string]any
|
|
// Command-mode encoding. commandArgv holds the resolved, unquoted argv in
|
|
// deterministic template order; commandString is its shell-safe joining.
|
|
commandField string
|
|
commandArgv []string
|
|
commandString string
|
|
// containmentGuard is the caller-executed guard expression. The Edge never
|
|
// evaluates it; it is returned verbatim to the caller.
|
|
containmentGuard string
|
|
// correlationDigest seals the complete issued payload. Receipt matching
|
|
// recomputes it before trusting any mutable in-memory fields.
|
|
correlationDigest string
|
|
}
|
|
|
|
// workspaceResult is a caller-reported workspace operation result the codec
|
|
// correlates against an issued payload.
|
|
type workspaceResult struct {
|
|
// callID is the tool call id the caller reports the result for. It must
|
|
// equal the issued public or provider id.
|
|
callID string
|
|
// status is the caller-reported outcome (e.g. "success", "error").
|
|
status string
|
|
// body is the caller-reported result body, if any.
|
|
body json.RawMessage
|
|
}
|
|
|
|
// workspaceResultReceipt records the correlation between a caller-reported
|
|
// result and the binding/payload that produced the call.
|
|
type workspaceResultReceipt struct {
|
|
fingerprint string
|
|
alternative string
|
|
operation workspaceOperationKind
|
|
toolName string
|
|
publicCallID string
|
|
providerCallID string
|
|
path string
|
|
status string
|
|
// resultHash is a sha256 of the compacted result body, empty when opaque.
|
|
resultHash string
|
|
// matched is true only when identity, operation, path, guard, and the
|
|
// configured result matcher all correlate.
|
|
matched bool
|
|
// mismatchReason explains why matched is false.
|
|
mismatchReason string
|
|
}
|
|
|
|
// encodeWorkspaceCall produces a deterministic, safe payload for one operation
|
|
// of the compiled binding from an issued tool call. It preserves typed
|
|
// structured values, synthesizes deterministic shell-safe commands in command
|
|
// mode, carries the public/provider identities, and emits a caller-executable
|
|
// containment guard. It returns an error when the call does not match the bound
|
|
// tool, the mapped path is missing, or the path fails lexical containment.
|
|
func encodeWorkspaceCall(b *workspaceBinding, op workspaceOperationKind, call normalizedToolCall) (*workspaceEncodedPayload, error) {
|
|
if b == nil {
|
|
return nil, fmt.Errorf("nil binding")
|
|
}
|
|
ob := b.operation(op)
|
|
if ob == nil {
|
|
return nil, fmt.Errorf("binding has no %q operation", op)
|
|
}
|
|
if call.Arguments == nil {
|
|
return nil, fmt.Errorf("nil call arguments")
|
|
}
|
|
if strings.TrimSpace(call.ID) == "" {
|
|
return nil, fmt.Errorf("call is missing a public tool call id")
|
|
}
|
|
if strings.TrimSpace(call.Name) != ob.toolName {
|
|
return nil, fmt.Errorf("call tool %q does not match bound tool %q for operation %q", call.Name, ob.toolName, op)
|
|
}
|
|
|
|
rawPath, ok := lookupMappedArgument(call.Arguments, ob.pathField)
|
|
if !ok {
|
|
return nil, fmt.Errorf("call is missing mapped path field %q", ob.pathField)
|
|
}
|
|
pathStr, ok := rawPath.(string)
|
|
if !ok || strings.TrimSpace(pathStr) == "" {
|
|
return nil, fmt.Errorf("mapped path field %q is not a non-empty string", ob.pathField)
|
|
}
|
|
safePath := lexicalNormalizePath(pathStr)
|
|
if err := validateContainment(safePath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
payload := &workspaceEncodedPayload{
|
|
fingerprint: b.fingerprint,
|
|
alternative: b.alternativeName,
|
|
operation: op,
|
|
mode: ob.mode,
|
|
toolName: ob.toolName,
|
|
publicCallID: strings.TrimSpace(call.ID),
|
|
providerCallID: strings.TrimSpace(call.ProviderCallID),
|
|
safePath: safePath,
|
|
}
|
|
|
|
switch ob.mode {
|
|
case modeStructured:
|
|
if err := encodeStructured(payload, ob, call, safePath); err != nil {
|
|
return nil, err
|
|
}
|
|
case modeCommand:
|
|
if err := encodeCommand(payload, ob, call, safePath); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unknown binding mode %q", ob.mode)
|
|
}
|
|
|
|
payload.containmentGuard = synthesizeContainmentGuard(safePath, ob.createsParents)
|
|
payload.correlationDigest = computePayloadCorrelationDigest(payload)
|
|
if payload.correlationDigest == "" {
|
|
return nil, fmt.Errorf("issued payload cannot be canonically correlated")
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
// encodeStructured drives the outgoing argument map only from the compiled
|
|
// argument map. The path is replaced with the containment-checked safe path;
|
|
// content and mode values are carried through byte-for-byte with their original
|
|
// types. No arbitrary extra fields are copied and no shell encoding is applied.
|
|
func encodeStructured(payload *workspaceEncodedPayload, ob *workspaceOperationBinding, call normalizedToolCall, safePath string) error {
|
|
args := make(map[string]any)
|
|
setMappedArgument(args, ob.pathField, safePath)
|
|
|
|
if ob.contentField != "" {
|
|
if value, ok := lookupMappedArgument(call.Arguments, ob.contentField); ok {
|
|
setMappedArgument(args, ob.contentField, cloneAnyValue(value))
|
|
} else if ob.op == opKindWrite {
|
|
return fmt.Errorf("write call is missing mapped content field %q", ob.contentField)
|
|
}
|
|
}
|
|
if ob.modeField != "" {
|
|
if value, ok := lookupMappedArgument(call.Arguments, ob.modeField); ok {
|
|
setMappedArgument(args, ob.modeField, cloneAnyValue(value))
|
|
}
|
|
}
|
|
|
|
payload.structuredArgs = args
|
|
return nil
|
|
}
|
|
|
|
// encodeCommand synthesizes a deterministic command from the fixed argv
|
|
// template. Placeholders {path} and {content} are substituted with the safe
|
|
// path and the mapped content; every other token is a literal. Each argv
|
|
// element is shell-safe single-quoted, so command output is stable regardless
|
|
// of Go map iteration order and content bytes are preserved exactly.
|
|
func encodeCommand(payload *workspaceEncodedPayload, ob *workspaceOperationBinding, call normalizedToolCall, safePath string) error {
|
|
var content string
|
|
if ob.contentField != "" {
|
|
if value, ok := lookupMappedArgument(call.Arguments, ob.contentField); ok {
|
|
content = commandArgumentString(value)
|
|
} else if ob.op == opKindWrite {
|
|
return fmt.Errorf("write call is missing mapped content field %q", ob.contentField)
|
|
}
|
|
}
|
|
|
|
argv := make([]string, 0, len(ob.argvTemplate))
|
|
for _, token := range ob.argvTemplate {
|
|
switch token {
|
|
case "{path}":
|
|
argv = append(argv, safePath)
|
|
case "{content}":
|
|
argv = append(argv, content)
|
|
default:
|
|
argv = append(argv, token)
|
|
}
|
|
}
|
|
|
|
quoted := make([]string, len(argv))
|
|
for i, arg := range argv {
|
|
quoted[i] = singleQuoteShell(arg)
|
|
}
|
|
|
|
payload.commandField = ob.commandField
|
|
payload.commandArgv = argv
|
|
payload.commandString = strings.Join(quoted, " ")
|
|
payload.structuredArgs = map[string]any{ob.commandField: payload.commandString}
|
|
return nil
|
|
}
|
|
|
|
// setMappedArgument assigns value at the dot-path key within args, creating
|
|
// intermediate maps as needed.
|
|
func setMappedArgument(args map[string]any, dotPath string, value any) {
|
|
parts := strings.Split(dotPath, ".")
|
|
current := args
|
|
for i := 0; i < len(parts)-1; i++ {
|
|
next, ok := current[parts[i]].(map[string]any)
|
|
if !ok {
|
|
next = make(map[string]any)
|
|
current[parts[i]] = next
|
|
}
|
|
current = next
|
|
}
|
|
current[parts[len(parts)-1]] = value
|
|
}
|
|
|
|
// commandArgumentString renders a mapped value for command substitution.
|
|
// Strings are used as-is; other JSON values are marshaled deterministically.
|
|
func commandArgumentString(value any) string {
|
|
if s, ok := value.(string); ok {
|
|
return s
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// lexicalNormalizePath applies deterministic path normalization without
|
|
// touching the filesystem: it trims, converts backslashes, collapses repeated
|
|
// slashes, and resolves "." segments while preserving a leading slash so
|
|
// validateContainment can reject absolute paths. ".." segments are preserved
|
|
// so validateContainment can reject traversal.
|
|
func lexicalNormalizePath(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
isAbsolute := strings.HasPrefix(raw, "/")
|
|
raw = strings.ReplaceAll(raw, "\\", "/")
|
|
for strings.Contains(raw, "//") {
|
|
raw = strings.ReplaceAll(raw, "//", "/")
|
|
}
|
|
parts := strings.Split(raw, "/")
|
|
resolved := make([]string, 0, len(parts))
|
|
for i, part := range parts {
|
|
if part == "." {
|
|
continue
|
|
}
|
|
if i == 0 && part == "" && isAbsolute {
|
|
resolved = append(resolved, "")
|
|
continue
|
|
}
|
|
if part == "" {
|
|
continue
|
|
}
|
|
resolved = append(resolved, part)
|
|
}
|
|
return strings.Join(resolved, "/")
|
|
}
|
|
|
|
// validateContainment lexically rejects paths that cannot be safely contained
|
|
// in the workspace before any encoding: empty, over-long, absolute, traversal,
|
|
// null-byte, and shell-metacharacter paths.
|
|
func validateContainment(path string) error {
|
|
if path == "" {
|
|
return fmt.Errorf("empty path")
|
|
}
|
|
if len(path) > 4096 {
|
|
return fmt.Errorf("path exceeds maximum length of 4096 characters")
|
|
}
|
|
if strings.HasPrefix(path, "/") {
|
|
return fmt.Errorf("absolute path is not allowed: %q", path)
|
|
}
|
|
for _, segment := range strings.Split(path, "/") {
|
|
if segment == ".." {
|
|
return fmt.Errorf("path traversal is not allowed: %q", path)
|
|
}
|
|
}
|
|
if strings.ContainsRune(path, 0) {
|
|
return fmt.Errorf("path contains null byte")
|
|
}
|
|
for _, r := range path {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
case r >= 'A' && r <= 'Z':
|
|
case r >= '0' && r <= '9':
|
|
case r == '.' || r == '-' || r == '_' || r == '/' || r == ' ':
|
|
default:
|
|
return fmt.Errorf("path contains unsafe character %q", string(r))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// synthesizeContainmentGuard returns a concrete caller-executed shell guard.
|
|
// It resolves the canonical workspace cwd and either the existing target or a
|
|
// canonical existing ancestor before the operation. Resolving the target itself
|
|
// when it already exists is essential: checking only the parent would allow a
|
|
// final-component symlink to escape the workspace. Parent-capable operations
|
|
// may retain a validated nonexistent suffix after fencing their nearest existing
|
|
// ancestor; operations without that capability still require the immediate
|
|
// parent to exist. The Edge never evaluates this guard or accesses a workspace.
|
|
func synthesizeContainmentGuard(relPath string, createsParents bool) string {
|
|
quoted := singleQuoteShell(relPath)
|
|
var b strings.Builder
|
|
b.WriteString("{ ")
|
|
b.WriteString(`IOP_WS_ROOT=$(realpath -e -- "${IOP_WORKSPACE_CWD:-.}") || exit 1; `)
|
|
b.WriteString(`if [ "$IOP_WS_ROOT" = "/" ]; then IOP_WS_PREFIX=""; else IOP_WS_PREFIX="$IOP_WS_ROOT"; fi; `)
|
|
b.WriteString(`IOP_WS_CANDIDATE="$IOP_WS_ROOT/`)
|
|
b.WriteString(relPath)
|
|
b.WriteString(`"; `)
|
|
b.WriteString(`if [ -e "$IOP_WS_CANDIDATE" ] || [ -L "$IOP_WS_CANDIDATE" ]; then IOP_WS_TARGET=$(realpath -e -- "$IOP_WS_CANDIDATE") || exit 1; `)
|
|
b.WriteString(`else `)
|
|
if createsParents {
|
|
b.WriteString(`IOP_WS_ANCESTOR="$IOP_WS_CANDIDATE"; IOP_WS_SUFFIX=""; `)
|
|
b.WriteString(`while [ ! -e "$IOP_WS_ANCESTOR" ] && [ ! -L "$IOP_WS_ANCESTOR" ]; do IOP_WS_NAME=$(basename -- "$IOP_WS_ANCESTOR") || exit 1; `)
|
|
b.WriteString(`if [ -n "$IOP_WS_SUFFIX" ]; then IOP_WS_SUFFIX="$IOP_WS_NAME/$IOP_WS_SUFFIX"; else IOP_WS_SUFFIX="$IOP_WS_NAME"; fi; `)
|
|
b.WriteString(`IOP_WS_ANCESTOR=$(dirname -- "$IOP_WS_ANCESTOR") || exit 1; done; `)
|
|
b.WriteString(`IOP_WS_ANCESTOR=$(realpath -e -- "$IOP_WS_ANCESTOR") || exit 1; `)
|
|
b.WriteString(`IOP_WS_TARGET="$IOP_WS_ANCESTOR/$IOP_WS_SUFFIX"; `)
|
|
} else {
|
|
b.WriteString(`IOP_WS_PARENT=$(realpath -e -- "$(dirname -- "$IOP_WS_CANDIDATE")") || exit 1; `)
|
|
b.WriteString(`IOP_WS_TARGET="$IOP_WS_PARENT/$(basename -- `)
|
|
b.WriteString(quoted)
|
|
b.WriteString(`)"; `)
|
|
}
|
|
b.WriteString(`fi; `)
|
|
b.WriteString(`case "$IOP_WS_TARGET/" in "$IOP_WS_PREFIX"/*) : ;; *) echo 'iop: path escapes workspace root' >&2; exit 1 ;; esac; }`)
|
|
return b.String()
|
|
}
|
|
|
|
// singleQuoteShell returns a POSIX single-quoted encoding of s. Bytes inside
|
|
// single quotes are literal, so content is preserved exactly; embedded single
|
|
// quotes are closed, escaped, and reopened.
|
|
func singleQuoteShell(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// matchResultReceipt correlates a caller-reported result against an issued
|
|
// payload. A matched receipt requires the reported call id to equal the issued
|
|
// public or provider id, the result body to parse, and the operation's
|
|
// configured result matcher to match the normalized {status, result} envelope.
|
|
// Opaque, error-shaped, wrong-id, and matcher-mismatched results do not match.
|
|
func matchResultReceipt(b *workspaceBinding, payload *workspaceEncodedPayload, result workspaceResult) *workspaceResultReceipt {
|
|
receipt := &workspaceResultReceipt{
|
|
operation: payload.operation,
|
|
toolName: payload.toolName,
|
|
path: payload.safePath,
|
|
status: result.status,
|
|
}
|
|
if b != nil {
|
|
receipt.fingerprint = b.fingerprint
|
|
receipt.alternative = b.alternativeName
|
|
}
|
|
receipt.publicCallID = payload.publicCallID
|
|
receipt.providerCallID = payload.providerCallID
|
|
if len(result.body) > 0 {
|
|
receipt.resultHash = sha256ResultHash(result.body)
|
|
}
|
|
|
|
if reason := matchResultCorrelation(b, payload, result); reason != "" {
|
|
receipt.mismatchReason = reason
|
|
return receipt
|
|
}
|
|
ob := b.operation(payload.operation)
|
|
|
|
normalized, err := normalizeResultEnvelope(result)
|
|
if err != nil {
|
|
receipt.mismatchReason = "result body is not valid JSON"
|
|
return receipt
|
|
}
|
|
if hasExplicitErrorSignal(normalized) {
|
|
receipt.mismatchReason = "result contains an explicit error signal"
|
|
return receipt
|
|
}
|
|
if !deepSubsetMatch(map[string]any(ob.resultMatcher), normalized) {
|
|
receipt.mismatchReason = "result does not satisfy the configured result matcher"
|
|
return receipt
|
|
}
|
|
|
|
receipt.matched = true
|
|
return receipt
|
|
}
|
|
|
|
// matchResultCorrelation validates only immutable issue identity. Callers use
|
|
// it to distinguish an exact caller-reported operation failure from malformed,
|
|
// unknown, or untrusted continuation input before considering cleanup.
|
|
func matchResultCorrelation(b *workspaceBinding, payload *workspaceEncodedPayload, result workspaceResult) string {
|
|
if b == nil || payload == nil || b.fingerprint != payload.fingerprint {
|
|
return "payload does not belong to binding"
|
|
}
|
|
if payload.correlationDigest == "" || payload.correlationDigest != computePayloadCorrelationDigest(payload) {
|
|
return "issued payload correlation digest does not match"
|
|
}
|
|
if b.operation(payload.operation) == nil {
|
|
return "binding has no such operation"
|
|
}
|
|
reportedID := strings.TrimSpace(result.callID)
|
|
if reportedID == "" {
|
|
return "result is missing a tool call id"
|
|
}
|
|
if reportedID != payload.publicCallID && reportedID != payload.providerCallID {
|
|
return "result call id does not correlate with the issued call"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// workspaceResultIsExact reports whether a caller result carries a
|
|
// self-describing operation report. An explicit failure status is exact on its
|
|
// own; otherwise the non-empty body must decode into the normalized
|
|
// {status, result} envelope. Opaque or malformed success bodies are untrusted
|
|
// and stay fail-closed.
|
|
func workspaceResultIsExact(result workspaceResult) bool {
|
|
if hasExplicitErrorSignal(map[string]any{"status": result.status}) {
|
|
return true
|
|
}
|
|
if len(bytes.TrimSpace(result.body)) == 0 {
|
|
return false
|
|
}
|
|
_, err := normalizeResultEnvelope(result)
|
|
return err == nil
|
|
}
|
|
|
|
// normalizeResultEnvelope builds the {status, result} envelope the configured
|
|
// result matcher is evaluated against. An empty body yields a nil result, so an
|
|
// opaque result cannot satisfy a matcher that requires result fields.
|
|
func normalizeResultEnvelope(result workspaceResult) (map[string]any, error) {
|
|
envelope := map[string]any{"status": result.status}
|
|
if len(bytes.TrimSpace(result.body)) == 0 {
|
|
envelope["result"] = nil
|
|
return envelope, nil
|
|
}
|
|
var decoded any
|
|
decoder := json.NewDecoder(bytes.NewReader(result.body))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
if err == nil {
|
|
return nil, fmt.Errorf("multiple JSON values are not allowed")
|
|
}
|
|
return nil, err
|
|
}
|
|
envelope["result"] = decoded
|
|
return envelope, nil
|
|
}
|
|
|
|
// computePayloadCorrelationDigest binds every issued value that affects caller
|
|
// execution or receipt admission. json.Marshal gives map keys a canonical
|
|
// ordering, preserving typed values while avoiding Go map iteration variance.
|
|
func computePayloadCorrelationDigest(payload *workspaceEncodedPayload) string {
|
|
if payload == nil {
|
|
return ""
|
|
}
|
|
description := map[string]any{
|
|
"fingerprint": payload.fingerprint,
|
|
"alternative": payload.alternative,
|
|
"operation": string(payload.operation),
|
|
"mode": string(payload.mode),
|
|
"tool_name": payload.toolName,
|
|
"public_call_id": payload.publicCallID,
|
|
"provider_call_id": payload.providerCallID,
|
|
"safe_path": payload.safePath,
|
|
"structured_args": payload.structuredArgs,
|
|
"command_field": payload.commandField,
|
|
"command_argv": payload.commandArgv,
|
|
"command_string": payload.commandString,
|
|
"containment_guard": payload.containmentGuard,
|
|
}
|
|
raw, err := json.Marshal(description)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
sum := sha256.Sum256(raw)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// hasExplicitErrorSignal rejects success-shaped bodies that also declare an
|
|
// endpoint error. It intentionally treats only semantically non-empty error
|
|
// values as signals so optional null/false fields remain representable.
|
|
func hasExplicitErrorSignal(value any) bool {
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range v {
|
|
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
|
if (normalizedKey == "error" || normalizedKey == "errors") && errorValuePresent(child) {
|
|
return true
|
|
}
|
|
if normalizedKey == "status" || normalizedKey == "type" {
|
|
if text, ok := child.(string); ok {
|
|
switch strings.ToLower(strings.TrimSpace(text)) {
|
|
case "error", "failed", "failure":
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if hasExplicitErrorSignal(child) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range v {
|
|
if hasExplicitErrorSignal(child) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func errorValuePresent(value any) bool {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return false
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return strings.TrimSpace(v) != ""
|
|
case []any:
|
|
return len(v) > 0
|
|
case map[string]any:
|
|
return len(v) > 0
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// sha256ResultHash computes a sha256 hex digest of the compacted result body
|
|
// for stable, order-independent receipt hashing.
|
|
func sha256ResultHash(body json.RawMessage) string {
|
|
var buf bytes.Buffer
|
|
if err := json.Compact(&buf, body); err != nil {
|
|
buf.Reset()
|
|
buf.Write(body)
|
|
}
|
|
sum := sha256.Sum256(buf.Bytes())
|
|
return hex.EncodeToString(sum[:])
|
|
}
|