@countersig/policy-client
v0.1.1
Published
Countersig policy client — fetches signed policy bundles and enforces destination allow-lists for AI agents. Client-side enforcement only; for hard security boundaries use the Countersig Gateway.
Maintainers
Readme
@countersig/policy-client
Client-side policy enforcement for AI agents registered with Countersig. Fetches signed policy bundles from the Countersig backend and enforces destination allow-lists before outbound HTTP calls.
⚠️ Read this first
This is a client-side hint, not a security boundary.
Any code path that does not route through PolicyClient.fetch() is not enforced. A compromised or malicious agent that imports node-fetch (or any other HTTP client) directly will bypass this library entirely.
For real security boundaries — where enforcement cannot be bypassed by the calling code — deploy the Countersig Gateway at the network layer in front of your agents.
Use this SDK to:
- Catch honest mistakes (a developer hardcoding a destination they shouldn't)
- Get fast in-process decisions during development
- Emit policy-violation telemetry from agent runtimes
Do not use this SDK as the only enforcement mechanism for production deployments handling sensitive data.
Install
npm install @countersig/policy-clientRequires Node.js 18+.
Quick start
import { PolicyClient, PolicyDeniedError } from '@countersig/policy-client';
const client = new PolicyClient({
apiBase: 'https://api.countersig.com',
apiKey: process.env.COUNTERSIG_API_KEY!,
agentId: process.env.COUNTERSIG_AGENT_ID!,
});
await client.init();
try {
const res = await client.fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ /* ... */ }),
});
const data = await res.json();
} catch (err) {
if (err instanceof PolicyDeniedError) {
console.error(`Blocked: ${err.destination} (${err.decision.reason})`);
} else {
throw err;
}
}
// Cleanup on shutdown
process.on('SIGTERM', () => client.close());Configuration
new PolicyClient({
// Required
apiBase: 'https://api.countersig.com',
apiKey: '...', // JWT or API key with read scope
agentId: 'uuid-here',
// Optional — JWKS source (one of)
jwksUrl: '...', // Defaults to {apiBase}/.well-known/jwks.json
jwks: { keys: [...] }, // Inline key set (skips remote fetch entirely)
// Optional — refresh and timing
refreshIntervalMs: 0, // Defaults to 90% of bundle TTL
failClosed: true, // Default: deny all on stale bundle
clockSkewSeconds: 30, // Default: 30s tolerance on issued_at
maxRefreshAttempts: 0, // Default: 0 (unlimited, exp backoff capped at 5 min)
fetchImpl: fetch, // For tests
});Policy modes
The bundle's policy_mode (set by your org admin) determines behavior:
enforced— Default-deny. Calls to non-whitelisted destinations throwPolicyDeniedError.audit_only— Calls go through, but blocked-in-enforced-mode calls emit apolicy_violationevent withwouldBlock: true. Use this for safe rollout.permissive— Whitelist is ignored. Only the deny list blocks calls. Dev/staging only.
Events
The client extends EventEmitter. Listen for:
client.on('bundle_loaded', ({ bundle }) => {
console.log('policy mode:', bundle.policy_mode);
});
client.on('bundle_refresh_failed', ({ error, attempt }) => {
console.warn(`refresh attempt ${attempt} failed:`, error.message);
});
client.on('policy_violation', ({ destination, reason, wouldBlock }) => {
// wouldBlock=true means audit_only mode allowed it through
metricsClient.increment('policy.violation', { reason, wouldBlock });
});
client.on('signature_invalid', ({ reason }) => {
// Bundle signature did not verify. Treat as a critical alert.
alertClient.fire('countersig.signature_invalid', { reason });
});
client.on('stale_bundle', ({ expiredAt, failClosed }) => {
// Bundle expired and refresh hasn't recovered yet
});Decision API (no network)
If you make calls through your own HTTP layer, use check() to get a decision without making a request:
const decision = client.check('https://api.openai.com/v1/chat');
// { allowed: true, reason: 'whitelisted', scope: 'org', mode: 'enforced' }
if (!decision.allowed) {
// Handle deny case yourself
}Agent-to-agent calls
For Countersig-internal calls between two registered agents, use fetchAgent. The client requests a short-lived A2A token from the backend and attaches it as a Bearer token on the outbound call.
const res = await client.fetchAgent(
'agent:550e8400-e29b-41d4-a716-446655440000',
JSON.stringify({ task: 'process-this' }),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Required: where to actually dispatch the call
'x-countersig-target-url': 'https://other-agent.internal/api/dispatch',
},
}
);The target agent verifies the token using @countersig/verify against the JWKS endpoint.
Stale bundles and fail-closed mode
When failClosed: true (default):
- A stale bundle (past
expires_at) causes every call to deny withreason: 'stale_bundle_fail_closed'. - Background refresh attempts continue with exponential backoff (5s → 10s → 20s → ... capped at 5 min).
- A successful refresh recovers all functionality immediately.
When failClosed: false:
- A stale bundle is used until refreshed. Calls evaluate against the last-known policy.
- Use this in development if you want to keep working when the backend is unreachable.
Bundle verification
Every bundle is signed with Ed25519 (JWS Compact Serialization, alg: EdDSA). The client:
- Fetches JWKS from
{apiBase}/.well-known/jwks.json(cached 10 minutes) - Verifies the signature against the key with
kid: 'a2a-ed25519-1' - Compares signed payload identity fields against the response envelope (defense against signing one bundle and shipping a different one)
- Validates timing (
issued_atnot in the future,expires_atafterissued_at) - Validates
agent_idmatches the configured agent
Any failure throws and emits signature_invalid. The bundle is rejected.
Comparison with the gateway
| | @countersig/policy-client | Countersig Gateway |
|---|---|---|
| Where it runs | Inside the agent process | Network layer in front of the agent |
| Latency | Microseconds (in-memory) | Single round-trip per cold-cache call |
| Bypassable | Yes — agent can import fetch directly | No — gateway sees all egress |
| Enforces blocks on | Calls routed through this SDK | All HTTP egress |
| Setup | npm install + client.init() | Deploy proxy/sidecar |
For serious deployments, run both: SDK catches honest mistakes early, gateway provides the actual security boundary.
License
MIT
Links
- Documentation: https://github.com/RunTimeAdmin/Countersig-Public/blob/main/docs/POLICY_SDK.md
- Backend repo: https://github.com/RunTimeAdmin/Countersig-Public
- Gateway: https://github.com/RunTimeAdmin/countersig-gateway
- Issue tracker: https://github.com/RunTimeAdmin/Countersig-Public/issues
