Stateless signatures
Memory-independent. They catch blatant single-shot attacks, a bulk credential export or a destructive wipe, on call one with no history. Actions are canonicalized, so Exfiltrate, export_all and drop_table all match.
DOCUMENTATION
tack-guard is runtime risk scoring for AI agent tool calls: it scores every tool call 0 to 1 against a per-agent baseline and contains escalations inline, in under 1ms, with no LLM and zero dependencies. This page is the full reference: install, the API, the eight signals, and the four attack patterns.
tack-guard ships as a single zero-dependency package on npm. It is written in TypeScript, ships both ESM and CommonJS builds with bundled types, and runs on Node 18 or newer.
Create a guard once, then evaluate each tool call before you run it.
Every call returns a graded verdict; you skip the tool whenever
result.blocked is true and hand the
summary back to the model.
import { createGuard } from '@tacksec/guard'
const guard = createGuard()
// Inside your agent's tool-execution pipeline:
const result = guard.evaluate({
action: 'write',
target: 'user_db.credentials',
})
if (result.blocked) {
// Skip the tool call and return result.summary to the model.
} else {
// Run the tool.
} A blocked, escalating call returns a fully explained result:
{
verdict: 'quarantine',
score: 0.57,
severity: 'CRITICAL',
pattern: 'credential-creep',
signals: { escalation_trajectory: true, new_write_access: true, sensitive_write: true }, // plus the other 5 signals, all false
evidence: [
'access to sensitive target: user_db.credentials',
'trajectory: read-only -> mutate -> sensitive/admin over 3d',
],
summary: 'Agent escalated from read-only to sensitive access. Credential-creep detected.',
quarantined: true,
blocked: true,
} Two complementary layers run on every call. The score is the weighted sum of the signals that fired, clamped to 0 to 1: it grades with the evidence, it is not a hardcoded constant.
Memory-independent. They catch blatant single-shot attacks, a bulk credential export or a destructive wipe, on call one with no history. Actions are canonicalized, so Exfiltrate, export_all and drop_table all match.
Memory-dependent. It tracks a per-agent baseline and scores deviations. This is what catches slow credential-creep, read-only for days then a first write then a reach into sensitive data, where no single call is damning.
Each signal contributes its weight when it fires. Override any of
them with the weights config.
| Signal | Weight | What it catches |
|---|---|---|
escalation_trajectory | 0.25 | Read-only baseline violated, scope grew to sensitive or admin. |
sensitive_write | 0.22 | Access to a sensitive target (credentials, PII). |
sensitive_egress | 0.30 | Sensitive data then sent to an untrusted destination. Opt-in via egressTargets. |
volume_spike | 0.15 | Call volume at or above 3x the baseline. |
rapid_burst | 0.15 | Many calls in a short window (an automated burst). Temporal. |
new_write_access | 0.10 | First mutation after a read-only baseline. |
after_hours | 0.08 | Activity outside business hours. |
admin_attempt | 0.07 | Admin access, or an admin target touched. |
| Pattern | Severity | Shape |
|---|---|---|
credential-creep | CRITICAL | Slow escalation from read-only into sensitive or admin access. Reports the escalation time span. |
data-exfiltration | CRITICAL | Sensitive data accessed, then sent to a destination outside egressTargets. Opt-in. |
smash-and-grab | CRITICAL / HIGH | A loud single-shot signature hit, or a rapid automated burst. |
behavioral-drift | HIGH / MEDIUM | Other anomalies that push the score over the detection threshold. |
The one catch with { action, target }
is mapping your real tool calls onto it. tack-guard does that for
you, so synonyms and casing cannot slip past a literal string check.
Infer from a provider tool call. OpenAI and Anthropic
function-calling, the Vercel AI SDK, and MCP all emit
{ name, arguments }.
inferToolCall maps the tool name to an
action and the arguments to a target.
import { createGuard, inferToolCall } from '@tacksec/guard'
const guard = createGuard()
for (const call of response.tool_calls) {
const verdict = guard.evaluate(inferToolCall(call))
if (verdict.blocked) continue // Skip the tool, return the refusal to the model.
runTool(call)
} Or wrap a tool once and forget about it. A blocked call does
not run: it throws GuardBlockedError,
or calls your onBlocked handler.
const safeQuery = guard.wrapTool(db.query, {
action: 'read',
target: (sql) => (sql.includes('credentials') ? 'user_db.credentials' : 'db'),
})
await safeQuery('SELECT ...') // Throws GuardBlockedError if the guard blocks it.
Under the hood, canonicalizeAction
folds any verb or tool name into six classes: read, write, admin,
exfiltrate, destroy, and other. It is deliberately conservative, so
an email or upload agent is not quarantined for doing its job.
Creates a guard. Every option is optional; the defaults are safe for most agents.
const guard = createGuard({
sensitiveTargets: ['payments', 'api_keys', 'credentials'], // string[] or predicate
egressTargets: ['mycorp.com'], // opt-in allowlist of trusted destinations
businessHours: [9, 18], // [start, end), default [9, 18)
mode: 'warn', // 'warn' (default, advisory) | 'enforce' | 'score-only'
baselineMin: 5, // calls before a baseline is established
detectionThreshold: 0.5, // score below this is no detection
weights: { rapid_burst: 0.2 }, // partial override of signal weights
}) | Option | Type | Description |
|---|---|---|
sensitiveTargets | string[] | (target) => boolean | Targets treated as sensitive. Default: unambiguous secrets (credential, secret, password, api_key, apikey, private_key, token, ssn, sensitive), narrow on purpose; pass your own to widen. |
egressTargets | string[] | (dest) => boolean | Allowlist of trusted egress destinations. Opt-in: dormant if unset, so no egress false positives by default. |
businessHours | [number, number] | Business-hours range [start, end) for the after-hours signal. Default [9, 18). |
weights | Partial<Record<SignalName, number>> | Partial override of signal weights. Unspecified signals keep their default. |
baselineMin | number | Calls observed before a baseline is established. Default 5. |
detectionThreshold | number (0-1) | Score below this is treated as no detection. Default 0.5. |
mode | 'warn' | 'enforce' | 'score-only' | Verdict policy. Default 'warn' (advisory, observe before you block). |
Scores a single tool call synchronously and returns a
GuardResult. This is the hot path: no
model, no network.
const result = guard.evaluate({
action: 'update_row', // any string, canonicalized internally
target: 'user_db.accounts', // what it is touching
agentId: 'sales-bot', // optional, default 'default'
hour: 14, // optional, default current hour
at: 1718000000000, // optional epoch ms, default now
}) Input: the ToolCall fields.
| Field | Type | Description |
|---|---|---|
action | string | Required. What the agent is doing. Any string, canonicalized internally. |
target | string | Required. What it is touching, e.g. user_db.accounts. |
agentId | string | Optional. Agent identifier for multi-agent setups. Default 'default'. |
hour | number (0-23) | Optional. Hour of day for the after-hours signal. Default current hour. |
at | number | Optional. Event time as epoch ms for the temporal signals. Default now. |
metadata | Record<string, unknown> | Optional. Arbitrary metadata attached to the event. |
Returns: a GuardResult.
| Field | Type | Description |
|---|---|---|
verdict | 'allow' | 'warn' | 'deny' | 'quarantine' | Final decision for this call. |
score | number (0-1) | Threat confidence. Higher is more dangerous. |
severity | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | Severity classification. |
pattern | Pattern | Detected attack pattern, or 'none'. |
signals | Record<SignalName, boolean> | Which behavioral signals fired (may be all false on a pure signature hit). |
evidence | string[] | Human-readable lines explaining why the signals fired. |
summary | string | One-line summary of the verdict. |
quarantined | boolean | Whether the agent is currently contained (persists across calls). |
blocked | boolean | Whether this specific call was blocked. |
Same as evaluate(), but awaits an
asynchronous MemoryClient (Redis, a
network DB, a cloud store). Scoring stays synchronous and local; only
the memory I/O is awaited. evaluate()
throws on an async client rather than silently degrading.
Wraps a tool so every call is evaluated first; blocked calls do not
run. action and
target can be static or functions of
the args. It is synchronous, so a blocked call throws synchronously:
use try / catch, not
.catch(). Pass
onBlocked to handle a block without
throwing.
Maps a provider { name, arguments }
tool call to a ToolCall. The action
comes from the tool name (canonicalized), the target from the
arguments. Override either explicitly.
Folds any verb or tool name into one of six canonical classes: read, write, admin, exfiltrate, destroy, other. Case and synonym aware.
Persistent, cross-session baselines via one line. Not yet implemented: it throws until Tack Cloud ships. The local guard works fully standalone today.
reset(agentId?) clears state,
setMemory(client) plugs in a custom
backend, and getAgentState(agentId)
inspects an agent.
For advanced tuning, tack-guard exports
WEIGHTS,
BASELINE_MIN,
DETECTION_THRESHOLD and the
SignalName type, plus the
Verdict,
Severity,
Pattern,
ToolCall,
GuardResult,
GuardConfig and
MemoryClient types.
mode sets the verdict policy. Start in
score-only or
warn to observe your traffic, then
switch to enforce once the numbers look
right for you.
| Mode | Behavior |
|---|---|
warn | Default (advisory). Reports threats and returns a 'warn' verdict, but never blocks: observe first, then enforce. |
enforce | Quarantines on CRITICAL or a signature hit, and blocks risky calls while the agent is quarantined. |
score-only | Always allows. Returns the score and signals only, never blocks. |
The default in-memory client loses baselines on restart, by design.
Implement the four-method MemoryClient
interface to back it with your own store. Synchronous backends (an
in-process cache, better-sqlite3) use
evaluate(); async backends (Redis, a
network DB) use evaluateAsync().
import { createGuard, type MemoryClient } from '@tacksec/guard'
class RedisMemory implements MemoryClient {
async push(agentId, event) { /* ... */ }
async getEvents(agentId) { /* returns Promise<StoredEvent[]> */ }
async clear(agentId) { /* ... */ }
async clearAll() { /* ... */ }
}
const guard = createGuard()
guard.setMemory(new RedisMemory())
await guard.evaluateAsync({ action: 'write', target: 'users' })
Persistent, cross-session baselines that survive restarts are
Tack Cloud's job, exposed through
guard.connect().
Every result carries a verdict and a severity.
| Verdict | Meaning |
|---|---|
allow | Let the call through. |
warn | Let it through but flag it. The only non-blocking detection verdict. |
deny | Block this single call without quarantining. Reserved: currently every block path also quarantines. |
quarantine | The agent is contained. This and future risky calls are blocked. |
Severity is one of
LOW,
MEDIUM,
HIGH,
CRITICAL, scaled with the score.
tack-guard advises; your pipeline enforces. It returns a verdict, and you must actually skip the blocked tool call. It is a fast heuristic layer, not a complete defense. The honest limits, by design:
sensitiveTargets), so a
destructive op on an innocuously named table can be missed, and a
benign write to a user_db.* table can
over-flag. Pass a predicate for precision.