<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Application Security & Beyond]]></title><description><![CDATA[Application Security & Beyond]]></description><link>https://bokaung.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae24a6617790d5cc48d48c7/c93ff825-59c5-44b3-a9c1-00786f7c3371.png</url><title>Application Security &amp; Beyond</title><link>https://bokaung.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 31 Oct 2026 11:16:06 GMT</lastBuildDate><atom:link href="https://bokaung.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What Pair Coding Actually Looks Like.]]></title><description><![CDATA[This is not a tutorial written after the fact. It is a record of a pair-coding session: I asked Claude Code to show me how to scan SkillTriage with…]]></description><link>https://bokaung.hashnode.dev/paircoding-with-ai</link><guid isPermaLink="true">https://bokaung.hashnode.dev/paircoding-with-ai</guid><category><![CDATA[vibecoding]]></category><category><![CDATA[semgrep]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[bo]]></dc:creator><pubDate>Wed, 28 Oct 2026 19:51:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/abe85061-4e5d-496c-b818-0ba7df3a70f1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This is not a tutorial written after the fact. It is a record of a pair-coding session: I asked Claude Code to show me how to scan SkillTriage with Semgrep, and we worked through it together. The commands, outputs, exit codes, and screenshots below are from that session. The terminal images are rendered from the captured stdout, not re-typed by hand.</p>
<p>The ask was specific:</p>
<blockquote>
<p>I want to scan using Semgrep to scan my code in <code>~/projects/SkillTriage</code>. Show me how to do it step by step. I want to execute the command myself in a separate terminal.</p>
</blockquote>
<p>Then, after the first pass:</p>
<blockquote>
<p>I want to capture all the steps and screenshots as well so it will be helpful when I write a blog.</p>
</blockquote>
<p>That second message changed the job. A command list is easy. A reproducible record — with the failures left in — is the useful artifact.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/09ce89b2-e955-469f-876b-7ca988f83d72.png" alt="Rendered chat excerpt with the human’s scan request, the agent’s scoping rule, the request for screenshots, and result chips for zero and three findings." style="display:block;margin:0 auto" />

<p><em>The two prompts that set the job, and the two results that closed the loop. This is a rendered excerpt, not a screenshot of the app.</em></p>
<h2>What pair coding is for</h2>
<p>Pair coding is not “the model writes it and I nod.” It is a division of attention:</p>
<ul>
<li><strong>The human owns intent.</strong> What are we scanning? What must we not scan? What counts as done? In this session: scan the package source, do not scan the malicious fixtures or the archive, do not execute the target, keep the raw output, and make the screenshots real.</li>
<li><strong>The agent owns execution and inspection.</strong> Write the commands, run them, read the exit code, notice when the output contradicts the claim, fix the command, and document the delta.</li>
<li><strong>Both own the evidence.</strong> A clean scan is a claim. The capture is the proof.</li>
</ul>
<p>That is the whole method. The rest is what it looked like in practice.</p>
<h2>Step 1 — install, then find the binary</h2>
<p>Semgrep is an open-source project maintained by Semgrep, Inc.; its source is at <a href="https://github.com/semgrep/semgrep">github.com/semgrep/semgrep</a>, and the public rules used here come from the <a href="https://semgrep.dev/registry">Semgrep Registry</a>.</p>
<p>Semgrep was already installed with <code>pipx</code>. What the agent found was a small, ordinary friction: the binary was in <code>~/.local/bin</code>, which was not on this shell’s <code>PATH</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/50301bcd-0811-4132-883d-c522efc42c33.png" alt="Terminal showing pipx reporting Semgrep 1.182.1 already installed and the full path version command." style="display:block;margin:0 auto" />

<p><em>Semgrep 1.182.1 was already installed; the binary was in</em> <code>~/.local/bin</code><em>, not on this shell’s</em> <code>PATH</code><em>.</em></p>
<p>That is not a security finding. It is the kind of detail a human would otherwise hit five minutes later and blame on the tool. The agent used the full path, confirmed the version, and moved on. For the rest of this post I write <code>semgrep</code>; if <code>~/.local/bin</code> is not on your <code>PATH</code>, use the full path.</p>
<h2>Step 2 — scan the source, not the whole repo</h2>
<p>The first real scan targeted only the package:</p>
<pre><code class="language-bash">semgrep scan \
  --config p/python \
  --config p/secrets \
  --config p/security-audit \
  --metrics=off \
  --disable-version-check \
  src/skilltriage
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/a29a6daf-daf0-4bec-b8c8-5d326a98ad5f.png" alt="Semgrep terminal output: 236 rules run on 46 files in src/skilltriage, zero findings, exit zero." style="display:block;margin:0 auto" />

<p><em>The package source passed the community packs: 236 rules, 46 files, zero findings. That is a result, not a proof.</em></p>
<p>The result was clean: <strong>236 rules, 46 files, 0 findings</strong>. The temptation is to write “Semgrep found nothing” and move on. That is exactly the wrong move. A scanner’s clean output is only as meaningful as its scope.</p>
<p>So the next question was not “what did it find?” It was “what did it actually scan?”</p>
<h2>Step 3 — the clean scan that scanned nothing</h2>
<p>The obvious next target was the repo’s malicious fixtures. The scan came back clean:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/1ec50b05-eb6b-4908-8069-e23ba108213b.png" alt="Semgrep terminal output saying Nothing to scan, zero targets scanned, and six files skipped by .semgrepignore." style="display:block;margin:0 auto" />

<p><em>The first fixture scan looked clean because it scanned nothing. Semgrep ignores</em> <code>tests/</code> <em>by default; the empty scope is the finding.</em></p>
<p><strong>Zero findings, zero targets.</strong> Semgrep’s default <code>.semgrepignore</code> behaviour excludes test directories, and the fixtures live under <code>tests/</code>. The result was not “safe.” It was “not looked at.”</p>
<p>This is the moment pair coding earns its keep. The agent could have reported the first clean result and stopped. Instead it read the summary, saw “Targets scanned: 0,” and said so. The fix was not to fight the ignore rules. It was to create a small, deliberately unsafe demo outside <code>tests/</code> and scan that, so the finding screenshot would be real and the scope would be explicit.</p>
<h2>Step 4 — what a finding looks like</h2>
<p>The demo is twelve lines: <code>subprocess.run(payload, shell=True)</code>, <code>eval(payload)</code>, and a cleartext HTTP <code>requests.post</code>. It is not part of SkillTriage, and it is never executed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/b19cec16-2655-438d-84b0-10ed632d20cd.png" alt="Semgrep output showing three blocking findings in demo/unsafe_demo.py: subprocess shell=True, eval, and an HTTP request." style="display:block;margin:0 auto" />

<p><em>A deliberately bad twelve-line demo, used only to show what a finding looks like: shell=True, eval, and cleartext HTTP. The demo is never executed.</em></p>
<p>Three blocking findings, each with a rule ID, a file and line, a rationale, and a fix hint. This is the part that is easy to demonstrate and easy to misunderstand: the point is not that Semgrep can find <code>eval</code>. The point is that the same command, with the same rules, produced a different result on a different scope. The evidence is the pair of runs, not either one alone.</p>
<h2>Step 5 — write a rule, break it, fix it</h2>
<p>SkillTriage has a design invariant: it must not start processes, open sockets, or evaluate strings. Its unit tests enforce that. Semgrep can enforce it at scan time too, with two project-specific rules:</p>
<ul>
<li><code>skilltriage-no-process-or-network</code></li>
<li><code>skilltriage-no-dynamic-execution</code></li>
</ul>
<p>The first draft used <code>from subprocess import ...</code>. Semgrep rejected it:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/8760de29-1e6b-4199-957c-c27ccbea4601.png" alt="Terminal showing an invalid Semgrep rule pattern error followed by a valid configuration after the fix." style="display:block;margin:0 auto" />

<p><em>The first custom rule was rejected:</em> <code>from subprocess import ...</code> <em>is not a valid Semgrep pattern. Changing</em> <code>...</code> <em>to</em> <code>$FUNC</code> <em>made it valid.</em></p>
<p>The error was precise: <code>Invalid pattern for Python</code>, <code>Stdlib.Parsing.Parse_error</code>. The fix was small: use <code>$FUNC</code> for the imported name. The rule validated, and the scan passed:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/7b6fed3a-463e-448c-a7dd-5c71c23adebe.png" alt="Semgrep output: two rules run on 45 files, zero findings, listing the two project-specific rule names." style="display:block;margin:0 auto" />

<p><em>Two project-specific invariants — no process/network imports, no dynamic execution — pass on 45 files.</em></p>
<p>This is the most honest screenshot in the set. It records a failed attempt and the correction, not just the green result. A pair-coding session that hides its first draft is a demo, not a record.</p>
<h2>Step 6 — the gate is a policy choice</h2>
<p>The same scan can pass or fail depending on what you decide to gate on. With <code>--error --severity ERROR --severity WARNING</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/fc5da28a-9f76-4e40-abc8-63c581d7c611.png" alt="Terminal showing the source scan exit 0 and the demo scan exit 1 with two blocking findings." style="display:block;margin:0 auto" />

<p><em>Same command, two targets. The package exits 0; the demo exits 1. The gate filtered to ERROR/WARNING, so the INFO-level HTTP finding did not fail the build.</em></p>
<p>The source exits <strong>0</strong>. The demo exits <strong>1</strong>. But note the count: the full demo scan had three findings; the gate shows two. The cleartext-HTTP finding is INFO-level and was filtered out by the severity choice. It is still in the full report. That is not a bug; it is a policy decision, and it should be visible.</p>
<p>For CI, the same run can produce SARIF:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/c0d0c5f2-1ef0-4aef-ae3e-661ee53ddd28.png" alt="Terminal showing a Semgrep SARIF scan and jq reporting version 2.1.0 and three results." style="display:block;margin:0 auto" />

<p><em>SARIF 2.1.0 with three results, ready for GitHub code scanning.</em></p>
<p>The SARIF file is the handoff point. GitHub code scanning can consume it; another tool can join on the rule IDs and locations. The terminal output is for the human. The SARIF is for the pipeline.</p>
<h2>The friction we kept</h2>
<p>Two things went wrong that are worth recording.</p>
<p><strong>Semgrep was installed but not on</strong> <code>PATH</code><strong>.</strong> The fix was to call <code>~/.local/bin/semgrep</code> directly. Boring, real, and the kind of thing that makes a “quick start” fail.</p>
<p><strong>Semgrep 1.182.1 intermittently aborted with</strong> <code>io_uring_queue_init: Cannot allocate memory</code><strong>.</strong> This was an environment/runtime issue, not a rule issue. The workaround was:</p>
<pre><code class="language-bash">export EIO_BACKEND=posix
semgrep scan --jobs 1 ...
</code></pre>
<p>With the POSIX backend and one job, the runs were stable. The screenshots show the commands without the workaround, for readability; the captures used it. On a normal desktop you probably will not need it. If you do see that error, now you know where to look.</p>
<p>Neither of these belongs in a polished “getting started” box. Both belong in a pair-coding record, because the next person will hit them.</p>
<h2>What this does not prove</h2>
<p>Semgrep found no issues in the SkillTriage package under the community packs and the two project rules. That does <strong>not</strong> mean SkillTriage is secure. It means the configured rules did not trigger on the files that were scanned.</p>
<p>What is out of scope here:</p>
<ul>
<li><strong>No dynamic testing.</strong> Nothing was executed. The demo file was read, not run.</li>
<li><strong>No auto-fix.</strong> We did not let Semgrep rewrite code. The <code>shell=True</code> finding is a demonstration, not a request to patch.</li>
<li><strong>No CI change.</strong> The SARIF is ready, but the workflow change is a separate decision. Semgrep’s public rules also change over time; a stable gate should pin a version or vendor the rules.</li>
<li><strong>No Semgrep App upload.</strong> <code>--metrics=off</code> and <code>semgrep scan</code> keep the run local. The registry rules are downloaded; the code is not uploaded.</li>
<li><strong>No scan of the fixtures or archive by default.</strong> They are deliberately hostile or superseded. Scanning them produces noise, and the empty-scope lesson above is why we check the target count before trusting a result.</li>
</ul>
<p>Semgrep is complementary to SkillTriage’s own 107 rules, the unit suite, Bandit, and pip-audit. It is another lens, not a verdict.</p>
<h2>What pair coding actually looks like</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/07ca40b9-c90c-462e-85e2-121312bda76e.png" alt="The pair-coding loop: Ask, Scope, Run, Read, Fix, Capture, with quotes from the human and the agent." style="display:block;margin:0 auto" />

<p><em>The loop that made the post possible: the human sets intent and constraints; the agent runs, reads, and reports; both check the same output.</em></p>
<p>The useful version of “vibe coding” is not prompt-and-pray. It is a loop with a paper trail:</p>
<ol>
<li>The human states intent and constraints.</li>
<li>The agent proposes the smallest command that can test the claim.</li>
<li>The agent runs it and reads the scope, the count, and the exit code.</li>
<li>The human checks whether the result answers the question that was asked.</li>
<li>When something is wrong, the agent fixes the command or the rule and re-runs the same check.</li>
<li>Both keep the output, the failure, and the limit.</li>
</ol>
<p>The artifacts are not just the code. They are the runbook, the raw captures, the screenshots, the version, the exit codes, and the list of what was not tested. That is what makes the session reusable by someone else — or by me, six months from now.</p>
<p>SkillTriage is at <a href="https://github.com/cicada-369/SkillTriage">github.com/cicada-369/SkillTriage</a>. Semgrep’s source is at <a href="https://github.com/semgrep/semgrep">github.com/semgrep/semgrep</a>; the public rules are in the <a href="https://semgrep.dev/registry">Semgrep Registry</a>. If you try the same loop, the most useful thing you can send back is the place where the output and the claim disagreed.</p>
<hr />
<p><strong>Bo Kaung</strong> · Builder/Breaker/Defender · <a href="https://www.linkedin.com/in/bokaung">LinkedIn</a> · <a href="https://hashnode.com/@cicada369">Hashnode</a></p>
]]></content:encoded></item><item><title><![CDATA[Your Agent's Skills Are Code. Audit Them.]]></title><description><![CDATA[If you use Claude Skills or Claude Code plugins, you are running code and instructions you probably have not reviewed. I know I was: dozens installed, and…]]></description><link>https://bokaung.hashnode.dev/your-agents-skills-are-code-audit-them</link><guid isPermaLink="true">https://bokaung.hashnode.dev/your-agents-skills-are-code-audit-them</guid><category><![CDATA[ai agents]]></category><category><![CDATA[Security]]></category><category><![CDATA[OWASP TOP 10]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[static analysis]]></category><dc:creator><![CDATA[bo]]></dc:creator><pubDate>Wed, 28 Oct 2026 19:36:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/09f1947e-67dc-4898-8ab2-ba85cf34d702.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you use Claude Skills or Claude Code plugins, you are running code and instructions you probably have not reviewed. I know I was: dozens installed, and no good answer to a simple question — <em>what can this thing do to my machine?</em></p>
<p>I was working through the OWASP Top 10 lists when I found the <a href="https://github.com/OWASP/www-project-agentic-skills-top-10">OWASP Agentic Skills Top 10</a>. It names exactly what I had been ignoring:</p>
<ol>
<li>Malicious Skills</li>
<li>Supply Chain Compromise</li>
<li>Over-Privileged Skills</li>
<li>Insecure Metadata</li>
<li>Untrusted External Instructions</li>
<li>Weak Isolation</li>
<li>Update Drift</li>
<li>Poor Scanning</li>
<li>No Governance</li>
<li>Cross-Platform Reuse</li>
</ol>
<p>I had never validated my own skills and plugins. Through 2026, organizations including <a href="https://cybersecuritynews.com/">Cyber Security News</a>, the <a href="https://cloudsecurityalliance.org/">Cloud Security Alliance</a>, and the <a href="https://nhimg.org/">Non-Human Identity Management Group</a> have documented the same pattern: agent capabilities are spreading faster than the review around them. A skill runs with the agent's privileges. A plugin can register a hook that runs automatically. A remote MCP server sees what the agent sends it.</p>
<p>So I built the tool I wanted: a static auditor that inspects a skill or plugin before installation, shows evidence, and never executes anything. I called it <strong>SkillTriage</strong> — triage, not a verdict.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/d2164330-5c4e-4f35-abca-1300135e764a.png" alt="A SkillTriage terminal scan of a deliberately risky example skill: a critical download-and-execute finding, an instruction-manipulation chain, and an approval bypass." style="display:block;margin:0 auto" />

<p><em>A failing scan: download-and-execute, an instruction-manipulation chain, and an approval bypass.</em></p>
<h2>What it is</h2>
<p>SkillTriage is a Python 3.11+ CLI with <strong>zero third-party runtime dependencies</strong>. It reads a skill directory, a Claude Code plugin root, or a <code>.zip</code>/<code>.tar.gz</code>, runs <strong>107 rules</strong> mapped to AST01–AST10, and emits text, JSON, Markdown, SARIF, or a self-contained offline HTML report. It never executes target code, fetches a URL, or calls a model — the property that makes it safe to point at something you do not trust.</p>
<p>A plugin is treated as <strong>one artifact</strong>: manifest, commands, agents, bundled skills, hooks, MCP servers, and marketplace entry are inspected together. The riskiest part is often not a <code>SKILL.md</code>; it is the hook that runs on every tool call, or the MCP server the manifest starts.</p>
<h2>What it looks like</h2>
<p>The HTML report is one file: no CDN, fonts, external images, or network requests; a restrictive CSP; owner-only permissions by default, because reports can contain excerpts from the artifact.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/37658fbd-ee3b-4483-8b85-97e7210a7bbb.png" alt="The SkillTriage HTML report: summary tiles, the target, and per-finding evidence, rationale, remediation, and CWE references." style="display:block;margin:0 auto" />

<p><em>The HTML report is one offline file: summary, evidence, rationale, remediation, and CWE references.</em></p>
<p>The terminal output is the same data with fewer pixels: what failed, why, where the evidence is, and how to fix it.</p>
<h2>What it catches</h2>
<ul>
<li><strong>Execution and manipulation.</strong> Download-and-execute, reverse shells, destructive commands, persistence, credential access, exfiltration, identity writes, prompt overrides, concealment, and approval bypasses.</li>
<li><strong>Supply chain.</strong> Unpinned or URL/VCS dependencies, install scripts, executable configuration, missing provenance, unpinned MCP launches, and cleartext MCP transport.</li>
<li><strong>Over-privileged components.</strong> Shell, filesystem, network, and tool scope; wildcard grants; plugin commands and agents asking for more than the task. A scoped <code>Bash(ls *)</code> is not broad; an unscoped <code>Bash(*)</code> is.</li>
<li><strong>Metadata and hooks.</strong> Unsafe YAML, duplicate keys, invisible characters, escaping or missing component paths; <code>PreToolUse</code>/<code>PermissionRequest</code> hooks that return an allow decision, escape the plugin root, or reference a missing script.</li>
<li><strong>MCP and secrets.</strong> <code>npx pkg@latest</code>, <code>uvx --from git+…</code>, images without a digest, non-loopback <code>http://</code>, literal header/env credentials (values are never stored), PEM and URL secrets, base64 payloads, invisible Unicode.</li>
<li><strong>Context, not suppression.</strong> Docs and examples lower noise, but outside a documentation path a critical finding cannot be talked down by a comment or a word such as “test”. Declared intent can downgrade technique rules only with an explicit policy allowance and a matching inventory hash.</li>
</ul>
<h2>How it works</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/8b8050e4-2a2d-46de-acc9-61fc28ee6f00.png" alt="The SkillTriage pipeline: target, safe load and normalize, 107 AST01–AST10 rules, context and correlation, then gate and report. It never executes target content or fetches a URL." style="display:block;margin:0 auto" />

<p><em>Five stages, no execution and no network. The report formats are rendered only at the end.</em></p>
<ol>
<li><strong>Load safely.</strong> Walk without following symlinks, open with <code>O_NOFOLLOW</code>, bound every read, and defend archive extraction against zip-slip, bombs, traversal, links, devices, and decompression.</li>
<li><strong>Parse safely.</strong> A restricted YAML subset, <code>json</code> and <code>tomllib</code>, with explicit nesting bounds and no object construction.</li>
<li><strong>Normalize.</strong> Skills and plugin artifacts become one model.</li>
<li><strong>Run 107 rules.</strong> Score context and correlate credential + network, or manipulation + execution, into higher-severity chains. Correlations use code, <code>SKILL.md</code>, or structured plugin config — never command or agent prose.</li>
<li><strong>Gate and report.</strong> Apply policy, suppressions, baseline, and declared intent; render text, JSON, Markdown, SARIF, or HTML.</li>
</ol>
<p>The rule count is a map of coverage, not a quality score:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae24a6617790d5cc48d48c7/084463d4-168f-480c-9015-d5abea1750cc.png" alt="Horizontal bar chart of 107 SkillTriage rules by OWASP control: AST01 Malicious Skills 24, AST02 Supply Chain Compromise 8, AST03 Over-Privileged Skills 14, AST04 Insecure Metadata 21, AST05 Untrusted External Instructions 4, AST06 Weak Isolation 8, AST07 Update Drift 7, AST08 Poor Scanning 10, AST09 No Governance 7, AST10 Cross-Platform Reuse 4." style="display:block;margin:0 auto" />

<p><em>Rule distribution by OWASP control at the time of writing. Counts show coverage, not quality.</em></p>
<h2>What I found on my machine</h2>
<p>I ran it over my own installed corpus: <strong>67 targets</strong> — 43 Claude Code plugins and 24 standalone skills — with <strong>zero scan errors and zero rule errors</strong>. Only one target gated: an offensive-security skill I wrote myself. That is the case the intent policy exists for.</p>
<p>The rest produced a handful of genuine findings — four unpinned MCP launches and some low-severity duplicate component names — but no plugin gated under the default policy. That is a useful result, not proof that those plugins are safe. It means the configured checks did not find enough evidence to block.</p>
<h2>The part I care about most: not making things worse</h2>
<p>A tool that reads hostile input has to survive hostile input. The loader refuses a symlinked target root, never follows a symlink inside a target, bounds per-file reads, and treats archive decompression and entry counts as attack surfaces. Parsers cannot construct objects. Every report field is escaped for control, bidi, and invisible characters before it reaches a terminal or HTML.</p>
<p>Two decisions came out of an independent review:</p>
<ul>
<li><strong>Inline suppressions are opt-in.</strong> A <code>skilltriage:disable</code> comment lives inside the artifact, so honoring it by default would let a package silence its own findings. Baselines are the reviewed-acceptance mechanism; inline suppressions require <code>--trust-inline-suppressions</code>.</li>
<li><strong>A target cannot weaken its own gate.</strong> Project config is not auto-loaded from the current directory; it must be passed with <code>--config</code>. A declared intent is honored only when policy allows it and the inventory record's content hash matches the current artifact. Findings are downgraded, never removed.</li>
</ul>
<p>The reviewer found three critical issues and a set of high and medium ones. The critical issues were: a skill could suppress every finding against itself with a comment, a planted project config could disable the gate, and a one-word context trick could push a critical payload below the threshold. All three are fixed with regression tests; the high and medium findings are fixed or documented as limits. The full review, including what was not tested, is in <a href="https://github.com/cicada-369/SkillTriage/blob/main/docs/security/review-v0.1.md"><code>docs/security/review-v0.1.md</code></a>.</p>
<h2>What it does not do</h2>
<ul>
<li><strong>No dynamic testing.</strong> It does not execute hooks, start MCP servers, or observe runtime file and network activity. That is the sandboxed v0.2 milestone, with its own threat model.</li>
<li><strong>Hooks and MCP are inspected, not run.</strong> Approval-injection detection follows the hook command and the script it directly references; an indirectly invoked script is a gap.</li>
<li><strong>No signature verification.</strong> Signature presence is recorded, not verified. Dependencies are inspected, not resolved, so transitive CVEs are out of scope. URLs are classified, not fetched.</li>
<li><strong>Marketplace scope.</strong> A marketplace manifest is evaluated only at an audited plugin root; standalone Connectors are not audited.</li>
<li><strong>False positives happen</strong> around security documentation, red-team tooling, and detector code. Context scoring, baselines, and intent are the controls. A clean report means the configured checks did not trigger; it does not prove safety.</li>
<li><strong>Platform coverage.</strong> The suite runs on Linux today; Windows and macOS file modes and archive semantics have not been exercised.</li>
</ul>
<h2>Try it</h2>
<p>SkillTriage is Apache-2.0 and lives at <a href="https://github.com/cicada-369/SkillTriage">github.com/cicada-369/SkillTriage</a>. It is not on PyPI in v0.1; install from source or use the single-file zipapp.</p>
<pre><code class="language-bash">git clone https://github.com/cicada-369/SkillTriage.git
cd SkillTriage
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e .

# Scan the default install locations
skilltriage scan

# One plugin, with a shareable offline report
skilltriage scan ~/.claude/plugins/cache/&lt;market&gt;/&lt;plugin&gt;/&lt;version&gt; \
  -o report.html --open

# A downloaded package, as SARIF for CI
skilltriage audit ./downloaded-skill --format sarif -o skilltriage.sarif
</code></pre>
<p>The default gate fails on HIGH or CRITICAL findings with HIGH confidence. <code>--profile advisory</code> never fails; <code>--profile strict</code> gates at MEDIUM. For CI, SARIF works with GitHub code scanning, and <code>--fail-on none</code> makes a scan report-only.</p>
<p>If you maintain an offensive-security skill, declare its intent and inventory it before allowing the downgrade:</p>
<pre><code class="language-bash"># metadata.skilltriage.intent: offensive-security in SKILL.md
skilltriage scan --inventory --update-inventory
skilltriage scan --inventory --allow-intent offensive-security
</code></pre>
<p>The declaration alone changes nothing: the policy must allow the value and the inventory hash must match the bytes you reviewed. The technique findings stay in every report, with the downgrade visible.</p>
<p>More detail: <a href="https://github.com/cicada-369/SkillTriage/blob/main/docs/PLUGINS.md">plugin coverage</a>, <a href="https://github.com/cicada-369/SkillTriage/blob/main/docs/INTENT.md">declared intent</a>, and the <a href="https://github.com/cicada-369/SkillTriage/blob/main/docs/RULES.md">rule catalog</a>.</p>
<h2>What’s next</h2>
<p>The next layer is the one the tool refuses to fake: <strong>sandboxed dynamic testing</strong>. Start the MCP server, run the hook, capture tool calls, and see what the artifact actually does — behind explicit consent, network-none defaults, and syscall/process observation. Static analysis tells you what to review; a sandbox tells you what happened. After that: signature and content-hash verification, SBOM/CVE resolution, and more plugin ecosystems.</p>
<p>If you try SkillTriage, the most useful thing you can send me is <strong>what it gets wrong</strong>. False positives make a scanner easy to ignore; false negatives are worse. Open an issue, or use the private security advisory flow for vulnerabilities.</p>
<p>Your agent's skills are code. Audit them before you trust them.</p>
<hr />
<p><strong>Bo Kaung</strong> · Builder/Breaker/Defender · <a href="https://www.linkedin.com/in/bokaung">LinkedIn</a> · <a href="https://hashnode.com/@cicada369">Hashnode</a></p>
<p><em>Further reading: the</em> <a href="https://github.com/OWASP/www-project-agentic-skills-top-10"><em>OWASP Agentic Skills Top 10</em></a> <em>and its</em> <a href="https://owasp.github.io/www-project-agentic-skills-top-10/"><em>published site</em></a><em>;</em> <a href="https://github.com/cicada-369/SkillTriage"><em>SkillTriage</em></a><em>; the</em> <a href="https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html"><em>SARIF specification</em></a> <em>and</em> <a href="https://docs.github.com/en/code-security/code-scanning"><em>GitHub code scanning</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>