diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..36bd853 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [StartAutomating] diff --git a/.github/workflows/BuildReptile.yml b/.github/workflows/BuildReptile.yml new file mode 100644 index 0000000..b9fce19 --- /dev/null +++ b/.github/workflows/BuildReptile.yml @@ -0,0 +1,497 @@ + +name: Build Module +on: + push: + pull_request: + workflow_dispatch: +jobs: + TestPowerShellOnLinux: + runs-on: ubuntu-latest + steps: + - name: InstallPester + id: InstallPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: InstallPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Installs Pester + .Description + Installs Pester + #> + param( + # The maximum pester version. Defaults to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99' + ) + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-Module -Name Pester -Repository PSGallery -Force -Scope CurrentUser -MaximumVersion $PesterMaxVersion -SkipPublisherCheck -AllowClobber + Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion} @Parameters + - name: Check out repository + uses: actions/checkout@v2 + - name: RunPester + id: RunPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + $Parameters.NoCoverage = ${env:NoCoverage} + $Parameters.NoCoverage = $parameters.NoCoverage -match 'true'; + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: RunPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Runs Pester + .Description + Runs Pester tests after importing a PowerShell module + #> + param( + # The module path. If not provided, will default to the second half of the repository ID. + [string] + $ModulePath, + # The Pester max version. By default, this is pinned to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99', + + # If set, will not collect code coverage. + [switch] + $NoCoverage + ) + + $global:ErrorActionPreference = 'continue' + $global:ProgressPreference = 'silentlycontinue' + + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + if (-not $ModulePath) { $ModulePath = ".\$moduleName.psd1" } + $importedPester = Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion + $importedModule = Import-Module $ModulePath -Force -PassThru + $importedPester, $importedModule | Out-Host + + $codeCoverageParameters = @{ + CodeCoverage = "$($importedModule | Split-Path)\*-*.ps1" + CodeCoverageOutputFile = ".\$moduleName.Coverage.xml" + } + + if ($NoCoverage) { + $codeCoverageParameters = @{} + } + + + $result = + Invoke-Pester -PassThru -Verbose -OutputFile ".\$moduleName.TestResults.xml" -OutputFormat NUnitXml @codeCoverageParameters + + "::set-output name=TotalCount::$($result.TotalCount)", + "::set-output name=PassedCount::$($result.PassedCount)", + "::set-output name=FailedCount::$($result.FailedCount)" | Out-Host + if ($result.FailedCount -gt 0) { + "::debug:: $($result.FailedCount) tests failed" + foreach ($r in $result.TestResult) { + if (-not $r.Passed) { + "::error::$($r.describe, $r.context, $r.name -join ' ') $($r.FailureMessage)" + } + } + throw "::error:: $($result.FailedCount) tests failed" + } + } @Parameters + - name: PublishTestResults + uses: actions/upload-artifact@main + with: + name: PesterResults + path: '**.TestResults.xml' + if: ${{always()}} + TagReleaseAndPublish: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: TagModuleVersion + id: TagModuleVersion + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.TagAnnotationFormat = ${env:TagAnnotationFormat} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: TagModuleVersion $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The tag version format (default value: '$($imported.Name) $(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagAnnotationFormat = '$($imported.Name) $($imported.Version)' + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Tagging" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $existingTags = git tag --list + + @" + Target Version: $targetVersion + + Existing Tags: + $($existingTags -join [Environment]::NewLine) + "@ | Out-Host + + $versionTagExists = $existingTags | Where-Object { $_ -match $targetVersion } + + if ($versionTagExists) { + "::warning::Version $($versionTagExists)" + return + } + + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $UserEmail) { $UserEmail = "$UserName@github.com" } + git config --global user.email $UserEmail + git config --global user.name $UserName + + git tag -a $targetVersion -m $ExecutionContext.InvokeCommand.ExpandString($TagAnnotationFormat) + git push origin --tags + + if ($env:GITHUB_ACTOR) { + exit 0 + }} @Parameters + - name: ReleaseModule + id: ReleaseModule + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.ReleaseNameFormat = ${env:ReleaseNameFormat} + $Parameters.ReleaseAsset = ${env:ReleaseAsset} + $Parameters.ReleaseAsset = $parameters.ReleaseAsset -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: ReleaseModule $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The release name format (default value: '$($imported.Name) $($imported.Version)') + [string] + $ReleaseNameFormat = '$($imported.Name) $($imported.Version)', + + # Any assets to attach to the release. Can be a wildcard or file name. + [string[]] + $ReleaseAsset + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping GitHub release" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $targetReleaseName = $targetVersion + $releasesURL = 'https://api.github.com/repos/${{github.repository}}/releases' + "Release URL: $releasesURL" | Out-Host + $listOfReleases = Invoke-RestMethod -Uri $releasesURL -Method Get -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + + $releaseExists = $listOfReleases | Where-Object tag_name -eq $targetVersion + + if ($releaseExists) { + "::warning::Release '$($releaseExists.Name )' Already Exists" | Out-Host + $releasedIt = $releaseExists + } else { + $releasedIt = Invoke-RestMethod -Uri $releasesURL -Method Post -Body ( + [Ordered]@{ + owner = '${{github.owner}}' + repo = '${{github.repository}}' + tag_name = $targetVersion + name = $ExecutionContext.InvokeCommand.ExpandString($ReleaseNameFormat) + body = + if ($env:RELEASENOTES) { + $env:RELEASENOTES + } elseif ($imported.PrivateData.PSData.ReleaseNotes) { + $imported.PrivateData.PSData.ReleaseNotes + } else { + "$($imported.Name) $targetVersion" + } + draft = if ($env:RELEASEISDRAFT) { [bool]::Parse($env:RELEASEISDRAFT) } else { $false } + prerelease = if ($env:PRERELEASE) { [bool]::Parse($env:PRERELEASE) } else { $false } + } | ConvertTo-Json + ) -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Content-type" = "application/json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + } + + + + + + if (-not $releasedIt) { + throw "Release failed" + } else { + $releasedIt | Out-Host + } + + $releaseUploadUrl = $releasedIt.upload_url -replace '\{.+$' + + if ($ReleaseAsset) { + $fileList = Get-ChildItem -Recurse + $filesToRelease = + @(:nextFile foreach ($file in $fileList) { + foreach ($relAsset in $ReleaseAsset) { + if ($relAsset -match '[\*\?]') { + if ($file.Name -like $relAsset) { + $file; continue nextFile + } + } elseif ($file.Name -eq $relAsset -or $file.FullName -eq $relAsset) { + $file; continue nextFile + } + } + }) + + $releasedFiles = @{} + foreach ($file in $filesToRelease) { + if ($releasedFiles[$file.Name]) { + Write-Warning "Already attached file $($file.Name)" + continue + } else { + $fileBytes = [IO.File]::ReadAllBytes($file.FullName) + $releasedFiles[$file.Name] = + Invoke-RestMethod -Uri "${releaseUploadUrl}?name=$($file.Name)" -Headers @{ + "Accept" = "application/vnd.github+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } -Body $fileBytes -ContentType Application/octet-stream + $releasedFiles[$file.Name] + } + } + + "Attached $($releasedFiles.Count) file(s) to release" | Out-Host + } + + + + } @Parameters + - name: PublishPowerShellGallery + id: PublishPowerShellGallery + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.Exclude = ${env:Exclude} + $Parameters.Exclude = $parameters.Exclude -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: PublishPowerShellGallery $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + [string[]] + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif', 'docs[/\]*') + ) + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + if (-not $Exclude) { + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif','docs[/\]*') + } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + @" + ::group::PSBoundParameters + $($PSBoundParameters | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Gallery Publish" | Out-Host + return + } + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $foundModule = try { Find-Module -Name $imported.Name -ErrorAction SilentlyContinue} catch {} + + if ($foundModule -and (([Version]$foundModule.Version) -ge ([Version]$imported.Version))) { + "::warning::Gallery Version of $moduleName is more recent ($($foundModule.Version) >= $($imported.Version))" | Out-Host + } else { + + $gk = '${{secrets.GALLERYKEY}}' + + $rn = Get-Random + $moduleTempFolder = Join-Path $pwd "$rn" + $moduleTempPath = Join-Path $moduleTempFolder $moduleName + New-Item -ItemType Directory -Path $moduleTempPath -Force | Out-Host + + Write-Host "Staging Directory: $ModuleTempPath" + + $imported | Split-Path | + Get-ChildItem -Force | + Where-Object Name -NE $rn | + Copy-Item -Destination $moduleTempPath -Recurse + + $moduleGitPath = Join-Path $moduleTempPath '.git' + Write-Host "Removing .git directory" + if (Test-Path $moduleGitPath) { + Remove-Item -Recurse -Force $moduleGitPath + } + + if ($Exclude) { + "::notice::Attempting to Exlcude $exclude" | Out-Host + Get-ChildItem $moduleTempPath -Recurse | + Where-Object { + foreach ($ex in $exclude) { + if ($_.FullName -like $ex) { + "::notice::Excluding $($_.FullName)" | Out-Host + return $true + } + } + } | + Remove-Item + } + + Write-Host "Module Files:" + Get-ChildItem $moduleTempPath -Recurse + Write-Host "Publishing $moduleName [$($imported.Version)] to Gallery" + Publish-Module -Path $moduleTempPath -NuGetApiKey $gk + if ($?) { + Write-Host "Published to Gallery" + } else { + Write-Host "Gallery Publish Failed" + exit 1 + } + } + } @Parameters + BuildReptile: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@main + diff --git a/Build/GitHub/Jobs/BuildReptile.psd1 b/Build/GitHub/Jobs/BuildReptile.psd1 new file mode 100644 index 0000000..af20cce --- /dev/null +++ b/Build/GitHub/Jobs/BuildReptile.psd1 @@ -0,0 +1,10 @@ +@{ + "runs-on" = "ubuntu-latest" + if = '${{ success() }}' + steps = @( + @{ + name = 'Check out repository' + uses = 'actions/checkout@main' + } + ) +} \ No newline at end of file diff --git a/Build/GitHub/Steps/PublishTestResults.psd1 b/Build/GitHub/Steps/PublishTestResults.psd1 new file mode 100644 index 0000000..e8111e8 --- /dev/null +++ b/Build/GitHub/Steps/PublishTestResults.psd1 @@ -0,0 +1,10 @@ +@{ + name = 'PublishTestResults' + uses = 'actions/upload-artifact@main' + with = @{ + name = 'PesterResults' + path = '**.TestResults.xml' + } + if = '${{always()}}' +} + diff --git a/Build/Reptile.GitHubWorkflow.PSDevOps.ps1 b/Build/Reptile.GitHubWorkflow.PSDevOps.ps1 new file mode 100644 index 0000000..35312ca --- /dev/null +++ b/Build/Reptile.GitHubWorkflow.PSDevOps.ps1 @@ -0,0 +1,12 @@ +#requires -Module PSDevOps +Import-BuildStep -SourcePath ( + Join-Path $PSScriptRoot 'GitHub' +) -BuildSystem GitHubWorkflow + +Push-Location ($PSScriptRoot | Split-Path) +New-GitHubWorkflow -Name "Build Module" -On Push, + PullRequest, + Demand -Job TestPowerShellOnLinux, + TagReleaseAndPublish, BuildReptile -OutputPath .\.github\workflows\BuildReptile.yml + +Pop-Location \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..79e0f1c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +## Reptile 0.1: + +* Initial Release of Reptile (#1) +* Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL. +* Reptile is simple (#2) scalable (#3) and mostly safe (#4) +* `Get-Reptile` / `Reptile` / 🦎, 🐊 gets and hatches reptiles (#5, 24) +* Turtle.Reptile is a fun example (#6) + * Is has limited variable support (#22) +* Calculating Reptile is a safe calculator (#7) +* Days Until Reptile tells time until (#8) +* Gradient Reptile generates gradients (#9) +* Sleepy Reptile shows multiple delayed returns (#26) +* `Get-Reptile` can get a specific `-ReptileName/-Name/-Species` (#17) +* `Get-Reptile` can `-Run` or `-Hatch` a new instance (#18) +* `Reptile` can change its skin (#23) +* `Reptile` returns data in chunks (#25) diff --git a/Commands/Get-Reptile.ps1 b/Commands/Get-Reptile.ps1 new file mode 100644 index 0000000..e2ffcfb --- /dev/null +++ b/Commands/Get-Reptile.ps1 @@ -0,0 +1,688 @@ +function Get-Reptile +{ + <# + .SYNOPSIS + Reptile + .DESCRIPTION + Reptile - Read Evaluate Print Terminal Input Loop Editor + .NOTES + ## Reptile + ### Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL + + Command Lines can be scary. + + Websites feel much safer. + + Reptile gives you simple, scalable and safe web terminals. + + ### Installing and Importing + + We can install Reptile from the PowerShell Gallery: + + ~~~PowerShell + Install-Module Reptile + ~~~ + + Once installed, we can import it with: + + ~~~PowerShell + Import-Module Reptile -PassThru + ~~~ + + We can also clone the repository and import it from any directory: + + ~~~PowerShell + git clone https://github.com/PowerShellWeb/Reptile + cd ./Reptile + Import-Module ./ -PassThru + ~~~ + + ### Getting Started + + Once installed, we just run reptile: + + ~~~PowerShell + reptile + ~~~ + + This will start a simple terminal with no commands enabled. + + You can still 'run' a few things. + + `2+2` will equal `4`. "a" + "b" + "c" will be `abc`. + + Feel free to play around. + + Reptile runs in Restricted Language mode, and it's pretty restrictive. + + ## Simple, Scalable, Safe + + Reptile gives you simple, scalable and safe web terminals. + + ### Simple + + Reptile run PowerShell in a [data block](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542) + + This only allows whatever commands you choose, and does not allow loops, strong types, or methods. + + All a reptile really does is take input, create a data block, and call PowerShell. + + ### Scalable + + Reptile is built with a [HttpListener](https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542) + and [PowerShell Thread Jobs](https://learn.microsoft.com/en-us/powershell/module/threadjob/start-threadjob?wt.mc_id=MVP_321542). + + This makes Reptile simple to scale: Just launch more than one job. + + ### Safe + + Data statements are a constrained form of PowerShell that primarily process data. + + Data statements can also run any number of -SupportedCommands. + + Data statements cannot access most variables, use methods, reference most types, or loop. + + This makes them fairly ideal for a mostly safe REPL loop. + + If a command is not supported, it will not be run. + + This means that as long as no supported command allow arbitrary code injection, you are safe. + + However, if you ran `reptile -supportedCommand python`, + then that would be a much more dangerous reptile to deal with. + + Which is why there are some additional safety measures. + + #### Additional Safety Measures + + ##### Local Loopback Port + + By default, reptile will run on a random local loopback port. + + This has three security benefits: + + 1. It does not require elevation to administrator + 2. It does not open an external port + 3. It is less predictable + + If you are running reptile locally as intended, you control which scripts you run, and they can run as you. + + If you choose to allow a live reptile instance, you are as safe as the commands the reptile supports. + + ##### AST Inspection + + Scripts that are not parsable as a data block will never be run. + + Additionally, if someone succeeds in the miracle of escaping syntax, + and the AST is not a single data statement, it will not run. + + ##### Background Execution + + All data blocks will be evaluated in a background job. + + This is a trade off of performance for security. + + Responses will take longer than they would inline, + but any potential data corruption is quite literally limited in scope. + + The background jobs cannot access the main server thread, + and so have a much more difficult time escalating any potential jailbreaks. + + Additionally, because the responses are run in background _thread_ jobs, + it limits the overall impact of each request, and thus service is harder to deny. + + ### Reptile Roadmap + + Reptile will Evolve. + + Reptile is a new project, and will grow and change with time. + Implementation is subject to change. + + The next items on the Reptile Roadmap are: + + * Additional Protocol Support + * JsonRPC + * MCP + * XRPC + * New Examples + * Better Variable Input + * More Turtles (and other useful interactive tools) + .EXAMPLE + reptile + .LINK + https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542 + #> + [Alias('Reptile','REPL','WebRepl','🦎','🐊')] + param( + # The name of specific reptile. + # Will check the current directory and the reptile module directory for reptiles. + # If a single reptile exists with that name, it will be run. + [Alias('Species')] + [string] + $ReptileName, + + # If set, will spawn a new instance of the first matching `-ReptileName/-Species` + [Alias('Hatch')] + [switch] + $Run, + + # The rootUrl of the server. + # By default, a random loopback address. + # Randomized loopback addresses are not exposed to the network, + # and do not require running as admin. + [Alias('ServerURL')] + [string]$RootUrl= + "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", + + # The list of supported commands + [Alias('SupportedCommands')] + [string[]] + $SupportedCommand = @(), + + # The Reptile's Shell + # This object will be rendered on GET requests. + # It should be HTML. + [PSObject] + [Alias('Repl', 'WebRepl', 'Scales','Scale', 'Skin')] + $Shell = @( + if (Test-Path "./repl.html") { + Get-Content ./repl.html + } else { + "Reptile" + "" + "" + "" + "
" + "" + "" + "
" + "" + "" + } + ) -join [Environment]::NewLine, + + # The script used to initialize the reptile. + [ScriptBlock] + $Initialize = {}, + + # The number of reptiles to run. + [Alias('EggCount')] + [uint32] + $NodeCount = 1 + ) + + if ($ReptileName) { + if (Test-Path $ReptileName) { + return Get-Item $ReptileName + } + + $foundReptiles = @( + Get-Module Reptile | + Split-Path | + Get-ChildItem -Recurse -File -Filter *.ps1 | + Where-Object Name -match '\p{P}Reptile\p{P}' | + Where-Object Name -like "$ReptileName*" + + Get-ChildItem -Filter *.ps1 | + Where-Object Name -match '\p{P}Reptile\p{P}' | + Where-Object Name -like "$ReptileName*" + ) + + # If we found reptiles and want to run them + if ($foundReptiles -and $Run) { + # Launch the script + & $foundReptiles[0] + return + } + + $foundReptiles + return + } + + + if ($SupportedCommand -match '^(?>Invoke-Expression|iex)$') { + Write-Error "No. Invoke-Expression is unsafe. We will not support this." + return + } + + # Create a listener + $httpListener = [Net.HttpListener]::new() + $httpListener.Prefixes.Add($RootUrl) + # and write a warning so that the user knows (and can click it open) + Write-Warning "Listening on $RootUrl $($httpListener.Start())" + + # Make our IO object by packing our job input into a dictionary. + $io = [Ordered]@{ + HttpListener = $httpListener + SupportedCommand = $SupportedCommand + Shell = $Shell + Initialize = $Initialize + } + # Every item in this dictionary becomes a variable in our job. + + # We will add IO to the return objects. + + # If we want things to be "hot-swappable", we can reference $IO + + # For example, we want to reply in a background job: + $ReplyDefinition = { + param([ScriptBlock]$dataBlock, $reply, [Collections.IDictionary]$Option) + # We want to double check the data statement is the only thing + if (-not ( + ($dataBlock.Ast.EndBlock.Statements.Count -eq 1) -and + ($dataBlock.Ast.EndBlock.Statements[0] -is + [Management.Automation.Language.DataStatementAst]) + )) { + $reply.Close() + return + } + + $supportedCommand = $option.SupportedCommand + + # Another bit of "should not be possible" paranoia: + # Double-check that the list of supported commands in the data block + # matches our list of supported commands. + $dataStatementAllows = + $dataBlock.Ast.EndBlock.Statements[0].CommandsAllowed -replace + '^["'']' -replace '["'']$' + + if (($dataStatementAllows -join ',') -ne ($SupportedCommand -join ',')) { + $reply.close() + return + } + + + $out = if ($option.Out -is [ScriptBlock]) { + $option.Out + } else { + { + param($reply) + + begin { + if (-not $reply.OutputStream) { throw "no output stream" ; return } + $reply.ProtocolVersion = '1.1' + $reply.SendChunked = $true + } + + process { + $in = $_ + + if ($in.OuterXml) { + $buffer = $OutputEncoding.GetBytes("$($in.OuterXml)") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + $reply.OutputStream.Flush() + } + elseif ($in.html) { + $buffer = $OutputEncoding.GetBytes("$($in.html)") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + $reply.OutputStream.Flush() + } + else { + # or the stringification of the result. + $buffer = $OutputEncoding.GetBytes("$in") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + $reply.OutputStream.Flush() + } + } + + end { + if ($reply.Close) { + $reply.Close() + } + } + } + } + # Then we want to try running the data block + try { + & $dataBlock *>&1 | & $out $reply + } catch { + # If anything went wrong, though it feels wrong, we want to respond with 200 + $reply.StatusCode = 200 + # so that the error is clear to an interactive user. + $reply.Close($OutputEncoding.GetBytes("$($_)"), $false) + } + } + $IO.ReplyDefinition = $ReplyDefinition + + $ServerDefinition = { + param([Collections.IDictionary]$io) + + # First, let's unpack. + $psvariable = $ExecutionContext.SessionState.PSVariable + foreach ($key in @($io.Keys)) { + if ($io[$key] -is [PSVariable]) { $psvariable.set($io[$key]) } + else { $psvariable.set($key, $io[$key]) } + } + + # Now we want to declare several filters for various conditions + + # First up, let's handle how we error out + filter errorOut { + $err = $_ + # If this is not an error, return. + if ($err -isnot [Management.Automation.ErrorRecord]) { + return + } + # Attempt to find the best error message + $bestMessage = + if ($err.Exception.InnerException.Message) { + $err.Exception.InnerException.Message + } elseif ($err.Exception.Message) { + $err.Exception.Message + } else { + "$err" + } + + # write out our error + $err | Out-Host + $err | Write-Error + # and, ironically, say things are OK + $reply.StatusCode = 200 + # So we can show the user the error. + $reply.Close($OutputEncoding.GetBytes("$bestMessage"), $false) + } + + # Next let's define a command to construct our data block + + filter getDataBlock([string]$inputString) { + try { + # First we construct a script block. + # If this fails, the code is invalid. + $inputScriptBlock = + [ScriptBlock]::Create($inputString) + + # data blocks give us an inline restricted language mode + [ScriptBlock]::Create("data $( + if ($SupportedCommand) { "-supportedCommand '$( + # and we can support a limited set of commands. + $SupportedCommand -replace "'","''" -join "','" + )'"} + ) {" + + [Environment]::NewLine + + $inputScriptBlock + + [Environment]::NewLine + + "}") + } catch { + # If we could not make this a data block + $_ | errorOut + continue nextRequest + } + } + + # Next, we define how we replace variables + filter replaceVariable { + param([string]$variableName, [string]$replacement) + + # Very permissive variable pattern: + # variables can begin with: + $prefixes = @( + ':' # colons (logo style) + '-{2}' # two dashes (css style) + '\$' # dollar signs (PowerShell style) + '@' # sql style / splatting style () + ) + $variablePattern = "(?>$($prefixes -join '|'))" + ([Regex]::Escape($variableName)) + + $in = $_ + $in -replace $variablePattern, "'$( + $replacement -replace # First sanitize each value + "'","''" -join # then join into a list of constants + "','" # and now we have multi-value type free variable support + )'" + + # An expanded variable that _somehow_ escapes stringification + # should be be caught by the data block. + + # This allowing us to support parameters without sacrificing safety. + } + + + filter getCommandAndInput { + # Read our body + $streamReader = + [IO.StreamReader]::new($request.InputStream, $request.ContentEncoding) + + + $inputString = $streamReader.ReadToEnd() + + $streamReader.Close() + $streamReader.Dispose() + + # If we cannot parse the body, we'll pass it as a command. + $inputParsed = $null + $jsonRpc = $null + $inputCopy = [Ordered]@{} + # If the content type resembled json + if ($request.ContentType -match '.+?/.{0,}json') { + # try to parse it + $inputParsed = + try { $inputString | ConvertFrom-Json -AsHashtable} + catch { + # and error out if that did not work. + $_ | errorOut + continue nextRequest + } + + # JSON rpc sends a method and parameters + if ($inputParsed.jsonrpc -and + $inputParsed.method + ) { + $jsonRpc = $inputParsed + + # Per the json rpc spec, without an id it is a notification + if ($null -eq $jsonRpc.method.id) { + $inputString # emit the input (thus notifying the server owner) + $reply.Close() # and close the request + continue nextRequest + } + + $inputCopy.input = $jsonRpc.method + + foreach ($key in $jsonRpc.parameters.keys) { + $inputCopy[$key] = $jsonRpc.parameters[$key] + } + } + else { + foreach ($key in $inputParsed.keys) { + $inputCopy[$key] = $inputParsed[$key] + } + } + } + + # If the content type looks like form data + if ($request.ContentType -eq 'application/x-www-form-urlencoded') { + # try to parse it + try { $inputParsed = [Web.HttpUtility]::ParseQueryString($inputString) } + catch { + # and error out if that did not work. + $_ | errorOut + continue nextRequest + } + + foreach ($key in $inputParsed.Keys) { + $inputCopy[$key] = $inputParsed[$key] + Write-Host "$key - $($inputCopy[$key])" + } + $reply.ContentType = 'text/html' + } + + # If we have parsed the input, + # then it's fairly simple to support variables. + + # (data blocks don't have variables, + # but they guard against injection enough to support open-ended text input) + if ($inputCopy.Count) { + if (-not $inputCopy['Command']) { + $err = + Write-Error "No Command" -TargetObject $request *>&1 + $err | errorOut + continue nextRequest + } + $inputString = $inputCopy['Command'] + foreach ($key in $inputCopy.Keys) { + $inputString = $inputString | replaceVariable $key $inputCopy[$key] + } + } + + # and then write what was attempted and when. + @( + "$($request.RemoteAddr) $($request.httpMethod) $($request.Url) @ $([datetime]::Now)" + ) | Write-Host -ForegroundColor Cyan + + # Now we try to make it into a data block + $dataBlock = GetDataBlock $inputString + + # This last bit of healthy paranoia is done twice. + # It may not even be possible, but, if, somehow someone managed to inject a _second_ + # command, or, magically make it not a data block, + if ( + ($dataBlock.Ast.EndBlock.Statements.Count -ne 1) -or + ($dataBlock.Ast.EndBlock.Statements[0] -isnot + [Management.Automation.Language.DataStatementAst]) + ) { + # we want to write an error. + $err = + Write-Error "Unbalanced Injection Attempted @ $([datetime]::Now)" -Category SecurityError -TargetObject $request *>&1 + + $err | errorOut + + continue nextRequest + } + + # Another bit of "should not be possible" paranoia: + # Double-check that the list of supported commands in the data block + # matches our list of supported commands. + $dataStatementAllows = + $dataBlock.Ast.EndBlock.Statements[0].CommandsAllowed -replace + '^["'']' -replace '["'']$' + if (($dataStatementAllows -join ',') -ne ($SupportedCommand -join ',')) { + # we want to write an error. + $Message = @( + "Supported Commands Change Attempt @ $([datetime]::Now)." + "Expected $SupportedCommand, got $DataStatementAllows" + ) -join ' ' + $err = + Write-Error $Message -Category SecurityError -TargetObject $request *>&1 + + $err | errorOut + continue nextRequest + } + } + + # Now that we have prepared all of our functions, + # we have the main request loop. + + # Then listen for the next request + :nextRequest while ($httpListener.IsListening) { + $getContext = $httpListener.GetContextAsync() + # (wait for short prime intervals, so we can cancel if we need to). + while (-not $getContext.Wait(17)) { } + $request, $reply = + $getContext.Result.Request, $getContext.Result.Response + + # We will not be able to predict head requests + if ($request.httpMethod -eq 'head') { + # so tell the client that the content length is zero and close out. + $reply.ContentLength = 0; $reply.Close() + continue nextRequest + } + + if ($request.httpMethod -eq 'get') { + # If it's get, return the REPL + $reply.ContentType = 'text/html' + $replBytes = $OutputEncoding.GetBytes("$($io.Shell)") + $reply.Close($replBytes, $false) + continue nextRequest + } + + # Any other verb we'll try to evaluate the body. + # Of course, if there is no body + if (-not $request.InputStream) { + Write-Host "No input" -ForegroundColor Yellow + $reply.ContentLength = 0 + $reply.Close() # close out + continue nextRequest # and continue to the next request. + } + + $dataBlock = $null + + . getCommandAndInput + + # Now we can launch an inner thread job to run the script and reply. + $replyJobParameters = @{ + ScriptBlock=$ReplyDefinition + ThrottleLimit=1kb + ArgumentList=@( + $dataBlock, $reply, + [Ordered]@{ + 'supportedCommand' = $SupportedCommand + 'jsonrpc' = $jsonRpcParsed + } + ) + InitializationScript=$Initialize + } + + # Doing this makes the server more resilient, but will be slower than directly handling each request. + Start-ThreadJob @replyJobParameters + + # Clean up any completed requests and continue on with the loop. + Get-Job | + Where-Object State -eq 'Completed' | + Remove-Job -Force + + } + + } + + $JobParameters = @{ + ScriptBlock = $ServerDefinition + InitializationScript = $Initialize + ThrottleLimit = 256 + ArgumentList = $io + Name = $RootUrl + } + + + + $ForcePassThru = @{Force=$true;PassThru=$true} + foreach ($nodeNumber in 1..$NodeCount) { + # Our server is a thread job + $reptileJob = Start-ThreadJob @JobParameters| # Output our job, + Add-Member -NotePropertyMembers @{ # but attach a few properties first: + HttpListener=$httpListener # * The listener (so we can stop it) + IO=$IO # * The IO (so we can change it) + Url="$RootUrl" # The URL (so we can easily access it). + } @ForcePassThru # Pass all of that thru and return it to you. + + $reptileJob.pstypenames.add('Reptile') + $reptileJob | + Add-Member ScriptProperty -Name Shell -Value { + return $io.Shell + } -SecondValue { + $io.Shell = $args -join [Environment]::NewLine + } @ForcePassThru | + Add-Member AliasProperty -Name Skin -Value Shell @ForcePassThru + } +} diff --git a/Examples/Calculating.Reptile.ps1 b/Examples/Calculating.Reptile.ps1 new file mode 100644 index 0000000..ed45a88 --- /dev/null +++ b/Examples/Calculating.Reptile.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + A Caculating Reptile +.DESCRIPTION + A simple calculator in Reptile. + + PowerShell Data blocks act as a simple calculator. +.NOTES + This explicitly supports no commands and has no initialization script, so it will not use any default values. + + This is about as barebones as you can get. +#> +Reptile -SupportedCommand @() -Initialize {} -Shell @" +Calculating Reptile + +
+ + +
+ + +"@ \ No newline at end of file diff --git a/Examples/DaysUntil.Reptile.ps1 b/Examples/DaysUntil.Reptile.ps1 new file mode 100644 index 0000000..b91be17 --- /dev/null +++ b/Examples/DaysUntil.Reptile.ps1 @@ -0,0 +1,30 @@ +<# +.SYNOPSIS + Days Until Reptile +.DESCRIPTION + A simple single command form. Show the number of days until a date. +.NOTES + This demonstrates how we can build really self-service forms. +#> +Reptile -Initialize { + # We can declare a small function in initialize + function DaysUntil([Parameter(Mandatory)][DateTime]$Date) { + "

$(($date - [DateTime]::Now).TotalDays) days until $($date)

" + } +} -SupportedCommand @( + # We also need to add it to the list of supported commands. + 'DaysUntil' +) -Shell @( + # Our shell is just a form with two inputs: + "
" + # A date selector + "" + # (with a label) + "" + # and a hidden input containing our script. + "" + "" + "
" +) \ No newline at end of file diff --git a/Examples/Gradient.Reptile.ps1 b/Examples/Gradient.Reptile.ps1 new file mode 100644 index 0000000..5dd0def --- /dev/null +++ b/Examples/Gradient.Reptile.ps1 @@ -0,0 +1,35 @@ +<# +.SYNOPSIS + Gradient Reptile +.DESCRIPTION + A simple Reptile that makes gradients. +.NOTES + This imports a single module and exposes a pair of commands +#> +Reptile -SupportedCommand @( + 'Gradient','Get-Gradient' +) -Initialize { + Import-Module Gradient +} -Shell @" +Gradient Reptile + +
+ + + + + + + " +'@))" + +
+ + +"@ \ No newline at end of file diff --git a/Examples/Sleepy.Reptile.ps1 b/Examples/Sleepy.Reptile.ps1 new file mode 100644 index 0000000..b8b5c2a --- /dev/null +++ b/Examples/Sleepy.Reptile.ps1 @@ -0,0 +1,33 @@ +<# +.SYNOPSIS + A Sleepy Reptile +.DESCRIPTION + A Sleepy Reptile. + + A simple sleepy reptile that demonstrates asynchronous output +.NOTES + This exposes a single command `SayWhen`. + + `SayWhen` will say a message after a short sleep +#> +Reptile -SupportedCommand @('SayWhen') -Initialize { + function SayWhen( + [string]$Message = "when", + [ValidateRange('00:00:00', '00:00:15')] + [Timespan]$time = '00:00:01' + ) { + Start-Sleep -Milliseconds $time.TotalMilliseconds + "

$([Web.HttpUtility]::HtmlEncode($message))

" + } +} -Shell ( +@" +
+ + + +
+"@ +) + diff --git a/Examples/Turtle.Reptile.ps1 b/Examples/Turtle.Reptile.ps1 new file mode 100644 index 0000000..1f6ea9e --- /dev/null +++ b/Examples/Turtle.Reptile.ps1 @@ -0,0 +1,78 @@ +#requires -Module Turtle, MarkX, oEmbed + +Push-Location $PSScriptRoot + +Reptile -Initialize { + Import-Module Turtle, MarkX, OEmbed, Gradient -Global + $env:TURTLE_BOT = $true + Set-Alias Random Get-Random + function RandomColor { "#{0:x6}" -f (Get-Random -Max 0xffffff) } + function RandomAngle {Get-Random -Min -360 -Max 360 } + + function RandomPercent { "$(Get-Random -Min 0.01 -Max 99.99)%" } + + function ColorWheel { + "" + + "
" + } + + function say { + $allInput = @($input) + @($args) + foreach ($message in $allInput) { + "

$([Web.HttpUtility]::HtmlEncode($message))

" + } + } + + function tips { + $tips = @( + + '`colorwheel` draws a color wheel' + + '`Get-Random` gets random numbers (or random items)' + + 'You can multiply lists to repeat them: `turtle @("rotate", (360/5), "forward", 42 * 5)`' + + "There are many types of flower (flower, triflower, petalflower, goldenflower)" + + 'Turtle can do math. Try./ `turtle rotate (360/4) forward 42`' + + 'Turtle can make patterns. Just add `pattern` to the end of a command.' + ) + + "

$($tips | + Get-Random | + ConvertFrom-Markdown | + Select-Object -ExpandProperty html + )

" + } +} -Repl (./TurtleShell.html.ps1) -SupportedCommand @( + 'Turtle', 'Get-Turtle', '🐢' + + 'MarkX', 'Markdown', 'Get-MarkX', + + 'Gradient', 'Get-Gradient', + + 'Get-OEmbed', 'oEmbed' + + 'Get-Random', 'Random', 'RandomColor', 'RandomAngle', 'RandomPercent' + + 'ColorWheel' + + 'Say' + + 'tip','tips' +) + +Pop-Location \ No newline at end of file diff --git a/Examples/TurtleShell.html.ps1 b/Examples/TurtleShell.html.ps1 new file mode 100644 index 0000000..7e7f1ce --- /dev/null +++ b/Examples/TurtleShell.html.ps1 @@ -0,0 +1,583 @@ +param( +[string] +$Lucky = @' +turtle lucky +'@, + +[PSObject[]] +$Examples = @( + +# Any example code to render. + +# Examples can use the command attribute to specify the command they run. + +"
" +"Examples" + + +#region Basic Examples + +@" + + + + + + + + +"@ + +#endregion Basic Examples + +#region Sector Examples +" +
+
Sectors +" + +@" + + + + + + + + + + + +"@ + +" +
+ +
+" +#endregion Sector Examples +#region Pie Examples +"
" + +"
Pies" + +@" + + + + + + + + + + + + +"@ + +"
" + +"
" +#endregion Pie Examples +#region Polygon Examples +"
" + +"
Polygons" + +@" + + + + + + + + + + + + + + +"@ + +"
" + +"
" +#endregion Pie Examples + +) + +) + + +"" +"" +"" +"" +"" + +@" + + +"@ + +" +
+ + + +
+" +"" +"" + +@" + +"@ +"" +"" +$Examples +"" +"
" +"" +"
    " +"
" + +$scaleAnimation = "{ scale: ['100%', '105%','100%'] }, 67" + +$TurtleShellJS = [Ordered]@{ + "getVariables" = "function(inputId) { + +const inputElement = document.getElementById(inputId) +if (! inputElement) { return } +if (! inputElement.previousElementSibling) { return } + +const variables = {} +for (const element of [ + ...inputElement.previousElementSibling.childNodes +]) { + if (element === inputElement) { continue } + if (element.name && element.value) { + variables[element.name] = element.value + } +} + +return variables +} +" + "onInput" = "function() { + console.log(this.value) + + // First we need to find all of the matching variable names + const toAdd = [] + for (const match of [ + ...this?.value?.matchAll(/(?:\:|-{2}|\$|\@)(?\w+)/g) + ]) { + toAdd.push(match.groups.name) + } + + // If the length was zero + if (toAdd.length == 0) { + // make the previous sibling invisible + this.previousElementSibling.classList.add('invisible') + return + } + + // Then we need to figure out what changed + const newVariables = [] + const removedVariables = [] + const uniqueVariables = [] + + if (this.dataset?.variableNames) { + + if (this.dataset?.variableNames != toAdd.join(' ')) { + var oldNames = this.dataset?.variableNames.split(' ') + for (let index = 0; index < toAdd.length; index++) { + if (index < oldNames.length) { + if (oldNames[index] != toAdd[index]) { + uniqueVariables.push({ + name:toAdd[index], new:false, old: oldNames[index] + }) + } else { + uniqueVariables.push({ + name:toAdd[index], new:false + }) + } + } else { + uniqueVariables.push({name:toAdd[index], new:true}) + } + } + } + } else { + for (const variableName of toAdd) { + uniqueVariables.push({name:variableName, new:true}) + } + } + this.dataset['variableNames'] = toAdd.join(' ') + this.previousElementSibling.classList.remove('invisible') + for (const uniqueVariable of uniqueVariables) { + if (uniqueVariable.new) { + // new variable, create a new element + const newInput = document.createElement('input') + newInput.name = uniqueVariable.name + newInput.id = this.id + '-' + uniqueVariable.name + + const newLabel = document.createElement('label') + newLabel.setAttribute('for', newInput.id) + newLabel.innerText = uniqueVariable.name + + this.previousElementSibling.appendChild(newLabel) + this.previousElementSibling.appendChild(newInput) + } + + if (uniqueVariable.old) { + // Variable with old name, rename + const oldId = this.id + '-' + uniqueVariable.old + const newId = this.id + '-' + uniqueVariable.name + for (const childNode of [ + ...this.previousElementSibling.childNodes + ]) { + if (childNode.name == uniqueVariable.old) { + childNode.setAttribute('name', uniqueVariable.name) + childNode.setAttribute('id', newId) + } + if (childNode.getAttribute('for') == oldId) { + childNode.setAttribute('for', newId) + childNode.innerText = uniqueVariable.name + } + } + } + } + console.log(uniqueVariables) +}" + "go" = @" +async function() { + let inputId = '' + let inputScript = '' + + if (event?.target?.animate) { + event.target.animate($scaleAnimation); + } + + if ( + event?.target?.getAttribute && + event?.target?.getAttribute('command') + ) { + inputScript = event.target.getAttribute('command') + + inputId = turtleShell.newShell(inputScript) + + const outputId = inputId.replace(/^command/i, 'output') + + const out = document.getElementById(outputId) + + const response = await fetch( + window.location.href, + {method: 'POST',body: inputScript} + ) + + out.innerHTML = await response.text() + + out.animate({ scale: ['0%', '100%'] }, 67); + + const inputElement = document.getElementById(inputId) + if (inputElement) { + inputElement.removeAttribute('disabled') + } + const goElement = document.getElementById( + inputId.replace(/^command/i, 'go') + ) + if (goElement) { + goElement.removeAttribute('disabled') + } + return + } + if (event?.target?.previousSibling?.value && + event?.target?.previousSibling?.id.match(/^command/i)) { + inputId = event?.target?.previousSibling?.id + const outputId = inputId.replace(/^command/i, 'output') + } + + if (! inputScript && inputId == 'command' || ! inputId) { + const repl = document.getElementById('command') + + repl.animate($scaleAnimation) + + inputId = turtleShell.newShell( + repl.value, turtleShell.getVariables(repl.id) + ) + document.getElementById(inputId).addEventListener('input',turtleShell.onInput) + inputScript = repl.value + } else { + const repl = document.getElementById(inputId) + repl.setAttribute('disabled', 'true') + inputScript = repl?.value + } + + if (! inputScript) { return } + + const outputId = inputId.replace(/^command/i, 'output') + const out = document.getElementById(outputId) + const goElement = document.getElementById( + inputId.replace(/^command/i, 'go') + ) + if (goElement) { + goElement.setAttribute('disabled', 'true') + + } + const inputElement = document.getElementById(inputId) + + const requestBody = { + command: inputScript + } + + if (inputElement) { + inputElement.animate($scaleAnimation); + const variables = turtleShell.getVariables(inputId) + if (variables) { + for (const variableName of Object.keys(variables)) { + requestBody[variableName] = variables[variableName] + } + } + } + + const response = await fetch(window.location.href, + { + headers: {"Content-Type": "application/json"}, + method: 'POST', + body: JSON.stringify(requestBody) + } + ) + out.innerHTML = await response.text() + out.animate({ scale: ['0%', '100%'] }, 67); + + if (inputElement) { + inputElement.removeAttribute('disabled') + inputElement.animate($scaleAnimation); + } + if (goElement) { + goElement.removeAttribute('disabled') + } +} +"@ + "newShell" = @" + function (input, variables = {}) { + +const now = new Date() + +const outputItemList = document.getElementById('output-item-list') + +const newListDetails = document.createElement('details') +newListDetails.setAttribute('open', '') + +const newListSummary = document.createElement('summary') + +newListSummary.innerText = outputItemList.childNodes.length +newListDetails.appendChild(newListSummary) +const newListGrid = document.createElement('div') +newListGrid.classList.add('repl-command-grid') +newListDetails.appendChild(newListGrid) + +const newListVariableArea = document.createElement('details') + +const newListVariableSummary = document.createElement('summary') + +newListVariableSummary.innerText = 'variables' + +if (! variables || Object.keys(variables).length == 0) { + newListVariableArea.classList.add('invisible') +} + +newListVariableArea.classList.add('repl-variables') + +for (const variableName of Object.keys(variables)) { + + const newInput = document.createElement('input') + newInput.name = variableName + newInput.value = variables[variableName] + newInput.id = 'command' + now.getTime() + '-' + variableName + + const newLabel = document.createElement('label') + newLabel.setAttribute('for', newInput.id) + newLabel.innerText = variableName + + newListVariableArea.appendChild(newLabel) + newListVariableArea.appendChild(newInput) +} + +newListVariableArea.appendChild(newListVariableSummary) +newListGrid.appendChild(newListVariableArea) + +const newListInput = document.createElement('textarea') +const inputLines = input.split(/(\r\n|\n|\r)/) + +newListInput.setAttribute('spellcheck','false') +newListInput.setAttribute('autocomplete','repl-command') +newListInput.setAttribute('rows',inputLines.length - 1) +newListInput.setAttribute('disabled', 'true') + +newListInput.classList.add('repl-command') +newListInput.id = 'command' + now.getTime() +newListInput.value = input +newListGrid.appendChild(newListInput) + +const newListOutput = document.createElement('output') +newListOutput.id = newListInput.id.replace(/^command/i, 'output') + +const newGoButton = document.createElement('button') +newGoButton.id = newListInput.id.replace(/^command/i, 'go') +newGoButton.innerText = 'Go Turtle' +newGoButton.classList.add('repl-go') +newGoButton.addEventListener('click', this.go) +newGoButton.setAttribute('disabled', 'true') +newListGrid.appendChild(newGoButton) +newListDetails.appendChild(newListOutput) + +const topToBottom = true; +if (topToBottom && outputItemList.firstChild) { + outputItemList.insertBefore(newListDetails, outputItemList.firstChild) +} else { + outputItemList.appendChild(newListDetails) +} + +newListDetails.animate({ scale: ['0%', '100%'] }, 67); + +return newListInput.id +} +"@ +} + + + +"" +"" +"" \ No newline at end of file diff --git a/README.md b/README.md index 4cdc67a..fc7273a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,143 @@ # Reptile -Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL. +### Reptile - Read Evaluate Print Terminal Input Loop Editor +## Reptile +### Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL + +Command Lines can be scary. + +Websites feel much safer. + +Reptile gives you simple, scalable and safe web terminals. + +### Installing and Importing + +We can install Reptile from the PowerShell Gallery: + +~~~PowerShell +Install-Module Reptile +~~~ + +Once installed, we can import it with: + +~~~PowerShell +Import-Module Reptile -PassThru +~~~ + +We can also clone the repository and import it from any directory: + +~~~PowerShell +git clone https://github.com/PowerShellWeb/Reptile +cd ./Reptile +Import-Module ./ -PassThru +~~~ + +### Getting Started + +Once installed, we just run reptile: + +~~~PowerShell +reptile +~~~ + +This will start a simple terminal with no commands enabled. + +You can still 'run' a few things. + +`2+2` will equal `4`. "a" + "b" + "c" will be `abc`. + +Feel free to play around. + +Reptile runs in Restricted Language mode, and it's pretty restrictive. + +## Simple, Scalable, Safe + +Reptile gives you simple, scalable and safe web terminals. + +### Simple + +Reptile run PowerShell in a [data block](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542) + +This only allows whatever commands you choose, and does not allow loops, strong types, or methods. + +All a reptile really does is take input, create a data block, and call PowerShell. + +### Scalable + +Reptile is built with a [HttpListener](https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542) +and [PowerShell Thread Jobs](https://learn.microsoft.com/en-us/powershell/module/threadjob/start-threadjob?wt.mc_id=MVP_321542). + +This makes Reptile simple to scale: Just launch more than one job. + +### Safe + +Data statements are a constrained form of PowerShell that primarily process data. + +Data statements can also run any number of -SupportedCommands. + +Data statements cannot access most variables, use methods, reference most types, or loop. + +This makes them fairly ideal for a mostly safe REPL loop. + +If a command is not supported, it will not be run. + +This means that as long as no supported command allow arbitrary code injection, you are safe. + +However, if you ran `reptile -supportedCommand python`, +then that would be a much more dangerous reptile to deal with. + +Which is why there are some additional safety measures. + +#### Additional Safety Measures + +##### Local Loopback Port + +By default, reptile will run on a random local loopback port. + +This has three security benefits: + +1. It does not require elevation to administrator +2. It does not open an external port +3. It is less predictable + +If you are running reptile locally as intended, you control which scripts you run, and they can run as you. + +If you choose to allow a live reptile instance, you are as safe as the commands the reptile supports. + +##### AST Inspection + +Scripts that are not parsable as a data block will never be run. + +Additionally, if someone succeeds in the miracle of escaping syntax, +and the AST is not a single data statement, it will not run. + +##### Background Execution + +All data blocks will be evaluated in a background job. + +This is a trade off of performance for security. + +Responses will take longer than they would inline, +but any potential data corruption is quite literally limited in scope. + +The background jobs cannot access the main server thread, +and so have a much more difficult time escalating any potential jailbreaks. + +Additionally, because the responses are run in background _thread_ jobs, +it limits the overall impact of each request, and thus service is harder to deny. + +### Reptile Roadmap + +Reptile will Evolve. + +Reptile is a new project, and will grow and change with time. +Implementation is subject to change. + +The next items on the Reptile Roadmap are: + +* Additional Protocol Support + * JsonRPC + * MCP + * XRPC +* New Examples +* Better Variable Input +* More Turtles (and other useful interactive tools) diff --git a/README.md.ps1 b/README.md.ps1 new file mode 100644 index 0000000..63b5ddd --- /dev/null +++ b/README.md.ps1 @@ -0,0 +1,7 @@ +$help = Get-Help Reptile + +"# $($help.SYNOPSIS)" + +"### $($help.Description.text -join [Environment]::NewLine)" + +$help.alertset.alert.text -join [Environment]::NewLine \ No newline at end of file diff --git a/Reptile.psd1 b/Reptile.psd1 new file mode 100644 index 0000000..e49cf98 --- /dev/null +++ b/Reptile.psd1 @@ -0,0 +1,150 @@ +# +# Module manifest for module 'Reptile' +# +# Generated by: James Brundage +# +# Generated on: 12/27/2025 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'Reptile.psm1' + +# Version number of this module. +ModuleVersion = '0.1' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '16622757-8355-4457-8807-97bc03cf0b41' + +# Author of this module +Author = 'James Brundage' + +# Company or vendor of this module +CompanyName = 'Start Automating' + +# Copyright statement for this module +Copyright = '2025 Start Automating' + +# Description of the functionality provided by this module +Description = 'Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL.' + +# Minimum version of the PowerShell engine required by this module +PowerShellVersion = '7.0' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +# NestedModules = @() + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = 'Get-Reptile' + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = @() + +# Variables to export from this module +VariablesToExport = @() + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = 'Reptile', '🦎','🐊' + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + LicenseUri = 'https://github.com/PowerShellWeb/Reptile/blob/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/PowerShellWeb/Reptile' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + ReleaseNotes = @' +## Reptile 0.1: + +* Initial Release of Reptile (#1) +* Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL. +* Reptile is simple (#2) scalable (#3) and mostly safe (#4) +* `Get-Reptile` / `Reptile` / 🦎, 🐊 gets and hatches reptiles (#5, 24) +* Turtle.Reptile is a fun example (#6) + * Is has limited variable support (#22) +* Calculating Reptile is a safe calculator (#7) +* Days Until Reptile tells time until (#8) +* Gradient Reptile generates gradients (#9) +* Sleepy Reptile shows multiple delayed returns (#26) +* `Get-Reptile` can get a specific `-ReptileName/-Name/-Species` (#17) +* `Get-Reptile` can `-Run` or `-Hatch` a new instance (#18) +* `Reptile` can change its skin (#23) +* `Reptile` returns data in chunks (#25) + +'@ + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/Reptile.psm1 b/Reptile.psm1 new file mode 100644 index 0000000..b0d0bb8 --- /dev/null +++ b/Reptile.psm1 @@ -0,0 +1,6 @@ +$commandsPath = Join-Path $PSScriptRoot Commands +foreach ($file in Get-ChildItem -File -Recurse -Path $commandsPath) { + if ($file.Extension -ne '.ps1') { continue } + if ($file.Name -like '*.*.ps1') { continue } + . $file.FullName +} \ No newline at end of file diff --git a/Reptile.tests.ps1 b/Reptile.tests.ps1 new file mode 100644 index 0000000..1e879fe --- /dev/null +++ b/Reptile.tests.ps1 @@ -0,0 +1,23 @@ +describe Reptile { + context 'Is a mostly safe repl' { + it 'Runs things in restricted language' { + $r = Reptile + + Invoke-RestMethod -Uri $r.Url -Body "1+1" -Method Post | + Should -Be 2 + + $r.HttpListener.Stop() + $r | Remove-Job -Force + } + + it 'Will not run unapproved commands' { + $r = Reptile + + Invoke-RestMethod -Uri $r.Url -Body "Stop-Process -id $pid" -Method Post | + Should -Match "line:\d" + + $r.HttpListener.Stop() + $r | Remove-Job -Force + } + } +} \ No newline at end of file diff --git a/code_of_conduct.md b/code_of_conduct.md new file mode 100644 index 0000000..33a4b70 --- /dev/null +++ b/code_of_conduct.md @@ -0,0 +1,7 @@ +## Reptile Code of Conduct + +Please be kind and do not harass any creators or community members. + +Please do not make dangerous reptiles. + +Definitely do not attempt to make Reptile less secure, or introduce changes that might harm others. diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..a77682a --- /dev/null +++ b/contributing.md @@ -0,0 +1,9 @@ +Contributions are welcome! + +If you can think of anything worthwhile to contribute, please file an issue or start a discussion. + +Contributions of dangerous reptiles are not welcome. + +Please carefully consider the security implications of whatever you may wish to contribute. + + diff --git a/security.md b/security.md new file mode 100644 index 0000000..d217cae --- /dev/null +++ b/security.md @@ -0,0 +1,72 @@ +## Reptile Security + +Reptile is designed to be *mostly* safe. + +By default, without exposing commands, Reptile should be completely safe. + +By exposing any commands, Reptiles are (almost) as dangerous as the commands they can run. + +Please use locally, with commands you have threat modelled. + +If you find a security concern about Reptile, please open an issue. + +If you want to read more about how Reptile works and keeps things mostly safe, read below: + +### Reptile Safety + +Reptile runs in data statements for safety. + +Data statements are a constrained form of PowerShell that primarily process data. + +Data statements can also run any number of -SupportedCommands. + +Data statements cannot access most variables, use methods, reference most types, or loop. + +This makes them fairly ideal for a mostly safe REPL loop. + +If a command is not supported, it will not be run. + +This means that as long as no supported command allow arbitrary code injection, you are safe. + +However, if you ran `reptile -supportedCommand python`, +then that would be a much more dangerous reptile to deal with. + +Which is why there are some additional safety measures. + +#### Additional Safety Measures + +##### Local Loopback Port + +By default, reptile will run on a random local loopback port. + +This has three security benefits: + +1. It does not require elevation to administrator +2. It does not open an external port +3. It is less predictable + +If you are running reptile locally as intended, you control which scripts you run, and they can run as you. + +If you choose to allow a live reptile instance, you are as safe as the commands the reptile supports. + +##### AST Inspection + +Scripts that are not parsable as a data block will never be run. + +Additionally, if someone succeeds in the miracle of escaping syntax, +and the AST is not a single data statement, it will not run. + +##### Background Execution + +All data blocks will be evaluated in a background job. + +This is a trade off of performance for security. + +Responses will take longer than they would inline, +but any potential data corruption is quite literally limited in scope. + +The background jobs cannot access the main server thread, +and so have a much more difficult time escalating any potential jailbreaks. + +Additionally, because the responses are run in background _thread_ jobs, +it limits the overall impact of each request, and thus service is harder to deny.