Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/safeguard-ps-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ The dev-loop wrapper distinguishes three failure shapes:
## Secret handling

- Do not write secret parameter values into evidence, status messages, or operator-visible output.
- SPP server-side already redacts known credential parameters as the literal string `**secret**` in returned task logs (constant `Hercules\Source\Hercules.DevKit\Constants\ParameterConstants.cs:5`, cited in [`tools/README.md`](../../../tools/README.md), "Secret handling"). Do not attempt to recover real values from these markers.
- SPP server-side already redacts known credential parameters as the literal string `**secret**` in returned task logs (constant `PangaeaAppliance\src\Platform\Platform.Rsms\Constants\ParameterConstants.cs:5`, cited in [`tools/README.md`](../../../tools/README.md), "Secret handling"; cross-repo map in [`docs/agent-reference/rsms-engine-map.md`](../../../docs/agent-reference/rsms-engine-map.md)). Do not attempt to recover real values from these markers.
- Custom-script authors who add new secret parameters must declare them with `Type: "Secret"` so the same redaction applies — see [`script-authoring`](../script-authoring/SKILL.md).

## Failing closed
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/task-log-analysis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ The skill consumes the JSON document produced by [`tools/Invoke-PlatformDevLoop.
Two layers of evidence matter:

- **`phases[2].data.taskLog`** — the structured array (`Timestamp`, `Status`, `Message`) that `safeguard-ps` attaches to `Ex.SafeguardLongRunningTaskException` on a trigger failure. The first non-`Queued`/`Running` `Status` value usually pins the failure phase; the last entry is the user-visible summary.
- **`phases[3].data.log`** — the per-named-log entries (`Recorded`, `Level`, `Event`) from `Get-SafeguardTaskLog`. Sections are separated by synthetic `--- <logName> ---` entries inserted by safeguard-ps. The two log names produced by SPP for platform tasks are stable string constants `Operation` and `SshCommunication` (cited in [`tools/README.md`](../../../tools/README.md) "phases[3] data"; defined in `Hercules\Source\Rsms.Public\Constants\Logging.cs:14-15`).
- **`phases[3].data.log`** — the per-named-log entries (`Recorded`, `Level`, `Event`) from `Get-SafeguardTaskLog`. Sections are separated by synthetic `--- <logName> ---` entries inserted by safeguard-ps. The two log names produced by SPP for platform tasks are stable string constants `Operation` and `SshCommunication` (cited in [`tools/README.md`](../../../tools/README.md) "phases[3] data"; defined in `PangaeaAppliance\src\Common\Common.Rsms\Rsms.Public\Constants\Logging.cs:14-15`, mapped in [`docs/agent-reference/rsms-engine-map.md`](../../../docs/agent-reference/rsms-engine-map.md)).

Read both. The `Operation` log shows what the platform script intended; `SshCommunication` (when present) shows the raw frames so a `Send`/`Receive` mismatch becomes diagnosable.

Expand Down
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ SPP runs the script against an asset and an account: the asset supplies network

Authoring a custom platform is iterative: draft the JSON, validate it against the schema, import it into a test appliance, trigger an operation with extended logging, read the task log, fix the script, repeat. The agent skill system is built around making this loop fast and grounded in real evidence.

## Cross-repo: the Rsms execution engine

The scripts authored here do not run in this repo. They are parsed, validated, and executed by the **Rsms** scriptable engine that ships inside the appliance, whose source lives in a separate repository: [`Kevin-Andrew/PangaeaAppliance`](https://github.com/Kevin-Andrew/PangaeaAppliance). Several contracts this repo depends on — the task-log shape, the status enum, the log-name constants, and the secret-redaction sentinel — are *defined there*, not here.

Two consequences shape how an agent should reason about compatibility:

- **The engine is authoritative; this repo's `schema/custom-platform-script.schema.json` is a convenience mirror that can drift.** When the local schema and the engine disagree, the engine wins. A clean local schema validation does not prove the engine will accept the script (see "`SchemaOnly` is not a correctness signal" below). The usual fix for a disagreement is to correct this repo's schema or sample, not the script under test.
- **A "compatibility bug" is almost always drift between the two repos** — the engine added, moved, renamed, or tightened a construct, and this repo's schema, samples, docs, or citations lagged. Resolving one means checking what the engine *actually* does at the relevant source location.

When a compatibility question arises — "is this verb real?", "does the engine accept this parameter type?", "why does the appliance reject a script that passes local schema validation?" — consult [`docs/agent-reference/rsms-engine-map.md`](docs/agent-reference/rsms-engine-map.md). It maps every authored construct (operations, `Do`-block verbs, parameter types, reserved variables, task-log/status contracts, the validation entry points) to the authoritative PangaeaAppliance source file and line. Its "Script validation" section is the highest-yield place to look for "schema accepts / engine rejects" classes of bug. Note that engine source paths move (the `Hercules\Source\*` tree was relocated under `src\`); the engine-map records the current paths and re-confirming against the live PangaeaAppliance tree before a high-stakes change is expected.

If a change in PangaeaAppliance is what broke the contract, the fix belongs in both places: update the engine-map citation here, and — when reachable — leave a note in the PangaeaAppliance Rsms project pointing back at this repo so the next engine change prompts a heads-up.

## Operating modes

The agent declares the active mode at the start of every session. Each skill declares the modes it supports and **fails closed** when invoked outside them.
Expand Down
1 change: 1 addition & 0 deletions docs/agent-reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Human-facing documentation lives in `docs/concepts/`, `docs/guides/`, `docs/tuto
| [`failure-patterns.md`](failure-patterns.md) | Error-signature → likely cause → fix catalog used by `task-log-analysis`. | **Initially empty.** Rows are populated from real extended task logs as failures are encountered. Invented rows are not acceptable. |
| [`script-authoring-deep-dives.md`](script-authoring-deep-dives.md) | Long-form reference for topics extracted out of the `script-authoring` skill (diagnostics rules, sample-mining loop, function-call signatures, Linux `CheckPassword` pattern, `Catch`-block logging). | Hand-maintained. |
| [`vendor-doc-search-recipes.md`](vendor-doc-search-recipes.md) | Query templates for fetching vendor docs and a normalization recipe for pasted vendor-doc excerpts. | Hand-maintained. |
| [`rsms-engine-map.md`](rsms-engine-map.md) | Cross-repo map: each authored construct (operations, `Do`-block verbs, parameter types, reserved variables, task-log/status contracts, validation entry points) → the authoritative source `file:line` in the `Kevin-Andrew/PangaeaAppliance` repo, where the Rsms engine that executes these scripts lives. Backs cross-repo compatibility-bug work. | Hand-maintained from a source-cited analysis of the PangaeaAppliance tree. Re-verify citations against the current engine ref; paths move. |

## Related contracts

Expand Down
2 changes: 1 addition & 1 deletion docs/agent-reference/failure-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Each row is grounded in a real cmdlet response captured during authoring or onbo
| --- | --- | --- | --- |
| `An error was thrown in the try block: "An interactive session is required"` (immediately after first `Send` on a successfully-opened SSH connection) | operation | The `Connect` command opened the SSH session without requesting a PTY, but the very first `Send` (or any subsequent `sudo` invocation against a target with `Defaults use_pty` in sudoers) requires an interactive shell. The Ubuntu 24.04 default sudoers sets `Defaults use_pty`, so any `sudo` will fail with this signature even if the initial bash session would have tolerated `Send` without a PTY. | Add `"RequestTerminal": "%RequestTerminal%"` (or `"RequestTerminal": true`) to every `Connect` command in the script, and declare a `RequestTerminal` parameter (`Type: Boolean`, `DefaultValue: true`) on every operation that opens a connection. The PTY allocation is a per-Connect setting, not a per-platform one. |
| `Sorry, try again.\r\nsudo: no password was provided\r\nsudo: 2 incorrect password attempts` (in a `Receive` buffer after a `(printf ... ) \| sudo -S ...` pipeline; the `Send` completed cleanly and `Connect` succeeded) | operation | Piping a password through a bash one-liner into `sudo -S` is brittle inside a PTY-allocated shell: the parent shell may strip or echo the password line in ways that defeat sudo's stdin read, and the only diagnostic ever printed is a generic "Sorry, try again". The pattern is also fragile to passwords containing shell metacharacters. The proven SSH password-rotation pattern in the repo never pipes the password — it sends `sudo passwd <user>` as a normal command, then walks through sudo's password prompt, the new-password prompt, and the retype-new-password prompt **as separate `Send`/`Receive` pairs**, with the password buffers marked `"ContainsSecret": true`. | Replace any `printf ... \| sudo -S ...` construct with the prompt-driven pattern from [`samples/ssh/generic-linux/GenericLinux.json`](../../samples/ssh/generic-linux/GenericLinux.json) lines 281–340 (`ChangeUserPassword`): `Send "sudo passwd <user>; echo CHGPASS=$?\n"`, then `Receive` the sudo prompt, `Send` `%FuncPassword%` (`ContainsSecret: true`, no surrounding quotes, no trailing `\n` — the appliance terminates secret sends itself), `Receive` `[Nn]ew.*[Pp]assword:`, `Send` `%NewPassword%`, `Receive` retype/new prompt, `Send` `%NewPassword%` again, `Receive` `CHGPASS=[0-9]+`. Capture the final buffer with `WriteResponseObject` so the task log shows the success/failure marker on the very next iteration. |
| `Error in component 'SetItem': invalid expression: <expr> Ambiguous match found for: 'Split'` (raised by Z.Expressions when evaluating any `%{ ... .Split(':') ... }%` interpolation) | parse | The script-engine's expression evaluator (Z.Expressions) is bound against a modern .NET base class library where `string.Split(...)` has multiple overloads (`Split(char)`, `Split(char[])`, `Split(string)`, etc.). When the operand is a single-character literal like `':'`, the evaluator cannot pick between `Split(char)` and `Split(char[])` and throws `Ambiguous match found for: 'Split'`. The result is that **any** ssh-batch script using `someString.Split(':')[N]` to slice a colon-delimited shadow line, `passwd` entry, or fstab field will fail at the first `SetItem` that interpolates it — even though the same expression is legal C# in isolation. | **First ask whether you need to split at all.** If the value is going into `CompareShadowHash.SaltedHash`, do **not** pre-split — the component handler splits the shadow line on `:` internally (verified in Hercules `Source/Hercules.WebService/Common/Crypt/PasswordHash.cs` `CheckPasswordAgainstShadowEntry`, and demonstrated by [`samples/ssh/generic-linux/GenericLinux.json`](../../samples/ssh/generic-linux/GenericLinux.json) line 236 which passes `%AccountEntry%` whole). When a split is genuinely required, replace `<value>.Split('<delim>')[<index>]` with a `Regex.Match`-based extraction: `%{ Regex.Match(<value>, "^[^:]*:([^:]*):").Groups[1].Value }%` for the second colon-delimited field, parameterizing the pattern for the field index needed. `Regex.Match` has a single overload at the appliance and is unambiguous. The same applies to any other ambiguous `string` method (`Replace`, `IndexOf`) when called with a single-character literal. |
| `Error in component 'SetItem': invalid expression: <expr> Ambiguous match found for: 'Split'` (raised by Z.Expressions when evaluating any `%{ ... .Split(':') ... }%` interpolation) | parse | The script-engine's expression evaluator (Z.Expressions) is bound against a modern .NET base class library where `string.Split(...)` has multiple overloads (`Split(char)`, `Split(char[])`, `Split(string)`, etc.). When the operand is a single-character literal like `':'`, the evaluator cannot pick between `Split(char)` and `Split(char[])` and throws `Ambiguous match found for: 'Split'`. The result is that **any** ssh-batch script using `someString.Split(':')[N]` to slice a colon-delimited shadow line, `passwd` entry, or fstab field will fail at the first `SetItem` that interpolates it — even though the same expression is legal C# in isolation. | **First ask whether you need to split at all.** If the value is going into `CompareShadowHash.SaltedHash`, do **not** pre-split — the component handler splits the shadow line on `:` internally (verified in `PangaeaAppliance\src\Service\Rsms\Common\Crypt\PasswordHash.cs:96` `CheckPasswordAgainstShadowEntry`, which splits the `/etc/shadow` line itself at `:98`; the `CompareShadowHash` component delegates to it and passes the whole line — formerly at the old-layout path `Source\Hercules.WebService\Common\Crypt\PasswordHash.cs`, see [`docs/agent-reference/rsms-engine-map.md`](rsms-engine-map.md)), and demonstrated by [`samples/ssh/generic-linux/GenericLinux.json`](../../samples/ssh/generic-linux/GenericLinux.json) line 236 which passes `%AccountEntry%` whole). When a split is genuinely required, replace `<value>.Split('<delim>')[<index>]` with a `Regex.Match`-based extraction: `%{ Regex.Match(<value>, "^[^:]*:([^:]*):").Groups[1].Value }%` for the second colon-delimited field, parameterizing the pattern for the field index needed. `Regex.Match` has a single overload at the appliance and is unambiguous. The same applies to any other ambiguous `string` method (`Replace`, `IndexOf`) when called with a single-character literal. |
| Operation returns a clean verdict (e.g., `CheckResult: false`, `PasswordMismatch`) against a yescrypt-format `/etc/shadow` entry (`$y$j9T$...`) **after** a `Try`/`Catch` fired earlier in the operation. The catch's exception text — typically a Z.Expressions `Ambiguous match found for: 'Split'` from a `SetItem` that pre-extracted a hash field — is logged but the verdict surfaces unannotated. | operation (misdiagnosed) | The verdict is the **catch's fallback value, not the target's answer**. `CompareShadowHash` supports yescrypt (`$y$` prefix) via the `Yescrypt.IsYescrypt`/`Yescrypt.CheckPassword` branch in `PasswordHash.CheckPasswordAgainstHash`; the upstream `LinuxSshFunctions.json` import uses it on yescrypt-default Ubuntu/Debian systems. The actual root cause is a script-side bug in the path that runs **before** `CompareShadowHash` — most often pre-splitting the shadow line in a `SetItem` expression that hits the Split-overload-ambiguity row above, then a `Try`/`Catch` falls back to `auth-by-login` or returns a sentinel mismatch. Without reading the caught exception, the verdict looks like a target-side hash incompatibility. | Read the **caught exception text** from the operation log before drawing target-side conclusions — `script-authoring`'s "Catch blocks must log before falling back" rule applies. Then fix the real bug: pass `%AccountEntry%` (the whole shadow line, captured via `getent shadow <user>`) directly to `CompareShadowHash.SaltedHash`. Do not pre-split. Mirror [`samples/ssh/generic-linux/GenericLinux.json`](../../samples/ssh/generic-linux/GenericLinux.json) lines 220–245: capture, compare whole, condition on `PasswordHashMatched`, and only fall back to a login-as-test pattern in a `Catch` block (and only when `getent` is genuinely unavailable, e.g., locked-down sudo). yescrypt is not the problem. |
| `HTTP Request threw an exception ... AuthenticationException: The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch, RemoteCertificateChainErrors` (on the very first `Request` component log line `Sending request [...] [Skip SSL Validation=False]`; subsequent `Block returned error state` followed by the operation's `Catch` returning a sentinel like `false`, which SPP surfaces as `PasswordMismatch` even though the script never reached the auth step) | connect | The `Request` component does not skip server certificate validation by default — its log emits `[Skip SSL Validation=False]` when the flag is unset. Targets with self-signed or hostname-mismatched certificates fail the TLS handshake before any HTTP traffic. If the operation's outer `Try` catches all exceptions and returns `false`, SPP sees a clean `CheckPassword` verdict of "mismatch" with no auth attempt — the cert error is buried mid-log. | Declare the **reserved parameter** `SkipServerCertValidation` (`Type: Boolean`, `DefaultValue: false`) on every operation and on any function that issues a `Request`, then set `"IgnoreServerCertAuthentication": "%{SkipServerCertValidation}%"` on every `Request` block — including token-refresh/login calls inside helper functions. SPP auto-sources the parameter from the asset's `VerifySslCertificate` flag (asset-level toggle via `Edit-SafeguardAsset -VerifyServerSslCertificate $false` for self-signed labs), so no `-CustomScriptParameters` plumbing is needed at onboarding. See [`docs/reference/reserved-parameters.md`](../reference/reserved-parameters.md) line 128 and [`docs/guides/http-platforms.md`](../guides/http-platforms.md) lines 638–651. **Do not invent a custom parameter name** — reserved names get the auto-population for free; custom names require the operator to remember `-CustomScriptParameters` on every asset. Also audit `Catch` blocks: a `Catch` that swallows all exceptions and returns a domain sentinel (`false` for `CheckPassword`, `true` for `CheckSystem`) hides connect-phase failures as operation-phase verdicts — the `script-authoring` "Catch blocks must log before falling back" rule applies. |
| `Response status: BadRequest` against a target that accepts the **identical** body when sent manually with `Invoke-RestMethod` or `curl`; the log shows `Sending request [...] [Skip SSL Validation=True]` followed by a 400 response from an endpoint that expects form fields containing `@` or `%`. The `Request` block uses `Content: { "Value": "%PreBuiltBody%", "ContentType": "application/x-www-form-urlencoded" }` where `%PreBuiltBody%` is a string built by `SetItem` with `UrlEncode`-ed fields concatenated by hand. | parse | `Request.Content.Value` is **not a documented field** ([`docs/reference/commands/request.md`](../reference/commands/request.md) lines 51–55 only define `Content.ContentObjectName` and `Content.ContentType`; line 158 explicitly states "`ContentType` is only applied when `ContentObjectName` is present"). When a script supplies `Content.Value` with form ContentType, the engine's behavior is undefined — observed cases include re-encoding `%40` to `%2540` (double-encoding) and silently dropping the body. The target then sees corrupted or absent form fields and returns 400 "Parameter verification failed" or similar — fooling diagnosis into thinking the credential is wrong. | Build a form object with one `SetFormValue` per field (the form is created on first use; default `CreateForm: "CreateIfNotFound"` is correct), then reference it from `Request.Content.ContentObjectName`. The engine URL-encodes each field exactly once. Mirror [`samples/http/twitter/CustomTwitter.json`](../../samples/http/twitter/CustomTwitter.json) lines 133–148 (CheckPassword) and 225–242 (ChangePassword). Do **not** pre-`UrlEncode` field values when feeding them through `SetFormValue` — the component encodes during serialization. Reserve `UrlEncode` + `SetItem` for URL **path** components substituted into `Request.Url` (where `Request.Content` is not involved). |
Expand Down
Loading
Loading