Lint baseline
Security skills for agentic SaaS apps
npx -y skills add Adversis/skills --skill lint-baselineAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Audits a repository against the Adversis v0 security baseline for agentic SaaS applications. Produces a pass/gap/unknown verdict per baseline item plus framework-specific findings for FastAPI, Next.js, Supabase, and MCP server integration patterns. Use when you want to check an agentic app codebase before a security review.
SKILL.md
36.0 KB, as published. Nobody here has run it
lint-baseline v0
You are conducting a security audit of this codebase against the
Adversis v0 baseline. The baseline is defined at docs/v0-baseline.md
in this repo (or at adversis.io/baseline when published).
Before you begin
Ask: "One-shot report or interactive walkthrough?"
- One-shot: run all checks, produce the full report, no questions
- Interactive: work item by item; ask one targeted question before issuing a verdict on items with behavioral components (2, 4, 7, 8); allow discussion before moving to the next item
Then proceed immediately to stack detection without waiting for further input.
Step 1: Stack detection
Read these files to identify which stack components are present:
package.jsonand/orpackage-lock.jsonrequirements.txt,pyproject.toml, oruv.lockdocker-compose.ymland/orDockerfile.env.example(if present)- Directory structure: presence of
app/,src/,pages/,app/(Next.js app dir)
Grep for these library patterns to confirm:
- FastAPI:
from fastapiorfastapiin requirements - SQLAlchemy:
sqlalchemyin requirements - Next.js:
"next"in package.json dependencies - Vercel AI SDK:
"ai"in package.json dependencies - Supabase:
"@supabase/supabase-js"in package.json - MCP servers:
mcp.json,.mcp.json,@modelcontextprotocol/sdk, ormcpin requirements - Langfuse:
langfusein requirements or package.json - LangSmith:
langsmithin requirements - Arize Phoenix:
arize-phoenixoropeninferencein requirements - Helicone:
heliconein package.json - LangGraph:
langgraphin requirements - LangChain:
langchainin requirements - nginx:
nginx.confordefault.confpresent in repo - Agent config files:
- Claude Code:
.claude/settings.jsonor.claude/settings.local.json - Cursor / generic MCP:
.mcp.jsonor.cursor/mcp.json - Windsurf:
.windsurf/mcp_config.json - Codex:
.codex/config.toml - Aider:
.aider.conf.ymlor.aider.conf.yaml
- Claude Code:
Record what you found. Only run checks relevant to the detected stack. Skip any check for a stack component not present.
State your findings:
Stack detected:
Backend: [FastAPI / other / none]
Database: [PostgreSQL/Supabase / other / none]
Frontend: [Next.js / other / none]
AI SDK: [Vercel AI SDK / LangChain / LangGraph / other]
MCP: [yes / no]
Observability: [Langfuse / LangSmith / Phoenix / Helicone / none]
Proxy: [nginx config present / not detected]
Agent config: [Claude Code / Cursor / Windsurf / Codex / Aider / none]
Suppression rules — do not flag
Apply these rules during discovery (pass 1) before promoting a candidate to validation. These represent patterns the framework handles automatically.
FastAPI / SQLAlchemy:
- SQLAlchemy ORM queries (
.filter(,.filter_by(,.all(),.first()) are not flagged for SQL injection unless.execute(with string interpolation,text()with unsafely bound params, or.from_statement(is used. Additionally flagorder_by(),group_by(), orliteral_column()called with a user-controlled string — these emit raw SQL and are injection vectors. os.environ.get(,settings.X,config.Xvalues are server-controlled — do not flag as attacker-controlled inputs for SSRF or injection purposes.- Test files (
tests/,conftest.py,*_test.py,test_*.py) and fixture files are not in scope. Note them briefly as excluded.
React / Next.js:
- React JSX
{variable}expressions are auto-escaped — do not flag for XSS unlessdangerouslySetInnerHTMLor.innerHTMLis used. process.env.NEXT_PUBLIC_*values in client components: expected behavior. Flag only when the value matches a secret key pattern.params,searchParamsin server components are not auto-sanitized but are also not auto-rendered — flag only when the value reaches a render primitive or DB query without validation.
Supabase:
- The Supabase anon key (starts with
eyJ) and new publishable key (sb_publishable_*) are safe to expose client-side — not credential gaps. Flag the legacy service role JWT (roleservice_role) and new secret key (sb_secret_*). - RLS is effective when tables have
ENABLE ROW LEVEL SECURITYAND at least one policy. Flag absence of either.
Committed test and example files:
- Any secret-shaped string in a file path containing
test,fixture,example,mock,stub,seed,fake, ordummy— note in the report as excluded, do not flag as a finding. .env.examplefiles with clearly placeholder values (REPLACE_ME,your-key-here,xxx,<your-key>) — not a finding.
Step 2: Baseline audit
Before beginning: Read docs/lint-baseline-patterns.md. The credential
patterns, non-credential patterns, deprecated library list, CVE findings,
and complementary tools table in that file are authoritative for Steps 2–5.
Use them in place of any inline data below.
Work through all 8 items. For each item:
- Discovery: search for the patterns described. Cast wide — flag anything that could be a gap.
- Validation: for each candidate, re-examine it with a skeptical framing. Can you trace a concrete path from a network entry point (route handler, RPC method, message queue consumer) to the gap in 5 hops or fewer? If not, downgrade to OBSERVATION rather than GAP. If the path crosses an auth or tenant-scope check, note it.
- Issue verdict: PASS / GAP / PASS · behavioral eval required / UNKNOWN.
In interactive mode: before issuing a verdict on items 2, 4, 7, 8, ask one targeted clarifying question if deployment context would change the verdict.
Item 1: Tenant-scoped DB queries
Patterns to search (discovery):
FastAPI / SQLAlchemy:
session.get(ModelClass,— any call whereModelClassis a tenant-scoped model (has atenant_idcolumn) and no predicate follows.filter_by(id=or.filter(ModelClass.id ==withouttenant_idin the same querycursor.execute(with string interpolation (not parameterized)- Alembic migration files that
INSERTorUPDATEtenant-scoped tables without settingapp.current_tenant - Celery tasks that query tenant-scoped models without a
SET LOCAL app.current_tenantstatement
Next.js / Supabase:
createClientcalled with a variable that could be the service role key (grep forSERVICE_ROLE,service_role)- Supabase queries using
.eq('id', id)without.eq('tenant_id', )or without RLS confirmed enabled - Check migrations: tables created without
ALTER TABLE ... ENABLE ROW LEVEL SECURITYfollowing theCREATE TABLE
Validation for each candidate: Can you reach this query from an authenticated HTTP request without providing the tenant ID from the session? If yes: GAP. If the query is in a clearly admin-only file or behind an explicit admin guard: OBSERVATION (note the file and the guard, flag if the guard looks weak).
False positives:
- Admin endpoints explicitly scoped to superuser/admin roles are not a gap for this item — note them and move on
- Test files and fixtures
Verdict states for this item:
PASS: all tenant-scoped model queries provably scope by a session-derived tenant ID; RLS enabled on all multi-tenant tables in migrations;service_rolekey appears only in files flagged as admin-onlyGAP: found a query without tenant predicate reachable from a non-admin authenticated pathUNKNOWN: cannot determine which models are tenant-scoped (unusual schema structure)
Item 2: Sanitizer on every LLM-output render path
Patterns to search (discovery):
Next.js / React:
- Import of
react-markdown,marked,markdown-it,remark, orrehype— note whether a sanitizer (rehype-sanitize,DOMPurify) is imported in the same file dangerouslySetInnerHTMLapplied to any variable that could be LLM output (variable names:response,content,message,output,completion,text,result)<ReactMarkdown>components without arehypePluginsprop
Python / FastAPI / Jinja:
| safefilter applied to a variable tracing to LLM outputMarkup(applied to a variable not from a known-safe sourcemarkdown(ormistune.html(withoutbleach.clean(in scope
MCP:
- Tool result strings passed directly to the host without sanitization
Validation for each candidate:
Is the variable receiving the unsafe treatment actually sourced from
LLM output (check the call stack — does it come from an openai,
anthropic, ai, or similar SDK call)? If yes and no sanitizer
is present: GAP.
In interactive mode: ask "Is markdown rendering of LLM responses
intentional? If yes, is there a documented CSP img-src allowlist?"
before issuing behavioral eval status.
Verdict states for this item:
PASS · behavioral eval required: every path from model response to render passes through an active sanitizer (DOMPurify, rehype-sanitize, or current equivalent — check Deprecated libraries indocs/lint-baseline-patterns.mdbefore accepting bleach as passing); nodangerouslySetInnerHTML,|safe, or{@html}applied to LLM output without an explicit sanitizer call upstream; reference-style markdown and CSP correctness require behavioral verificationGAP: LLM output reaches a render primitive without a sanitizerUNKNOWN: cannot trace LLM output to render paths (complex component graph)
Item 3: Secrets in vault or env — not database columns
Patterns to search (discovery):
ORM models (SQLAlchemy, Prisma, Drizzle, Django models):
- Column names matching
*_token,*_secret,*_api_key,*_access_key,*_refresh_token,*_private_key - Any
String/Textcolumn without an explicit encryption decorator or comment
Source files (all languages):
- Grep for each pattern in the Credential-shaped patterns table in
docs/lint-baseline-patterns.md. Scope: all files not intests/,fixtures/,.env.example,README.md.
Configuration files:
mcp.json,.mcp.json,claude_desktop_config.json— check for hardcoded credential values (not env var references)
Validation for each candidate:
Is the column or variable actually storing credentials at runtime, or
is it a schema field that happens to be named token? Is the
credential shape in source a real value or a placeholder? Flag only
values that look live (not REPLACE_ME, your-key-here, xxx).
False positives:
- Test and fixture files (see suppression rules above)
- All patterns in the Non-credential patterns table in
docs/lint-baseline-patterns.md— suppress these; do not flag
Verdict states for this item:
PASS: no ORM column named*_token,*_secret,*_key, or*_api_keywithout an encryption decorator; no credential-shaped strings fromdocs/lint-baseline-patterns.mdfound in source outside test fixtures; credentials read from a secrets manager client or environment variable, not from a DB query resultGAP: live credential found in source or DB column without encryption
Item 4: Typed messages with provenance tags
Patterns to search (discovery):
Python / LangGraph / LangChain:
- f-string prompt construction that includes retrieval variables:
f"...{doc}...",f"...{context}...",f"...{retrieved}...",f"...{result}...",f"...{email}...",f"...{ticket}..." - String concatenation building a prompt:
prompt = system + "\n" + user_input + "\n" + retrieved_text PromptTemplatewith variables that could be retriever-filled
Vercel AI SDK / TypeScript:
messagesarray built by appending retrieval results to asystemoruserrole without a trust delimitersystemPromptvariable that concatenates trusted instructions with untrusted content using template literals
LangChain specifically:
RetrievalQA,ConversationalRetrievalChain— check whether retrieved documents are distinguished from user instructions; see Deprecated libraries indocs/lint-baseline-patterns.mdfor current status and recommended replacement
Validation for each candidate: Is the concatenated variable actually sourced from an untrusted source (retrieval, tool result, web content, user-uploaded file)? If it's all from a single trusted source (pure user message + system instruction with no retrieval), not a gap.
In interactive mode: ask "Is your retrieval source internal-only (your own vector store with controlled content), or can it include user-uploaded documents, email content, or web-fetched material?" A fully internal-only source lowers the injection surface; user uploads or web content make this item's PASS condition critical.
Verdict states for this item:
PASS · behavioral eval required: LLM calls use typed message objects with distinct sources; no f-string or+concatenation of trusted instructions with retrieval results; untrusted content wrapped in unambiguous delimiters; behavioral verification (promptfoo indirect injection tests) required to confirm the model honors the separation under adversarial promptsGAP: f-string or concatenation mixing trusted instructions with retrieval results, with no provenance delimiterUNKNOWN: LLM call structure too abstracted to trace (wrapped in a framework or SDK not recognized)
Item 5: All uses of dangerouslySetInnerHTML, |safe, and raw markdown renderers are documented and sanitizer-covered
Patterns to search (discovery — grep the entire codebase):
dangerouslySetInnerHTML(React) — any usagebypassSecurityTrustHtml(Angular) — any usagev-html=(Vue) — any usage{@html(Svelte) — any usage| safe(Jinja2/Django templates) — any usageMarkup((Python MarkupSafe) — any usage applied to a non-constantmark_safe((Django) — any usagemarkdown(,mistune.html(,markdown2.markdown((Python) — import without a sanitizer in the same file (check Deprecated libraries indocs/lint-baseline-patterns.mdfor current sanitizer status and migration recommendation).innerHTML =(vanilla JS) — any dynamic assignmentinsertAdjacentHTML((vanilla JS) — any dynamic usagedocument.write(— any usagehref=,src=,formAction=(React props) — flag when value is user-controlled; JSX escaping does not protect againstjavascript:URI injectionallowDangerousHtml(MDX or markdown renderers) — any usage
Validation for each candidate: Is this in a path that could receive LLM output, user input, or any other untrusted content now or in future? The bar is lower than other items — if the primitive exists without a sanitizer, it's worth flagging even if the current value is trusted, because the primitive becomes the hazard when the input changes.
False positives:
- Known-safe constant strings (
dangerouslySetInnerHTML={{ __html: '<br/>' }}) - Documented and audited allowlist entries with explicit comments
Verdict states for this item:
PASS: no forbidden primitives, or all occurrences are in an audited allowlist with explicit justificationGAP: forbidden primitive found without sanitizer and without documented justification
Item 6: Trace redaction
Patterns to search (discovery):
Observability SDK initialization (check only SDKs detected in stack):
- Langfuse:
Langfuse(orCallbackHandler(without amask=parameter - LangSmith:
Client(withouthide_inputs=/hide_outputs=parameters; or@traceablewithoutprocess_inputs=/process_outputs=parameters (these are separate APIs: Client-level vs. function-level masking) - Arize Phoenix:
px.launch_app(orOpenAIInstrumentor(without a span processor that filters credential-shaped attributes - Helicone: SDK init without a custom logger that redacts sensitive keys
- W&B Weave:
weave.init(without attribute filtering
Tool function returns:
- Functions named
*_tool, decorated with@tool, or in atools/directory that call external APIs — do they returnresponse.textorresponse.headersor the raw response object directly? - Error handlers in tools that include the full exception (which may contain a URL with an API key in query params)
Validation for each candidate: Is the observability SDK actually used in this codebase (did stack detection confirm it)? Is the tool return actually reaching the trace store, or is it handled elsewhere?
Verdict states for this item:
PASS: every detected observability SDK initialization includes a redaction callback; tool result handlers strip credential-shaped patterns before returning; no function named*_tokenor*_secretreturns its full input in error messagesGAP: observability SDK initialized without redactionUNKNOWN: observability SDK detected in requirements but no init call found in source (may be initialized in a config not in the repo)
Item 7: Caches keyed by (tenant_id, …)
Patterns to search (discovery):
Python:
@lru_cache,@cache,@cached,@cachetools.cached— what is the key function? Does it includetenant_idoruser_id?redis.set(,redis.setex(,r.set(— what is the key string? Does it include a tenant or user identifier?cache.set(key,— same check
Next.js:
unstable_cache(— is the cache key function or tag array including a user/tenant identifier?React.cache(— same check
Semantic cache libraries:
- Import of
gptcache, or vector store used as a cache (Pinecone, Weaviate, Qdrant with a similarity lookup) — is there a documented per-tenant namespace?
In interactive mode: ask "Do you use semantic caching for LLM responses? If yes, which library and how is the namespace scoped?"
Validation for each candidate: Is the cached value tenant-specific (i.e., different tenants should get different responses for the same query)? If the value is genuinely tenant-agnostic (e.g., a public lookup table, today's weather), no issue. If the value could differ by tenant, a key without tenant_id is a gap.
Verdict states for this item:
PASS · behavioral eval required: cache key derivation in all cache decorator usages includes a tenant or user identifier; no semantic cache library import without a documented per-tenant namespace strategy; adversarial collision testing required to fully verifyGAP: cache key derivation omits tenant/user identifier on a tenant-specific valueUNKNOWN: caching detected but key construction not traceable from source
Item 8: Egress allowlist on the agent worker
Patterns to search (discovery):
Tool definitions:
- Functions decorated with
@toolor in atools/directory that accept aurloruriparameter — is the parameter typed as barestror is it validated against an allowlist? requests.get(url,httpx.get(url,fetch(url— where isurldefined? Is it model-produced or from a constrained source?- Tools named
fetch_url,web_search,browse,http_request,get_url,visit_page— is there anALLOWED_HOSTSorALLOWED_DOMAINSconstant adjacent?
LangChain tools:
RequestsGetTool(,RequestsPostTool(— isallowed_hostsparameter set?
MCP server tool definitions:
- Tools with URL parameters — is there a host allowlist enforced?
Image rendering (client-side):
- Note that client-side
<img>rendering is itself an egress channel — item 8 and item 2 are co-dependent; passing one without the other does not block exfiltration
In interactive mode: ask "Is there a network-level egress allowlist on the agent worker (VPC, firewall, Kubernetes NetworkPolicy)? This is the structural enforcement that static analysis cannot verify."
Validation for each candidate:
Is the URL actually model-produced, or is it from a server-controlled
config? A requests.get(settings.WEBHOOK_URL) is not an egress issue
(server-controlled). A requests.get(tool_input.url) with no allowlist
is a gap.
Verdict states for this item:
PASS · behavioral eval required: URL-parameter tool definitions use typed/validated parameters or an adjacent allowlist constant rather thanurl: strwith no constraint; tools namedfetch_url,web_search,browse, orhttp_requesthave an explicitALLOWED_HOSTSconstant; LangChainRequestsGetToolor equivalent includesallowed_hosts; note that application-level allowlists are not sufficient — network-level enforcement requires a runtime probe to verifyGAP: URL-parameter tool found with no allowlist
Step 3: Produce the baseline report
After all 8 items are complete, output the report in this exact structure:
lint-baseline v0 · [repo name or current directory]
Stack detected: [list detected components]
── Baseline ─────────────────────────────────────────────────────
1 Tenant-scoped DB queries [verdict]
2 Sanitizer on LLM output [verdict]
3 Secrets in vault/env [verdict]
4 Typed provenance context [verdict]
5 No forbidden render primitives [verdict]
6 Trace redaction [verdict]
7 Tenant-keyed caches [verdict]
8 Egress allowlist [verdict]
─────────────────────────────────────────────────────────────────
Then for each item that is not a clean PASS, provide a detail block:
### Item N: [name]
**[VERDICT]**
Found: [specific file:line and the pattern]
Why it fails: [one sentence tracing to the pass criterion]
Remediation: [one concrete action — the minimum change to clear this item]
[For behavioral eval required items:]
Next step: Run [promptfoo / garak / egress probe] — see docs/v0-baseline.md
for the specific test to run.
For items with a clean PASS, no detail block needed — the summary line is sufficient.
After the baseline report, continue to Step 4 (framework depth layer). The framework findings appear in a separate section after the baseline summary — they are additional observations, not baseline verdict items.
Step 4: Framework depth layer
Run this after the 8-item baseline is complete. These checks produce a "Framework findings" section in the report. They are not rated against the baseline criteria — they are stack-specific observations.
Issue each finding as:
FINDING— confirmed gap with a traceable pathOBSERVATION— pattern worth examining; reachability or intent unclear without context
4A. Choke point and middleware audit
The question for every security-critical decision: is it made in one centralized place (making the secure path the default), or scattered per-route/per-query/per-render (making every new code that forgets it a silent gap)?
Authentication centralization:
FastAPI:
- Read the main app file and all router files. Is there a dependency
(
Depends(get_current_user)or equivalent) applied at the router-prefix level, or is it applied per-endpoint? - If per-endpoint: count how many endpoints have it. If any route handler in a file that handles user data lacks it, flag as FINDING.
- Any route that reads
request.headers.get("X-User-Id")orrequest.headers.get("X-Tenant-Id")and uses it for an auth or scoping decision without verifying it against a session: FINDING. Header values from clients are attacker-controlled.
Next.js:
- Read
middleware.tsormiddleware.js. What does thematcherpattern cover? List the route prefixes it DOES NOT cover. - Flag any Server Action that does not call
await getSession()orawait auth()(or the project's equivalent) within the first 5 lines: FINDING. Middleware does not protect Server Actions. - Any server component or route handler that reads
headers().get('x-user-id')or equivalent for auth: FINDING.
CVE checks (only for detected stack components):
Check package.json version values against the CVE findings table in
docs/lint-baseline-patterns.md. Apply the FINDING / OBSERVATION verdicts
defined there for each CVE entry.
Tenant scope source:
- Search for
tenant_idappearing in query parameter extraction:request.query_params.get("tenant_id"),params.tenant_id,searchParams.get("tenant_id"),req.body.tenant_id. - If tenant_id for a DB query comes from the request body or query params rather than from the session/JWT: FINDING (IDOR via trusted input — passing the wrong tenant_id bypasses the tenant scope even when the helper is used).
Input validation placement:
FastAPI:
- Route handlers that accept
body: dictorbody: Anyinstead of a typed Pydantic model: OBSERVATION (no schema enforcement at entry). - Handlers that call
request.json()directly and then pass the result to a DB operation: FINDING.
Next.js / Vercel AI SDK:
streamText(,generateText(,generateObject(calls where toolexecutefunctions receive parameters but no Zod schema is applied to the tool definition: OBSERVATION.
Output encoding centralization:
- If
rehype-sanitize,DOMPurify, orbleachappears in more than 2 distinct files without a shared wrapper function: OBSERVATION (each call site is an independent decision; future sites may omit the sanitizer). - Identify whether there is one canonical "render LLM output" component or helper. If multiple: list them and confirm each is sanitized.
4B. LLM blind spot coverage
These are vulnerability classes where AI-assisted review is documented to underperform. Apply explicit directed checks.
IDOR / BOLA across tenant boundary: For every route handler or server action that accepts an object ID (UUID, integer, slug) as a path or body parameter:
- Find the DB query that retrieves the object.
- Check whether the query includes a tenant or user predicate from the session — not from the request.
- If the query fetches by ID alone: FINDING.
Format: "Route
[METHOD] [path]fetches[Model]by id without tenant predicate (file:line). Authenticated attacker can access any [Model] by guessing or enumerating IDs."
Auth checks present but bypassable:
Next.js middleware matcher gaps:
- From the
matcherpattern in middleware.ts, list at minimum:/api/routes,/admin/routes, and any route prefixes in the app directory not covered by the matcher. - If any of these handle user data or mutations: FINDING.
Conditional bypass in auth logic:
- Search for
if not settings.PROD,if os.environ.get("ENV") != "production",if process.env.NODE_ENV === 'development'inside auth functions or decorators: FINDING (auth bypass in non-prod; often reaches staging). @csrf_exempton any POST endpoint that handles user data: FINDING (Django).
Read vs. write permission disparity:
- For any resource type (model, entity) with both GET and POST/PUT/
DELETE handlers: confirm the ownership or tenant check is present
AND equivalent in both. A
get_post(id, user_id=current_user.id)that has a matching write path using onlyidis IDOR-on-write. - This is most common in REST APIs where GET uses a queryset filter but POST/PUT accepts an ID in the body.
Header trust: Search for these patterns and flag each as FINDING:
request.headers.get("X-Forwarded-For")used for rate-limit, geo-restriction, or IP allowlist (attacker can spoof this header unless nginx is configured to overwrite it)request.headers.get("X-Real-IP")used for security decisions- Any
X-User-*,X-Tenant-*,X-Role-*,X-Admin-*header read for authorization without session verification headers().get(in Next.js server components used for auth scoping
Implicit trust in internal calls:
- Background tasks (Celery, BullMQ, FastAPI
BackgroundTasks) that accept auser_idortenant_idas a parameter and perform privileged operations: OBSERVATION (confirm the caller validates before passing). - Service-to-service HTTP calls that pass
X-User-IdorX-Tenant-Idheaders without signing: OBSERVATION (internal network trust assumption; flag if the service is exposed externally).
4C. Stack-specific integration patterns
nginx + app server path normalization (only if nginx config detected):
Read all nginx .conf files in the repo. Check:
- Does any
locationblock useproxy_passwith a trailing slash (e.g.,proxy_pass http://backend/)? If yes: note that nginx rewrites the location prefix — the app will see a different path than the nginx location matcher. Flag if the app has auth or routing logic based on path prefixes: OBSERVATION. - Is
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_forused (append, not overwrite)? If the app uses X-Forwarded-For for security decisions, this is controllable: FINDING. The safe form isproxy_set_header X-Forwarded-For $remote_addr. - Is
merge_slashes on(nginx default) ormerge_slashes off? If the app has auth middleware that matches on/admin/but nginx normalizes//admin/to/admin/, double-slash bypass is possible if the app re-processes the path before middleware runs: OBSERVATION.
Next.js Server Actions (only if Next.js detected):
- Find all Server Actions (functions with
"use server"directive at the top, or files with"use server"at the file level). - For each mutation Server Action (one that writes to DB, sends email,
transfers data): confirm
await getSession()or equivalent auth check appears before any data operation. - For each Server Action that accepts user input: confirm a Zod schema validates the input before it's used.
- Server Actions that call Supabase with the service role key: confirm the call site is admin-only. If reachable from a user-facing component tree: FINDING.
Vercel AI SDK tool definitions (only if Vercel AI SDK detected):
- Find all
tool(ortools: {definitions. - For each tool with an
executefunction:- Is there a Zod schema via
parameters(AI SDK ≤4) orinputSchema(AI SDK 5)? If neither: OBSERVATION. - Does the execute function call an external service? If yes, does it validate that the parameters match an allowlist before dispatching? If not and a URL parameter is involved: FINDING.
- Does the tool return data that includes credentials, internal endpoints, or PII? If yes, is there a redaction step before the result re-enters the model context? If not: FINDING (also triggers baseline item 6).
- Is there a Zod schema via
Supabase client context (only if Supabase detected):
Grep for all createClient( calls. For each:
- What is the second argument (the key)?
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEYin a client component: expected, fine.process.env.SUPABASE_SERVICE_ROLE_KEYanywhere: flag unless the call site is demonstrably server-only AND admin-only. Check: is this file a Server Action, API route, or server component with no client-accessible path? If uncertain: FINDING.process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY: FINDING (service role key in a public env var — exposed to every browser).
- MCP tools that initialize a Supabase client: what key are they using? Service role + MCP tool + agent = RLS bypass on any injection: FINDING.
4D. Agent configuration files
Only run if "Agent config" was detected during stack detection. Skip entirely if no agent config files are present.
Read all detected agent config files. Apply the three check groups below.
Only examine files that contain at least one of the keys hooks,
mcpServers, or permissions — skip files that contain none. Do not flag
patterns found in .env.example, README.md, or any file whose path
contains test, fixture, example, mock, stub, or seed.
Permission grants
Search in any file containing a permissions block:
- Any entry matching
"Bash(*)","WebFetch(*)","Read(*)","Write(*)","Edit(*)", or"Agent(*)"→ OBSERVATION (wildcard grants disable harness gating; flag so a reviewer can confirm these are not present in production config) "bypassPermissions": true,"dangerouslySkipPermissions": true,"approvalMode": "never","autoApprove": true, or"autoRun": true→ FINDING (safety prompts disabled)"allowUnsandboxedCommands": trueor"dangerouslyDisableSandbox": true→ FINDING (sandbox escape hatch open)"denyRead"key present in asandboxblock without a corresponding"Read(<path>)"entry in the deny permissions list → OBSERVATION (sandbox.denyRead only constrains Bash subprocesses; the Read tool bypasses it entirely, so the restriction is incomplete)
MCP server definitions
Search in any file containing a mcpServers block:
- A
"command"value set to"curl","wget","sh","bash","zsh", or"eval"→ FINDING (MCP server runs a shell or downloader as its process; executes on agent startup) - A
"command"value of"npx","uvx","bunx", or"pnpm"(dlx) where the arguments include anhttps://orgithub:URL → OBSERVATION (remote code fetched and executed at agent startup; flag for review of the source URL and whether pinning or a hash is used)
Hook commands
Search in any file containing a hooks block:
- Any hook command string containing
curl,wget,nc,ncat,netcat,scp, orrsync→ FINDING (hook performs network I/O; potential exfiltration channel) - Any hook command string referencing
~/.ssh/,~/.aws/credentials,~/.kube/,~/.gnupg/,~/.netrc, or.env→ FINDING (hook reads credential or secret files) - Any hook command string containing
$(env),$(printenv),$(set), or an environment variable interpolation matching*TOKEN*,*KEY*,*SECRET*,*PASSWORD*, or*CREDENTIAL*(e.g.$ANTHROPIC_API_KEY,$AWS_SECRET_ACCESS_KEY) → FINDING (hook captures secrets from the environment) - A hook command referencing
.claude/hooks/*.sh,.claude/hooks/*.py,.claude/hooks/*.js, or$CLAUDE_PROJECT_DIR/.claude/hooks/→ OBSERVATION (the hook is approved once at config commit time, but the referenced script can be mutated by any later commit without re-prompting — same trust model as a Makefile called from a hook)
Framework findings output format
After completing sections 4A, 4B, and 4C, output:
── Framework findings ────────────────────────────────────────────
[FINDING] [one-line description]
[FINDING] [one-line description]
[OBSERVATION] [one-line description]
─────────────────────────────────────────────────────────────────
Then for each FINDING, provide a detail block:
**FINDING: [description]**
File: [path:line]
What it means: [one sentence — what an attacker gains]
Fix: [one concrete change]
For OBSERVATIONs, a single line in the summary is sufficient unless you have a specific question to ask in interactive mode.
If no framework findings: output "── Framework findings: none ──".
Step 5: What this didn't cover
At the end of every report, output this section. Use the Complementary tools
table in docs/lint-baseline-patterns.md for correct tool names, attributions,
and invocations. Include only rows where "When to include" matches the detected
stack.
This didn't cover the following
────────────────────────────────────────────
lint-baseline audits the 8 agentic-app baseline items plus
framework-specific patterns for the detected stack. It does not
replace a general security review or a full appsec engagement.
Complementary tools worth running:
[list tools from docs/lint-baseline-patterns.md — Complementary tools section —
filtered by detected stack]