iop/apps/edge/internal/openai/workspace_tool_codec.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[:])
}