modules/ISC.AgentAdapter.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest

$script:SchemaVersion = 1
$script:SupportedConnectors = @(
    'entra-id'
    'aws-saas'
    'aws-ciem'
    'google-workspace'
    'iqservice'
)

function Get-AgentSchemaVersion {
    return $script:SchemaVersion
}

function Get-AgentSupportedConnectors {
    return @($script:SupportedConnectors)
}

function ConvertTo-CanonicalJson {
    param([Parameter(Mandatory)]$InputObject)

    return ($InputObject | ConvertTo-Json -Depth 20 -Compress)
}

function Import-SourceSetupModule {
    param(
        [Parameter(Mandatory)][string]$ModuleRoot,
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$FileName
    )

    if (-not (Get-Module -Name $Name)) {
        Import-Module (Join-Path $ModuleRoot $FileName) -Force -Global -WarningAction SilentlyContinue
    }
}

function Get-AgentRequestValue {
    param(
        $Object,
        [Parameter(Mandatory)][string]$Name,
        $Default = $null
    )

    if ($null -eq $Object) { return $Default }
    if ($Object -is [System.Collections.IDictionary]) {
        if ($Object.Contains($Name)) { return $Object[$Name] }
        return $Default
    }
    $prop = $Object.PSObject.Properties[$Name]
    if ($prop) { return $prop.Value }
    return $Default
}

function Get-AgentRequestHash {
    param([Parameter(Mandatory)]$Request)

    $canonical = ConvertTo-CanonicalJson -InputObject $Request
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($canonical)
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $hash = $sha.ComputeHash($bytes)
    }
    finally {
        $sha.Dispose()
    }
    return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
}

function Read-AgentJsonFile {
    param([Parameter(Mandatory)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "JSON file not found: $Path"
    }
    $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
    if ([string]::IsNullOrWhiteSpace($raw)) {
        throw "JSON file is empty: $Path"
    }
    return ($raw | ConvertFrom-Json)
}

function Write-AgentJsonFile {
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)]$Object
    )

    $parent = Split-Path -Parent $Path
    if ($parent -and -not (Test-Path -LiteralPath $parent)) {
        New-Item -ItemType Directory -Path $parent -Force | Out-Null
    }
    $json = $Object | ConvertTo-Json -Depth 20
    Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 -NoNewline
}

function Resolve-AgentSecretReference {
    param([Parameter(Mandatory)][string]$Reference)

    if ($Reference -match '^env:(.+)$') {
        $name = $Matches[1]
        $value = [Environment]::GetEnvironmentVariable($name)
        if ([string]::IsNullOrEmpty($value)) {
            throw "Environment variable '$name' is not set."
        }
        return $value
    }
    if ($Reference -match '^file:(.+)$') {
        $path = $Matches[1]
        if (-not (Test-Path -LiteralPath $path)) {
            throw "Secret file not found: $path"
        }
        return (Get-Content -LiteralPath $path -Raw).TrimEnd()
    }
    throw "Invalid secret reference '$Reference'. Use env:NAME or file:/path."
}

function Set-AgentRestrictedFile {
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][string]$Content
    )

    $parent = Split-Path -Parent $Path
    if ($parent -and -not (Test-Path -LiteralPath $parent)) {
        New-Item -ItemType Directory -Path $parent -Force | Out-Null
    }
    Set-Content -LiteralPath $Path -Value $Content -Encoding UTF8 -NoNewline
    if ($IsWindows -or $env:OS -like 'Windows*') {
        $acl = Get-Acl -LiteralPath $Path
        $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
            [System.Security.Principal.WindowsIdentity]::GetCurrent().Name,
            'FullControl',
            'Allow'
        )
        $acl.SetAccessRule($rule)
        $acl | Set-Acl -LiteralPath $Path
    }
    else {
        & chmod 600 $Path
    }
}

function New-AgentRunDirectory {
    param(
        [Parameter(Mandatory)][string]$ConnectorSlug,
        [string]$RunId
    )

    if (-not $RunId) {
        $RunId = [Guid]::NewGuid().ToString('n')
    }
    $root = Join-Path (Get-Location) (Join-Path 'wizard-output' (Join-Path $ConnectorSlug 'agent-runs'))
    $runDir = Join-Path $root $RunId
    New-Item -ItemType Directory -Path $runDir -Force | Out-Null
    return [PSCustomObject]@{
        RunId = $RunId
        Path  = $runDir
    }
}

function New-AgentEnvelope {
    param(
        [Parameter(Mandatory)][ValidateSet('catalog', 'plan', 'result', 'error')]
        [string]$Kind,
        [Parameter(Mandatory)][string]$Connector,
        [string]$Status = 'ok',
        $Body = $null,
        [string[]]$Errors = @(),
        [string[]]$Warnings = @()
    )

    return [ordered]@{
        schemaVersion = $script:SchemaVersion
        kind          = $Kind
        connector     = $Connector
        status        = $Status
        body          = $Body
        errors        = @($Errors)
        warnings      = @($Warnings)
        generatedAt   = (Get-Date).ToUniversalTime().ToString('o')
    }
}

function Test-AgentSecretFieldName {
    param([Parameter(Mandatory)][string]$Name)

    $lower = $Name.ToLowerInvariant()
    return $lower -match 'secret|password|private key|refresh token|downloaduri|signed.?url'
}

function ConvertTo-RedactedAgentObject {
    param([Parameter(Mandatory)]$InputObject)

    if ($null -eq $InputObject) { return $null }
    if ($InputObject -is [string]) {
        if ($InputObject.Length -le 4) { return '***' }
        return '***' + $InputObject.Substring($InputObject.Length - 4)
    }
    if ($InputObject -is [System.Collections.IDictionary]) {
        $out = [ordered]@{}
        foreach ($key in $InputObject.Keys) {
            if (Test-AgentSecretFieldName -Name ([string]$key)) {
                $out[$key] = '***redacted***'
            }
            else {
                $out[$key] = ConvertTo-RedactedAgentObject -InputObject $InputObject[$key]
            }
        }
        return $out
    }
    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
        return @($InputObject | ForEach-Object { ConvertTo-RedactedAgentObject -InputObject $_ })
    }
    if ($InputObject -is [pscustomobject]) {
        $out = [ordered]@{}
        foreach ($prop in $InputObject.PSObject.Properties) {
            if (Test-AgentSecretFieldName -Name $prop.Name) {
                $out[$prop.Name] = '***redacted***'
            }
            else {
                $out[$prop.Name] = ConvertTo-RedactedAgentObject -InputObject $prop.Value
            }
        }
        return $out
    }
    return $InputObject
}

function Test-AgentPlanReady {
    param([Parameter(Mandatory)]$Plan)

    if ($Plan.status -ne 'ready') {
        throw "Plan status is '$($Plan.status)'. Resolve needsInput items before Apply."
    }
    if (-not $Plan.requestHash) {
        throw 'Plan is missing requestHash.'
    }
    return $true
}

function Assert-AgentPlanMatchesRequest {
    param(
        [Parameter(Mandatory)]$Plan,
        [Parameter(Mandatory)]$Request
    )

    $expected = Get-AgentRequestHash -Request $Request
    if ($Plan.requestHash -ne $expected) {
        throw 'Plan requestHash does not match the supplied request. Re-run Plan.'
    }
}

function Import-AgentConnectorModule {
    param(
        [Parameter(Mandatory)][string]$ModuleRoot,
        [Parameter(Mandatory)][string]$Connector
    )

    switch ($Connector) {
        'entra-id' { Import-Module (Join-Path $ModuleRoot 'ISC.EntraSourceSetup.psm1') -Force -WarningAction SilentlyContinue }
        'aws-saas' { Import-Module (Join-Path $ModuleRoot 'ISC.AwsSourceSetup.psm1') -Force -WarningAction SilentlyContinue }
        'aws-ciem' { Import-Module (Join-Path $ModuleRoot 'ISC.AwsSourceSetup.psm1') -Force -WarningAction SilentlyContinue }
        'google-workspace' { Import-Module (Join-Path $ModuleRoot 'ISC.GoogleSourceSetup.psm1') -Force -WarningAction SilentlyContinue }
        'iqservice' { Import-Module (Join-Path $ModuleRoot 'ISC.IQServiceSourceSetup.psm1') -Force -WarningAction SilentlyContinue }
        default { throw "Unsupported connector '$Connector'." }
    }
}

function Invoke-AgentConnectorCatalog {
    param(
        [Parameter(Mandatory)][string]$Connector
    )

    switch ($Connector) {
        'entra-id' { return Get-EntraAgentCatalog }
        'aws-saas' { return Get-AwsAgentCatalog -Variant 'aws-saas' }
        'aws-ciem' { return Get-AwsAgentCatalog -Variant 'aws-ciem' }
        'google-workspace' { return Get-GoogleAgentCatalog }
        'iqservice' { return Get-IQServiceAgentCatalog }
        default { throw "Unsupported connector '$Connector'." }
    }
}

function Invoke-AgentConnectorPlan {
    param(
        [Parameter(Mandatory)][string]$Connector,
        [Parameter(Mandatory)]$Request
    )

    switch ($Connector) {
        'entra-id' { return New-EntraAgentPlan -Request $Request }
        'aws-saas' { return New-AwsAgentPlan -Request $Request -Variant 'aws-saas' }
        'aws-ciem' { return New-AwsAgentPlan -Request $Request -Variant 'aws-ciem' }
        'google-workspace' { return New-GoogleAgentPlan -Request $Request }
        'iqservice' { return New-IQServiceAgentPlan -Request $Request }
        default { throw "Unsupported connector '$Connector'." }
    }
}

function Invoke-AgentConnectorApply {
    param(
        [Parameter(Mandatory)][string]$Connector,
        [Parameter(Mandatory)]$Request,
        [Parameter(Mandatory)]$Plan,
        [switch]$WhatIf
    )

    switch ($Connector) {
        'entra-id' { return Invoke-EntraAgentApply -Request $Request -Plan $Plan -WhatIf:$WhatIf }
        'aws-saas' { return Invoke-AwsAgentApply -Request $Request -Plan $Plan -Variant 'aws-saas' -WhatIf:$WhatIf }
        'aws-ciem' { return Invoke-AwsAgentApply -Request $Request -Plan $Plan -Variant 'aws-ciem' -WhatIf:$WhatIf }
        'google-workspace' { return Invoke-GoogleAgentApply -Request $Request -Plan $Plan -WhatIf:$WhatIf }
        'iqservice' { return Invoke-IQServiceAgentApply -Request $Request -Plan $Plan -WhatIf:$WhatIf }
        default { throw "Unsupported connector '$Connector'." }
    }
}

function Invoke-AgentAdapter {
    param(
        [Parameter(Mandatory)][ValidateSet('Catalog', 'Plan', 'Apply')]
        [string]$Operation,
        [Parameter(Mandatory)][string]$Connector,
        [string]$RequestPath,
        [string]$PlanPath,
        [string]$OutputPath,
        [switch]$WhatIf
    )

    if ($Connector -notin $script:SupportedConnectors) {
        throw "Unsupported connector '$Connector'. Supported: $($script:SupportedConnectors -join ', ')."
    }

    try {
        switch ($Operation) {
            'Catalog' {
                $body = Invoke-AgentConnectorCatalog -Connector $Connector
                $envelope = New-AgentEnvelope -Kind 'catalog' -Connector $Connector -Body $body
            }
            'Plan' {
                if (-not $RequestPath) { throw 'Plan requires -RequestPath.' }
                $request = Read-AgentJsonFile -Path $RequestPath
                if ($request.connector -and $request.connector -ne $Connector) {
                    throw "Request connector '$($request.connector)' does not match -Connector '$Connector'."
                }
                $planBody = Invoke-AgentConnectorPlan -Connector $Connector -Request $request
                $envelope = New-AgentEnvelope -Kind 'plan' -Connector $Connector -Status $planBody.status -Body $planBody
            }
            'Apply' {
                if (-not $RequestPath) { throw 'Apply requires -RequestPath.' }
                if (-not $PlanPath) { throw 'Apply requires -PlanPath.' }
                $request = Read-AgentJsonFile -Path $RequestPath
                $planWrapper = Read-AgentJsonFile -Path $PlanPath
                $plan = if ($planWrapper.body) { $planWrapper.body } else { $planWrapper }
                Test-AgentPlanReady -Plan $plan
                Assert-AgentPlanMatchesRequest -Plan $plan -Request $request
                $resultBody = Invoke-AgentConnectorApply -Connector $Connector -Request $request -Plan $plan -WhatIf:$WhatIf
                $envelope = New-AgentEnvelope -Kind 'result' -Connector $Connector -Status $resultBody.status -Body (ConvertTo-RedactedAgentObject -InputObject $resultBody)
            }
        }

        if ($OutputPath) {
            Write-AgentJsonFile -Path $OutputPath -Object $envelope
        }
        return $envelope
    }
    catch {
        $errorEnvelope = New-AgentEnvelope -Kind 'error' -Connector $Connector -Status 'error' -Errors @($_.Exception.Message)
        if ($OutputPath) {
            Write-AgentJsonFile -Path $OutputPath -Object $errorEnvelope
        }
        throw
    }
}

Export-ModuleMember -Function @(
    'Import-SourceSetupModule'
    'Get-AgentSchemaVersion'
    'Get-AgentSupportedConnectors'
    'ConvertTo-CanonicalJson'
    'Get-AgentRequestValue'
    'Get-AgentRequestHash'
    'Read-AgentJsonFile'
    'Write-AgentJsonFile'
    'Resolve-AgentSecretReference'
    'Set-AgentRestrictedFile'
    'New-AgentRunDirectory'
    'New-AgentEnvelope'
    'Test-AgentSecretFieldName'
    'ConvertTo-RedactedAgentObject'
    'Test-AgentPlanReady'
    'Assert-AgentPlanMatchesRequest'
    'Import-AgentConnectorModule'
    'Invoke-AgentAdapter'
)