feat(scaffolde): register native projected capabilities - #49
Conversation
Adds a narrow-waist `scaffolde_capability` tool that reads a Scaffolde-authored descriptor registry at `$HERMES_HOME/scaffolde/capabilities.json` and executes declared operations as argv-only subprocesses (no shell). - tools/scaffolde_capabilities.py: registry loader + validator + invoker. Fail-closed on schema, non-bun entrypoints, entrypoints outside HERMES_HOME, entrypoints/registry that do not match their Scaffolde-managed sha256 in .scaffolde/deployment.json, HOME/PATH overrides, secret-looking descriptor values, and malformed argv templates. Env is allowlist-only (inherit + set), stdout/stderr are truncated and redacted, write-risk operations are approval-gated with a 0600 temp preview for oversized payloads. - tools/scaffolde_capability_tool.py: status/list/invoke tool schema and handler, registered into the tool registry. - agent/scaffolde_capability_prompt.py + agent/system_prompt.py: routing block emitted only when the tool is live and the registry is valid, ahead of skill guidance. - toolsets.py: add `scaffolde_capability` to the Hermes core toolset. - tests/tools/test_scaffolde_capability_tool.py: registry status matrix, path/secret/command rejection, argument validation and no-shell argv, env allowlist, approval gating, degraded semantics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 091c08525f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| else: | ||
| script = Path(_expand_home(args[0], hermes_home)) | ||
| if not script.is_absolute(): | ||
| script = hermes_home / script |
There was a problem hiding this comment.
Resolve entrypoints against their execution cwd
When a descriptor uses an allowed relative entrypoint such as runner.ts with a non-root cwd, validation hashes $HERMES_HOME/runner.ts here, but execution passes runner.ts unchanged while setting the subprocess cwd, so Bun executes <cwd>/runner.ts instead. Bun 1.2.14's --help confirms that cwd is used to resolve files and entrypoints. This lets an unmanaged script execute under a registry that was reported valid; resolve and hash the relative entrypoint against the declared cwd, then execute that same resolved path.
Useful? React with 👍 / 👎.
| proc = subprocess.run( | ||
| argv, | ||
| cwd=desc["entrypoint"]["cwd"], |
There was a problem hiding this comment.
Revalidate the entrypoint after approval
If a write invocation waits for interactive approval and the managed entrypoint is replaced during that wait, the hash checked at the start of invoke_capability is stale by the time this call runs, so the replacement bytes execute without ever matching deployment metadata. The same race exists with a smaller window for reads. Pin the verified file or repeat integrity validation immediately before execution so approval cannot create a large provenance-bypass window.
Useful? React with 👍 / 👎.
| _SECRET_VALUE_RE = re.compile( | ||
| r"(-----BEGIN [A-Z ]*PRIVATE KEY-----|xox[baprs]-|gh[pousr]_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]{12,}|AIza[0-9A-Za-z_-]{20,}|ya29\.)" | ||
| ) |
There was a problem hiding this comment.
Redact complete prefixed credentials
When a capability prints a Slack or Google OAuth credential, these alternatives match only xoxb- or ya29.; _redact replaces only that prefix and returns the entire credential payload in stdout/stderr to the model and persisted conversation. For example, xoxb-1234567890-abcdefghijklmnop becomes [REDACTED]1234567890-abcdefghijklmnop. Extend each pattern through the complete token, or use the repository's established sensitive-text redactor.
Useful? React with 👍 / 👎.
| if isinstance(value, str) and len(value) > 160: | ||
| preview[name] = f"{value[:160]}… ({len(value)} chars)" |
There was a problem hiding this comment.
Show the complete payload before approval
When an individual write argument exceeds 160 characters but the canonical payload remains at most 2,000 characters—for example, a typical email body—the approval reason contains only its first 160 characters and no review file. Prompt-injected or simply unintended content can therefore be hidden in the omitted suffix while the user is asked to authorize the send. Use the exact-preview-file path whenever any field is truncated, or display the complete inline payload.
Useful? React with 👍 / 👎.
| # Scaffolde native capability narrow waist (status/list/invoke over descriptor registry) | ||
| "scaffolde_capability", |
There was a problem hiding this comment.
Gate the capability tool on a valid registry
For every installation without a valid Scaffolde deployment, placing this tool in _HERMES_CORE_TOOLS without a check_fn sends an unusable schema on every model request; the new test explicitly locks in that behavior even when the registry is absent. Register it with the existing registry_has_valid_capabilities check (and expose registry diagnostics outside the model schema if needed) so non-Scaffolde users pay zero permanent tool footprint.
AGENTS.md reference: AGENTS.md:L193-L195
Useful? React with 👍 / 👎.
| text=True, | ||
| capture_output=True, | ||
| timeout=max(1, min(int(timeout), 300)), |
There was a problem hiding this comment.
Enforce output limits while reading
When a capability emits a large or unbounded stream, capture_output=True buffers all stdout and stderr in memory before the later 64K/16K slices are applied. A runaway or data-heavy runner can therefore exhaust the long-lived CLI or gateway process despite the apparent output caps. Read through bounded pipes or temporary files and terminate or drain once the configured limits are reached.
Useful? React with 👍 / 👎.
| try: | ||
| actual = hashlib.sha256((hermes_home / relative_path).read_bytes()).hexdigest() | ||
| except OSError: | ||
| return False | ||
| return actual == expected |
There was a problem hiding this comment.
Bound registry and entrypoint reads before verification
When an attacker with the stated ability to write $HERMES_HOME/scaffolde/ replaces the registry or a managed entrypoint with a very large file, integrity checking buffers the entire file with read_bytes, and the registry path additionally calls read_text and json.loads before its managed hash is checked. A hash mismatch therefore does not prevent memory exhaustion during status, prompt construction, or invocation. Stream hashes with an explicit size ceiling and verify the registry bytes before parsing them.
Useful? React with 👍 / 👎.
| elif {"HOME", "PATH"} & set(env_set): | ||
| errors.append(_err("environment_override", "environment.set may not override HOME or PATH", cid or None)) |
There was a problem hiding this comment.
Compare protected environment names case-insensitively
On Windows, environment names are case-insensitive, so a descriptor using Path or path in environment.set bypasses this exact-case PATH check and replaces the child process's effective PATH. A capability script that subsequently launches a command by name can then execute an unintended binary despite the documented no-PATH-override rule. Normalize environment names for validation and reject case-insensitive duplicates before building the child environment.
AGENTS.md reference: AGENTS.md:L58-L64
Useful? React with 👍 / 👎.
| shell=False, | ||
| text=True, | ||
| capture_output=True, |
There was a problem hiding this comment.
Decode Bun output explicitly as UTF-8
On hosts whose preferred locale encoding is not UTF-8, particularly Windows, text=True decodes Bun's UTF-8 output with the platform default encoding. Capability results containing non-ASCII email subjects or bodies can therefore become mojibake or raise a decode error that is reported as execution_failed even though the operation succeeded. Specify encoding="utf-8" with an explicit error policy when capturing the streams.
AGENTS.md reference: AGENTS.md:L58-L64
Useful? React with 👍 / 👎.
| reason = f"Scaffolde capability {capability_id}.{operation} is a write operation and requires approval. {detail}" | ||
| return reason, f"scaffolde:{capability_id}:{operation}:{digest}", preview_path |
There was a problem hiding this comment.
Encode approval rule components unambiguously
Because capability IDs and operation names are only required to be non-empty strings, they may contain :. Two distinct writes such as capability a:b operation c and capability a operation b:c, with the same arguments, produce the same approval rule key here; a session or permanent approval for one therefore auto-approves the other. Hash a canonical structured tuple or restrict identifier characters rather than concatenating unescaped components.
Useful? React with 👍 / 👎.
Summary
scaffolde_capabilityas a native Hermes toolSecurity model
$HERMES_HOME/.scaffolde/deployment.jsonbunwith exactly one managed.tsentrypoint and no interpreter flagsshell=Falserisk: writeoperation uses payload-scoped approval; large payloads use an exact temporary0600preview plus SHA-256Verification
ruff checkon changed Python files — passedDeployment order
Land this consumer before Scaffolde producer PR pai-scaffolde/scaffolde-ai#2086. Without a projected registry, the tool reports an explicit absent/degraded status.