Tutorial

How to Give Your LangChain.js Agent a Security Sensor

RobertUpdated May 11, 20268 min read
Dark terminal-style feature image. White text reads: Same sensor. Now in TypeScript. Below it: checkVulnerability in teal monospace. Attestd branding bottom left.

How to Give Your LangChain.js Agent a Security Sensor#

LangChain.js agents are increasingly making real deployment decisions. They resolve dependencies, recommend package versions, build pipelines, and approve infrastructure changes. Most of them do this with no access to vulnerability or supply chain data.

A CVE database check is not something an LLM can do reliably from training data. An axios@0.19.0 might be safe or it might carry a known exploit. A @bitwarden/cli@2026.4.0 might look current but was a malicious publish on npm in April 2026, live for 90 minutes, with no CVE attached. The LLM cannot know either of these things without calling out to a structured data source.

This tutorial builds a LangChain.js tool() that wraps the Attestd API and gives any agent deterministic answers to those questions.


What you will need#

bash
npm install @attestd/sdk @langchain/core @langchain/openai langchain zod

An Attestd API key from api.attestd.io/portal/login. Free tier, 1,000 calls a month.


What the tool returns#

The Attestd API returns a deterministic riskState — one of critical, high, elevated, low, or none — derived from NVD CVE data and the CISA KEV catalog. For PyPI and npm packages it also returns a supplyChain object indicating whether the version was a known malicious publish.

Two signals, independent of each other. A package can have riskState: "none" and supplyChain.compromised: true at the same time. @bitwarden/cli@2026.4.0 is exactly that case:

json
{
  "product": "@bitwarden/cli",
  "version": "2026.4.0",
  "riskState": "none",
  "supplyChain": {
    "compromised": true,
    "sources": ["registry", "osv"],
    "malwareType": "backdoor",
    "description": "TeamPCP supply chain attack via compromised GitHub Actions CI/CD pipeline. Credential stealer targets SSH keys, cloud credentials, Claude Code auth tokens, and MCP configs.",
    "compromisedAt": "2026-04-22T17:57:00Z",
    "removedAt": "2026-04-22T19:30:00Z"
  }
}

No CVEs. Still malicious. A riskState check alone would have passed it.


Pattern 1 — Building the tool#

Instantiate Client once at module level. Never instantiate inside the tool function on every call.

typescript
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { Client, AttestdUnsupportedProductError } from '@attestd/sdk';
 
const _client = new Client({ apiKey: process.env.ATTESTD_API_KEY! });
 
export const checkVulnerability = tool(
  async ({ product, version }) => {
    try {
      const result = await _client.check(product, version);
      return {
        outsideCoverage: false,
        riskState: result.riskState,
        activelyExploited: result.activelyExploited,
        patchAvailable: result.patchAvailable,
        fixedVersion: result.fixedVersion,
        supplyChainCompromised: result.supplyChain?.compromised ?? false,
      };
    } catch (err) {
      if (err instanceof AttestdUnsupportedProductError) {
        return {
          outsideCoverage: true,
          riskState: null,
          message: `No Attestd coverage for '${product}'. Treat as unknown risk.`,
        };
      }
      throw err;
    }
  },
  {
    name: 'check_package_vulnerability',
    description:
      'Check whether a software package version has known CVE vulnerabilities ' +
      'or supply chain compromise. Use before deploying or recommending any ' +
      'software dependency. outsideCoverage=true means Attestd has no data — ' +
      'treat as unknown risk, not safe. ' +
      'Input: product slug (e.g. "nginx", "runc", "@bitwarden/cli") and exact version string.',
    schema: z.object({
      product: z.string().describe('Package slug, e.g. "nginx", "runc", "@bitwarden/cli"'),
      version: z.string().describe('Exact version string, e.g. "1.0.0"'),
    }),
  },
);

A few things worth noting here.

AttestdUnsupportedProductError must be caught. An agent that throws on an unsupported product is not production-ready. The outsideCoverage: true return gives the agent a signal it can branch on: no data from Attestd is unknown risk, not a clean bill of health.

supplyChain is null for infrastructure products like nginx and runc. Those products are not distributed via a package registry, so there is no supply chain signal. The tool returns supplyChainCompromised: false in that case, which is correct: the story for those products is CVE riskState, not supply chain.

The tool description determines when the LLM calls this tool. The phrase "treat as unknown risk, not safe" is there deliberately to prevent the model from treating an unsupported product as clean by default.


Pattern 2 — Running an agent#

Here is a complete agent that takes a dependency list and returns a go/no-go decision with reasoning.

typescript
import { ChatOpenAI } from '@langchain/openai';
import { createToolCallingAgent, AgentExecutor } from 'langchain/agents';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { checkVulnerability } from './attestd-tool.js';
 
const llm = new ChatOpenAI({ model: 'gpt-4o-mini', temperature: 0 });
 
const prompt = ChatPromptTemplate.fromMessages([
  [
    'system',
    'You are a security-aware deployment assistant. ' +
    'Before approving any software dependency, check its vulnerability status ' +
    'using the check_package_vulnerability tool. ' +
    'Block deployment if riskState is "critical" or "high", or if ' +
    'supplyChainCompromised is true. ' +
    'If outsideCoverage is true, state that explicitly and do not treat it as safe.',
  ],
  ['human', '{input}'],
  ['placeholder', '{agent_scratchpad}'],
]);
 
const agent = createToolCallingAgent({ llm, tools: [checkVulnerability], prompt });
const executor = new AgentExecutor({ agent, tools: [checkVulnerability] });
 
const response = await executor.invoke({
  input: 'Is it safe to deploy with runc 1.0.0 and @bitwarden/cli 2026.4.0?',
});
console.log(response.output);

With the two packages in this example, here is what the agent encounters:

runc 1.0.0 returns riskState: "high", activelyExploited: true, patchAvailable: true, fixedVersion: "1.0.0-rc95 or later". Three CVEs including CVE-2024-21626, a container escape on the CISA KEV list.

@bitwarden/cli 2026.4.0 returns riskState: "none" and supplyChainCompromised: true. The CVE check passes. The supply chain check blocks it.

A well-prompted agent will block on both and explain why:

text
runc 1.0.0: BLOCKED — riskState high, remote exploitable, CVE-2024-21626 on CISA KEV.
  Upgrade to 1.0.0-rc95 or later.
 
@bitwarden/cli 2026.4.0: BLOCKED — supply chain compromised (backdoor, TeamPCP attack April 2026).
 
Deployment not approved.

Use createToolCallingAgent, not the legacy createReactAgent with a hub prompt. The tool-calling pattern gives more reliable structured output.


Pattern 3 — Async#

The JS Client is natively async. Every client.check() call returns a Promise. There is no separate async client class. The tool function above is already async and works correctly in any async context, including server frameworks and edge runtimes. Use await executor.invoke({...}) as shown.


Full runnable example#

typescript
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { ChatOpenAI } from '@langchain/openai';
import { createToolCallingAgent, AgentExecutor } from 'langchain/agents';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { Client, AttestdUnsupportedProductError } from '@attestd/sdk';
 
const _client = new Client({ apiKey: process.env.ATTESTD_API_KEY! });
 
const checkVulnerability = tool(
  async ({ product, version }) => {
    try {
      const result = await _client.check(product, version);
      return {
        outsideCoverage: false,
        riskState: result.riskState,
        activelyExploited: result.activelyExploited,
        patchAvailable: result.patchAvailable,
        fixedVersion: result.fixedVersion,
        supplyChainCompromised: result.supplyChain?.compromised ?? false,
      };
    } catch (err) {
      if (err instanceof AttestdUnsupportedProductError) {
        return {
          outsideCoverage: true,
          riskState: null,
          message: `No Attestd coverage for '${product}'. Treat as unknown risk.`,
        };
      }
      throw err;
    }
  },
  {
    name: 'check_package_vulnerability',
    description:
      'Check whether a software package version has known CVE vulnerabilities ' +
      'or supply chain compromise. Use before deploying or recommending any ' +
      'software dependency. outsideCoverage=true means unknown risk, not safe. ' +
      'Input: product slug and exact version string.',
    schema: z.object({
      product: z.string().describe('Package slug, e.g. "nginx", "runc", "@bitwarden/cli"'),
      version: z.string().describe('Exact version string'),
    }),
  },
);
 
const llm = new ChatOpenAI({
  model: 'gpt-4o-mini',
  temperature: 0,
  apiKey: process.env.OPENAI_API_KEY,
});
 
const prompt = ChatPromptTemplate.fromMessages([
  [
    'system',
    'You are a security-aware deployment assistant. ' +
    'Check each dependency with check_package_vulnerability before approving. ' +
    'Block if riskState is critical or high, or if supplyChainCompromised is true. ' +
    'State outsideCoverage results explicitly. Do not treat them as safe.',
  ],
  ['human', '{input}'],
  ['placeholder', '{agent_scratchpad}'],
]);
 
const agent = createToolCallingAgent({ llm, tools: [checkVulnerability], prompt });
const executor = new AgentExecutor({ agent, tools: [checkVulnerability] });
 
const dependencies = [
  ['runc', '1.0.0'],
  ['litellm', '1.82.7'],
  ['@bitwarden/cli', '2026.4.0'],
  ['nginx', '1.27.4'],
];
 
const query =
  'Check these dependencies: ' +
  dependencies.map(([p, v]) => `${p}@${v}`).join(', ');
 
const response = await executor.invoke({ input: query });
console.log(response.output);

Expected output for these four packages:

text
runc 1.0.0: BLOCKED — riskState high, remote exploitable without authentication,
  CVEs include CVE-2024-21626 (CISA KEV). Upgrade to 1.0.0-rc95 or later.
 
litellm 1.82.7: BLOCKED — supply chain compromised. TeamPCP backdoor,
  active March 24 2026. riskState is none but supply chain check fails.
 
@bitwarden/cli 2026.4.0: BLOCKED — supply chain compromised. TeamPCP attack
  via CI/CD pipeline, active April 22 2026. riskState is none but supply chain check fails.
 
nginx 1.27.4: APPROVED — riskState none, no supply chain signal.

Return field reference#

FieldWhat it means for your agent
outsideCoveragetrue means no data. Not safe, unknown.
riskStateCVE severity. critical or high should block by default.
activelyExploitedIn the CISA KEV catalog. Treat as highest urgency.
patchAvailable / fixedVersionUpgrade path when a fix exists.
supplyChainCompromisedKnown malicious publish on PyPI or npm. Block if true, independent of riskState. false for infrastructure products with no registry signal.

Full reference at attestd.io/docs/integrations/langchain-js.

Get an API key at api.attestd.io/portal/login. Free tier, 1,000 calls a month, no credit card required.