@thsky-21/thskyshield
v4.0.0
Published
Runtime governance for autonomous AI agents — per-run tool allowlists, write ceilings, loop detection, step limits, and timeouts. Works with LangGraph, OpenAI Agents SDK, or any TypeScript agent framework.
Maintainers
Readme
@thsky-21/thskyshield
Runtime governance for autonomous agents. Your agent can only do what you said it could.
Every run declares a boundary — how many steps it may take, how long it may run, which tools it may call, and whether it may write. The check happens before the call fires, in one atomic round-trip, from a control plane that is not the agent process it governs.
- Zero runtime dependencies. One import, from Node's built-in
crypto. - Your prompt text never leaves your process. It is hashed locally; only the hash is transmitted. See what leaves your process below.
- Fails open, never silently. A control-plane failure allows the step and says so, in a field you can count.
npm install @thsky-21/thskyshieldThree calls. That's the integration.
import { Thskyshield, ShieldKilledError } from "@thsky-21/thskyshield";
const shield = new Thskyshield({ apiKey: process.env.THSKYSHIELD_KEY! });
// 1. Declare the boundary for this run.
const run = await shield.beginRun({
iterationLimit: 50,
timeoutSeconds: 300,
loopThreshold: 5, // identical prompt N times → killed_loop
allowedTools: ["search", "read_file"],
maxMutationClass: "reversible", // may write, may not destroy
});
try {
while (working) {
// 2. The check — before the model call or tool call executes.
const { requestId } = await run.beforeStep({
stepType: "llm",
model: "gpt-4o",
promptInput: messages, // hashed here; the text is not sent
});
const result = await callTheModel(messages);
// 3. Close the step.
await run.afterStep({
requestId,
outputTokenCount: result.usage.output_tokens,
model: "gpt-4o",
});
}
await run.end();
} catch (err) {
if (err instanceof ShieldKilledError) {
// err.reason is one of the five below. The step did NOT execute.
console.error(`run stopped: ${err.reason} (run ${err.runId})`);
} else {
throw err;
}
}beforeStep throwing ShieldKilledError is the whole product: the step it was called for did not run.
The five boundaries
They divide into two classes, and the distinction is not cosmetic — it decides how much turning enforcement on can change.
Containment — refuses an action that had not happened yet. A kill here destroys nothing.
| Reason | Trips when |
|---|---|
| killed_scope_tool | the step calls a tool that is not in the run's allowedTools |
| killed_scope_mutation | the step's mutationClass exceeds the run's maxMutationClass |
| killed_loop | the same prompt fingerprint repeats loopThreshold times |
Resource — exhaustion. A kill here can end work that might have succeeded, so it requires a second, explicit opt-in on the project (enforce_resource_gates) beyond simply enabling enforce mode.
| Reason | Trips when |
|---|---|
| killed_iterations | the step count would exceed iterationLimit |
| killed_timeout | wall clock exceeds timeoutSeconds |
Gates are evaluated in that order — all three containment gates before both resource gates — so a run that trips two at once reports the categorical reason rather than the exhaustion one.
New projects start in observe mode, where every gate is evaluated and recorded but nothing is stopped. You see what would have happened before anything acts on your behalf.
Fail-open is never silent
If the control plane cannot be reached, the step is allowed. That is the only acceptable default: a governance vendor must not be able to take your agent down.
But an allow that nobody decided is not the same as an allow, so every result says which it was:
const step = await run.beforeStep({ stepType: "llm", model: "gpt-4o" });
step.degradedReason;
// null → a real verdict from the control plane
// 'begin_run_failed' → beginRun never reached us; this run is ungoverned
// 'transport_error' → the step could not reach us
// 'timeout' → we did not answer in time
// 'auth_unresolved' → your key could not be resolved
run.degradedStepCount; // how many steps in this run ran ungoverned
run.degradedStepCounts; // the same, broken down by reasonallowed: true with a populated degradedReason is the valid fail-open shape. allowed: true with null is a governed allow. There is no third shape — if you cannot tell a customer how many of their steps ran ungoverned, the record is not an audit log.
What this package transmits
The complete list. It is the same list published at thskyshield.com/data, and a test in the control-plane repo fails if the two ever disagree.
Leaves your process
| Field | Type | Why |
|---|---|---|
| prompt_fingerprint | string, 64-char hex | SHA-256 of your prompt, computed in your process. Loop detection. |
| input_token_count / output_token_count | integers, optional | Token counts you report, before and after the call. Telemetry — no gate reads them. |
| model | string | The model id you pass, e.g. "gpt-4o". Recorded against the step; no gate reads it. |
| tool_name | string | The tool a step calls. Checked against the allowlist. |
| mutation_class | read_only | reversible | destructive | How destructive a step is. Checked against the ceiling. |
| step_type | llm | tool | custom | What kind of step this is. |
| is_retry / completion_status | boolean / enum | Whether a step is a retry, and how it finished. |
| step cap, timeout, loop threshold | numbers | The boundary you declare — your own numbers, back to you. |
| allowed_tools / max_mutation_class / strict_scope | string[] / enum / boolean | The tool allowlist and write ceiling you declare. |
| run id | string | Issued at beginRun, in the URL of every later call for that run. |
| agent_name / task_type / session_id / environment | strings, optional | Labels you choose. Capped at 128 chars. |
| request_id | string | Correlates afterStep back to the beforeStep it settles. |
| metadata | flat key-value | Passed through and stored verbatim — we do not inspect it. Validated to flat string/number/boolean (max 32 keys, 256 chars per value) and rejected before transmission if it isn't. |
Never leaves your process
| | |
|---|---|
| Prompt text | Only the fingerprint is sent. The text it came from never is. |
| Completion text | afterStep sends counts and a status. Never content. |
| Tool arguments | tool_name is sent. What you called it with is not. |
| Tool results | Not sent at any point in the run lifecycle. |
| Retrieved documents | No field carries document or context content. |
| System prompts | No field carries system-prompt content. |
| Your LLM provider's API keys | This package does not proxy your model calls, so it never sees them. |
Your Thskyshield key does leave your process, in the Authorization header — it has to, to authenticate, the same as any API you call.
Verify it yourself. That is the point of this repo being readable: grep -n "JSON.stringify" index.ts shows you every request body this package constructs.
The fingerprint is byte-pinned
_fingerprint in index.ts carries a canonical serialization spec as a comment, and it is load-bearing rather than decorative. Loop detection compares hashes across steps, so a second implementation — a Python port, or your own — must produce byte-identical output for the same input or loop detection silently stops working between languages. The spec pins key ordering, separators, number formatting and encoding for exactly that reason. Do not "clean it up" into a plain JSON.stringify.
API
new Thskyshield({ apiKey, baseUrl?, timeoutMs? })
run = await shield.beginRun({
iterationLimit?, timeoutSeconds?, loopThreshold?,
allowedTools?, maxMutationClass?, strictScope?,
agentName?, taskType?, sessionId?, environment?, metadata?, externalRunId?,
})
await run.beforeStep({ stepType, model?, promptInput?,
toolName?, mutationClass?, inputTokenCount?, isRetry? })
await run.afterStep({ requestId,
outputTokenCount?, completionStatus? })
await run.end()
await run.getStatus()baseUrl defaults to the canonical terminal host, which takes zero redirects. If you point it somewhere that redirects, the package warns you at construction and follows exactly one hop — it will not silently rewrite your URL, because re-pointing your proxy (and your API key) at our host is not this package's decision to make.
Links
- Control plane / dashboard — thskyshield.com
- Docs — thskyshield.com/agents/docs
- What leaves your process — thskyshield.com/data
- Try it with no signup —
npx @thsky-21/thskyshield-demo
License
Apache-2.0. See LICENSE.
