circle-ir-ai 4.11.1 → 4.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.12.0] - 2026-09-11
11
+
12
+ ### Changed
13
+
14
+ - **circle-ir 4.9.10 → 4.9.13.** Picks up four upstream fixes that were published after the
15
+ 4.9.10 tag: the CLI dedupe from cognium-dev#305, three precision fixes from #317, and the
16
+ cross-method alias fix from #318. Verified against the class that prompted the upgrade —
17
+ `RegExp.exec` no longer yields a `command_injection` sink, which is the fourth shape of
18
+ cognium-ai#419 (the other three were ours and are fixed below). Exactly one copy of
19
+ `circle-ir` resolves in the tree; full suite unchanged at 2,205 passing.
20
+
21
+ ### Fixed
22
+
23
+ - **`enableLLM` also gates the SKILL.md natural-language extractor (cognium-ai#405 residual).**
24
+ The gate shipped covering instruction-safety and capability-mismatch, but
25
+ `NaturalLanguageExtractor.extractSemanticFacts()` is a **third** LLM caller and it runs during
26
+ artifact extraction, *before* either gate is consulted — so `--no-llm` still printed the
27
+ unreachable-endpoint warning. Found while validating this release. Skipping it is safe: the
28
+ extractor already degrades to an empty result when the LLM is unavailable, and the only
29
+ consumer of the SKILL.md IR is the capability-mismatch comparison, which `enableLLM: false`
30
+ switches off anyway.
31
+
32
+ ### Added
33
+
34
+ - **`enableLLM` master switch for `analyzeSkillBundle` (cognium-ai#405).** `enableLLM: false`
35
+ means no phase may reach the network. `enableInstructionSafety: false` alone did not cover
36
+ it: the capability-mismatch comparison is a **second** LLM caller in the same pipeline and
37
+ was ungated, so a run with instruction-safety off still attempted a connection and printed
38
+ the unreachable-endpoint warning. Defaults to `true`, so existing callers are unaffected.
39
+
40
+ ### Fixed
41
+
42
+ - **Skills engine precision — the five deterministic classes from the SR skillharness review
43
+ (cognium-ai#416, #417, #418, #420, #427).**
44
+ - **#416** `codebase_exfiltration` fired on `vercel-deploy-claimable`'s `deploy.sh`, which tars the
45
+ project and POSTs it to `api.vercel.com` with the operator's own token — the product, not
46
+ exfiltration. `isDeclaredDeployUpload` now suppresses it when **both** hold: every absolute host
47
+ in the file is a known deploy/publish endpoint, and the skill declares that intent in its own
48
+ SKILL.md. Either condition alone would be exploitable, so both are required; an archive that also
49
+ reaches any other host still fires.
50
+ - **#417** `trigger_hijack` read the documentation checkbox `- [ ] [Create todos for all tasks]` as
51
+ an auto-attach claim. `for all tasks` now needs a skill-activation subject within 40 characters
52
+ (the #352-pass-2 rule), and fenced blocks and `- [ ]` task-list items are excluded — an example
53
+ being shown is not a declaration.
54
+ - **#418** `broken_reference` fired on a page-template link inside a fenced block. New `fenceMask`
55
+ drops reference positions inside ``` / ~~~ fences.
56
+ - **#420 (a false negative)** a backticked sibling directory such as `` `../using-superpowers/references/` ``
57
+ escaped the bundle root but was invisible: the extension test and the `rootPath` requirement both
58
+ hid it, so `obra/executing-plans` returned zero findings. An escaping path is now a reference on
59
+ the strength of the string alone — whether it exists on disk is irrelevant to whether it escapes.
60
+ - **#427 (structural)** 616 of the 2,265 Milestone-1 CRITICAL/HIGH rows name no file at all: they
61
+ are matchers over SKILL.md prose. skillsregistry#85 already established that an instruction read
62
+ must never flip the verdict alone; these matchers are deterministic, so they lack #85's
63
+ reproducibility problem but share its epistemic one — a prose read is a claim about what a skill
64
+ *says*, a taint finding is a claim about what its code *does*. The four behaviour-claim detectors
65
+ (`trigger_hijack`, `invisible_characters`, `multimodal_payload`, `financial_access`) no longer flip
66
+ the verdict to VULNERABLE without a code-backed finding corroborating. They still surface and still
67
+ penalise `trust_score`. Scoped to claims: an encoded payload or hidden instruction in SKILL.md is an
68
+ artifact present in the text and keeps flipping. Kill switch `SKILL_PROSE_VERDICT_GUARD=0`.
69
+ - **Skills engine — a sink is not a finding (cognium-ai#419).** `extractFindingsFromIR`
70
+ emitted one `vulnerability` per entry in circle-ir's sink **inventory**, with no
71
+ source→sink flow required, so we published sinks that circle-ir itself did not consider
72
+ findings. That is why the SkillsRegistry harness saw `code_injection` on
73
+ `setTimeout(connect, 250)` — a function reference — and `xss` on a static `cssText`
74
+ literal, while the SAST team measured circle-ir silent on the same fixtures at the exact
75
+ version tested. Reproduced here with the LLM off, so it is not an enrichment artefact.
76
+ The emission now goes through circle-ir's own `generateFindings(sources, sinks, dfg, …)`,
77
+ which applies the source→sink compatibility check the cross-product skipped — the same
78
+ unification CLAUDE.md records as the open design gap on the mastra `runReport` path. A
79
+ real request-param → `exec()` flow still reports; the code-threat detectors are a separate
80
+ path and are unaffected, so a hardcoded malicious command with no tainted source still
81
+ fires there. A join failure falls back to the sink list rather than silently dropping
82
+ findings. Kill switch `SKILL_SINK_FLOW_GUARD=0`.
83
+
84
+ ### Fixed
85
+
86
+ - **Skills engine precision — `credential_exfiltration` no longer fires on co-presence
87
+ (cognium-ai#412).** The CWE-522 detector asserted exfiltration whenever a credential path
88
+ appeared anywhere in a file that also contained an egress token anywhere else. It was 208 of
89
+ the 2,265 Milestone-1 CRITICAL/HIGH rows; SR resolved 40 of them to source at HEAD and not one
90
+ was exfiltration. Four gates now apply, in the order that clears the most: the path must be in
91
+ real code (not a comment, not a JSON-schema `description`, not prose); it must not be a guard's
92
+ own denylist entry; it must not be the skill's own token store under its own config directory;
93
+ and the read must reach an egress call, by position or through the identifier it was assigned
94
+ to. `danduh/mcp-security`, the single certified true positive in the cohort, still fires.
95
+ - **Skills engine precision — the inverted-detection class, generalised (cognium-ai#414).** #370
96
+ gated quoted literals inside denylist arrays for two detectors. SR then scanned all 1,649
97
+ findings naming a concrete file for three high-specificity families — recursive delete (11),
98
+ cryptominer pool (3), wallet seed phrase (5) — and every one of the 19 was inverted: a
99
+ scanner's own rule catalogue, a jail's denylist, a defensive comment, or a test asserting
100
+ rejection. `inDefensiveContext()` now covers rule catalogues and message tables, comments
101
+ including trailing ones, defensive vocabulary and assertions nearby, and applies to every
102
+ code-threat detector except `hardcoded_secret`. Suppression turns on whether the match is
103
+ *inert* — present only inside a literal on a line whose code half calls nothing that runs or
104
+ sends — never on the filename, so a genuine `exec("rm -rf /")` still fires from
105
+ `src/security/policy.ts`.
106
+
10
107
  ## [4.11.1] - 2026-09-09
11
108
 
12
109
  Patch: the thinking-model timeout allowance introduced in 4.11.0 was global, so a
@@ -38,6 +38,34 @@ import type { SkillBundle, SkillFinding } from './types.js';
38
38
  export declare function buildCommentMask(lines: string[], path?: string): boolean[];
39
39
  /** Blank out string and regex literals so a token like `eval(` inside `'eval('` or `/\\beval\\s*\\(/` is not read as code. */
40
40
  export declare function stripLiterals(line: string): string;
41
+ export declare function inDefensiveContext(lines: string[], mask: boolean[], i: number, _path?: string, match?: string, radius?: number): boolean;
42
+ /**
43
+ * A JSON-schema / tool-parameter `description` string (cognium-ai#412): the credential path is
44
+ * documentation of a parameter, not a read. `"key_file": { "description": "Path to SSH private key
45
+ * file (e.g. ~/.ssh/id_rsa)" }`.
46
+ */
47
+ export declare function isSchemaDescription(line: string): boolean;
48
+ /**
49
+ * The path appears only inside a sentence — three or more words around it in one literal — so it is
50
+ * being *talked about*, not opened ("No credentials stored in Reqly"). Same rule `pathIsWritten`
51
+ * applies to memory files, lifted so the credential class can use it too (cognium-ai#412).
52
+ */
53
+ export declare function isProseMention(line: string, pathRe: RegExp): boolean;
54
+ /**
55
+ * The skill's OWN credential store (cognium-ai#412): a path under the product's own config
56
+ * directory — `~/.config/<product>/credentials.json`, `~/.<product>/credentials.json`,
57
+ * `os.homedir()/.config/<product>/…`, or a path built from a CONFIG_DIR/APP_DIR identifier.
58
+ * Reading and writing your own OAuth token file is what every OAuth client does; it is not
59
+ * exfiltration, whatever else the file happens to contain.
60
+ */
61
+ export declare function isOwnCredentialStore(line: string): boolean;
62
+ /**
63
+ * The credential read reaches egress (cognium-ai#412). Co-presence in one file is not enough — the
64
+ * SR cohort is full of files that read their own config and, elsewhere, call the product's own API.
65
+ * Egress must be on the same line, within a short radius of the read, or reached through the
66
+ * identifier the read was assigned to.
67
+ */
68
+ export declare function readReachesEgress(lines: string[], mask: boolean[], at: number, readOp: RegExp, radius?: number): boolean;
41
69
  export declare function inDenylistContext(lines: string[], i: number, radius?: number): boolean;
42
70
  /**
43
71
  * skillsregistry#72 — decode the literals a runtime would decode, so the
@@ -57,6 +85,13 @@ export declare function decodeEncodedLiterals(content: string): {
57
85
  * ordinary rendering (leading BOM, emoji ZWJ sequences). Exported for tests.
58
86
  */
59
87
  export declare function covertInvisibleCodepoints(text: string): string[];
88
+ /**
89
+ * Lines inside a fenced markdown block (``` or ~~~), for SKILL.md prose detectors.
90
+ * A fenced block is an example being *shown* — a template, a sample header, a command
91
+ * transcript — not something the skill asserts about itself (cognium-ai#417 / #418).
92
+ */
93
+ export declare function fenceMask(lines: string[]): boolean[];
94
+ export declare function isDeclaredDeployUpload(content: string, skillMd?: string): boolean;
60
95
  /** The always-on / auto-attach claim in a text (SKILL.md or a listing description), or null. Shared with the marketplace scanner (SR-D2 / #260). */
61
96
  export declare function alwaysOnClaim(text: string): string | null;
62
97
  export declare const NARROW_CLAIM: RegExp;
@@ -1 +1 @@
1
- {"version":3,"file":"code-threat-patterns.d.ts","sourceRoot":"","sources":["../../src/skills/code-threat-patterns.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAMH,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAoB,MAAM,YAAY,CAAC;AA4Q9E,+DAA+D;AAK/D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,SAAK,GAAG,OAAO,EAAE,CAkCtE;AAED,8HAA8H;AAC9H,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIlD;AASD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,SAAI,GAAG,OAAO,CAOjF;AAmHD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,YAAY,GAAG,UAAU,GAAG,KAAK,GAAG,QAAQ,CAAC;IACvD,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AASD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,eAAe,EAAE,CAAA;CAAE,CA8BvF;AA63BD;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAqBhE;AA6ID,oJAAoJ;AACpJ,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMzD;AA6BD,eAAO,MAAM,YAAY,QAA2H,CAAC;AACrJ,eAAO,MAAM,UAAU,QAAiF,CAAC;AAyOzG;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAqBzD;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAW7D;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,EAAE,YAAY,GAAG,YAAY,CAYzE;AA2DD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,EAAE,CAuDxE;AAiKD,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,EAAE,CA2D5E"}
1
+ {"version":3,"file":"code-threat-patterns.d.ts","sourceRoot":"","sources":["../../src/skills/code-threat-patterns.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAMH,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAoB,MAAM,YAAY,CAAC;AA4Q9E,+DAA+D;AAK/D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,SAAK,GAAG,OAAO,EAAE,CAkCtE;AAED,8HAA8H;AAC9H,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIlD;AAyED,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,EAAE,EACf,IAAI,EAAE,OAAO,EAAE,EACf,CAAC,EAAE,MAAM,EACT,KAAK,SAAK,EACV,KAAK,SAAK,EACV,MAAM,SAAI,GACT,OAAO,CA0BT;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAWpE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAK1D;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,SAAI,GAAG,OAAO,CAkBnH;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,SAAI,GAAG,OAAO,CAOjF;AAmHD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,YAAY,GAAG,UAAU,GAAG,KAAK,GAAG,QAAQ,CAAC;IACvD,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AASD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,eAAe,EAAE,CAAA;CAAE,CA8BvF;AA65BD;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAqBhE;AA2HD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,EAAE,CAYpD;AAoBD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAKjF;AAuBD,oJAAoJ;AACpJ,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMzD;AAkCD,eAAO,MAAM,YAAY,QAA2H,CAAC;AACrJ,eAAO,MAAM,UAAU,QAAiF,CAAC;AAyOzG;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAqBzD;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAW7D;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,EAAE,YAAY,GAAG,YAAY,CAYzE;AA2DD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,EAAE,CA8ExE;AAiKD,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,EAAE,CA2D5E"}
@@ -340,6 +340,172 @@ export function stripLiterals(line) {
340
340
  // in an injection defence. The tell is a nearby identifier or membership test.
341
341
  const DENYLIST_IDENT = /(?<![A-Za-z])(?:marker|pattern|signature|block(?:ed|list)?|deny|ban(?:ned)?|forbid(?:den)?|danger(?:ous)?|risk[a-z]*|detect[a-z]*|scan[a-z]*|blacklist|disallow[a-z]*|suspicious|unsafe|malicious|guard|filter|sanitiz[a-z]*|validat[a-z]*)(?![a-z])/i; // `HIGH_RISK_MARKERS`: `_` is a word char, so no \b
342
342
  const MEMBERSHIP_TEST = /\.(?:includes|test|match|search|indexOf|some|find)\s*\(|\bre\.(?:search|match|compile|findall)\s*\(|\bany\s*\(|\s(?:not\s+)?in\s+[A-Z_a-z]/;
343
+ /**
344
+ * cognium-ai#414 — the inverted-detection class, generalised past #370's literal denylists.
345
+ * SR scanned all 1,649 Milestone-1 findings naming a concrete file and tested three
346
+ * high-specificity signature families at HEAD: recursive-delete (11), cryptominer pool (3),
347
+ * wallet seed phrase (5). All 19 were inverted — a scanner's own rule catalogue, a jail's
348
+ * denylist, a defensive comment, or a test asserting rejection. The cohort this hits hardest
349
+ * is exactly the security tools a trust registry should be ranking highest.
350
+ *
351
+ * Four shapes, matching the four in the ticket:
352
+ * 1. a rule catalogue / message table / indicator list (identifier OR rule-shaped field);
353
+ * 2. a comment — including a trailing one — since a comment never executes;
354
+ * 3. a guard / validator / policy / scanner by path, or an assertion on the construct;
355
+ * 4. a test or benchmark fixture.
356
+ *
357
+ * Every branch except the comment one also requires the match to be INERT: present only inside
358
+ * a string or regex literal on a line whose code half calls nothing live. That is what keeps a
359
+ * genuine `exec("rm -rf /")` or `fetch(url, { body: readFileSync('~/.aws/credentials') })`
360
+ * firing even when it sits in a file called `security.ts`.
361
+ */
362
+ const DEFENSIVE_IDENT = /(?<![A-Za-z])(?:catalog(?:ue)?|indicator|ioc|policy|policies|guard|jail|sandbox|validator|validate|sanitiz|reject|refuse|severity|advisor|remediat|mitigat|malware|malicious|suspicious|threat|audit|lint|rule)/i;
363
+ /** A rule-catalogue FIELD: the match is a value under a documentation-shaped key. */
364
+ const RULE_FIELD = /["']?(?:message|msg|label|title|description|detail|name|id|sev|severity|pattern|regex|re|test|indicator|rule|hint|reason|advice|remediation)["']?\s*[:=]/i;
365
+ /** An assertion — a test proving the guard rejects the construct. */
366
+ const ASSERTION = /\b(?:assert\w*|expect|should|must_fail|is_err|to(?:Throw|Be|Equal)|panics|refute)\b/;
367
+ /** A live call: the literal on this line is an ARGUMENT to something that runs or sends. */
368
+ const LIVE_SINK = /\b(?:exec\w*|spawn\w*|system|popen|Popen|check_output|check_call|eval|Function|shell_exec|passthru|Invoke-Expression|iex)\s*\(|\bsubprocess\b|\bshutil\.rmtree\b|\bos\.(?:system|remove|unlink|rmdir)\s*\(|\bChild(?:Process)?\b/;
369
+ function escapeRe(s) {
370
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
371
+ }
372
+ /** Index at which a line comment starts outside any string literal, or -1. */
373
+ function commentStart(line) {
374
+ let q = null;
375
+ // A leading `#!` is a shebang, not a comment opener.
376
+ for (let i = /^#!/.test(line) ? 2 : 0; i < line.length; i++) {
377
+ const c = line[i];
378
+ if (q) {
379
+ if (c === '\\')
380
+ i++;
381
+ else if (c === q)
382
+ q = null;
383
+ continue;
384
+ }
385
+ if (c === '"' || c === "'" || c === '`') {
386
+ q = c;
387
+ continue;
388
+ }
389
+ // `//` in `https://` is a scheme, not a comment; `--data-binary` is a flag, not SQL.
390
+ if (c === '#' || (c === '/' && line[i + 1] === '/' && line[i - 1] !== ':'))
391
+ return i;
392
+ }
393
+ return -1;
394
+ }
395
+ /**
396
+ * The match is inert: it survives only inside a literal, and the code half of the line calls
397
+ * nothing that runs or sends. A rule catalogue entry is inert; `exec(payload)` is not.
398
+ */
399
+ function isInertLiteral(line, match) {
400
+ if (!match)
401
+ return false;
402
+ const re = new RegExp(escapeRe(match));
403
+ if (!onlyInLiteral(line, re))
404
+ return false;
405
+ const code = stripLiterals(line);
406
+ return !LIVE_SINK.test(code) && !NETWORK_EGRESS.test(code);
407
+ }
408
+ export function inDefensiveContext(lines, mask, i, _path = '', match = '', radius = 6) {
409
+ const line = lines[i] ?? '';
410
+ // 2 — a comment never executes. Block comments and docstrings come from the #370 mask;
411
+ // a trailing comment is caught by position.
412
+ // A shebang is an interpreter selection, not a comment — and some detectors anchor their
413
+ // hit to it (`#!/usr/bin/env bash` for an env-harvesting script).
414
+ if ((mask[i] || isCommentOrDoc(line)) && !/^\s*#!/.test(line))
415
+ return true;
416
+ if (match) {
417
+ const at = line.indexOf(match);
418
+ const cs = commentStart(line);
419
+ if (at >= 0 && cs >= 0 && at > cs)
420
+ return true;
421
+ }
422
+ const inert = isInertLiteral(line, match);
423
+ if (!inert)
424
+ return false;
425
+ // 1 — rule catalogue, message table, indicator list, denylist (the #370 gate widened).
426
+ if (inDenylistContext(lines, i, radius))
427
+ return true;
428
+ if (RULE_FIELD.test(line))
429
+ return true;
430
+ // 3 + 4 — a guard / validator / policy / scanner, or a test asserting rejection. Judged by the
431
+ // code around the match, never by the filename alone: `src/security/collect.ts` is exactly where
432
+ // a real infostealer would put itself.
433
+ const lo = Math.max(0, i - radius);
434
+ const hi = Math.min(lines.length - 1, i + radius);
435
+ for (let k = lo; k <= hi; k++) {
436
+ if (ASSERTION.test(lines[k]) || DEFENSIVE_IDENT.test(lines[k]))
437
+ return true;
438
+ }
439
+ return false;
440
+ }
441
+ /**
442
+ * A JSON-schema / tool-parameter `description` string (cognium-ai#412): the credential path is
443
+ * documentation of a parameter, not a read. `"key_file": { "description": "Path to SSH private key
444
+ * file (e.g. ~/.ssh/id_rsa)" }`.
445
+ */
446
+ export function isSchemaDescription(line) {
447
+ return /["']?(?:description|title|help|placeholder|example|hint|doc)["']?\s*[:=]\s*["'`]/.test(line);
448
+ }
449
+ /**
450
+ * The path appears only inside a sentence — three or more words around it in one literal — so it is
451
+ * being *talked about*, not opened ("No credentials stored in Reqly"). Same rule `pathIsWritten`
452
+ * applies to memory files, lifted so the credential class can use it too (cognium-ai#412).
453
+ */
454
+ export function isProseMention(line, pathRe) {
455
+ const m = pathRe.exec(line);
456
+ if (!m)
457
+ return false;
458
+ const q = /(["'`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
459
+ let lit;
460
+ while ((lit = q.exec(line))) {
461
+ if (lit.index <= m.index && m.index < lit.index + lit[0].length) {
462
+ return lit[2].trim().split(/\s+/).length >= 3 && !/[\/\\]/.test(lit[2].trim().split(/\s+/)[0]);
463
+ }
464
+ }
465
+ return false;
466
+ }
467
+ /**
468
+ * The skill's OWN credential store (cognium-ai#412): a path under the product's own config
469
+ * directory — `~/.config/<product>/credentials.json`, `~/.<product>/credentials.json`,
470
+ * `os.homedir()/.config/<product>/…`, or a path built from a CONFIG_DIR/APP_DIR identifier.
471
+ * Reading and writing your own OAuth token file is what every OAuth client does; it is not
472
+ * exfiltration, whatever else the file happens to contain.
473
+ */
474
+ export function isOwnCredentialStore(line) {
475
+ if (/\b(?:CONFIG_DIR|CONFIG_PATH|APP_DIR|APP_CONFIG|DATA_DIR|STATE_DIR|CACHE_DIR|configDir|appDir|dataDir)\b/.test(line))
476
+ return true;
477
+ // ~/.config/<name>/… or ~/.<name>/… — a named product directory, not a third party's dotfile.
478
+ return /(?:~|\$HOME|homedir\(\)|Path\.home\(\)|process\.env\.HOME|os\.path\.expanduser)[^\n]*?[\/'"`,\s]\.?config[\/'"`,\s]+[\w.-]+[\/'"`,\s]/.test(line)
479
+ || /(?:~|\$HOME|homedir\(\)|Path\.home\(\)|process\.env\.HOME|os\.path\.expanduser)[^\n]*?[\/'"`,\s]\.[\w-]{2,}[\/'"`,\s]+(?:credentials?|token|auth)[\w.-]*\.json/i.test(line);
480
+ }
481
+ /**
482
+ * The credential read reaches egress (cognium-ai#412). Co-presence in one file is not enough — the
483
+ * SR cohort is full of files that read their own config and, elsewhere, call the product's own API.
484
+ * Egress must be on the same line, within a short radius of the read, or reached through the
485
+ * identifier the read was assigned to.
486
+ */
487
+ export function readReachesEgress(lines, mask, at, readOp, radius = 4) {
488
+ const code = (k) => !mask[k] && !isCommentOrDoc(lines[k]);
489
+ const line = lines[at];
490
+ if (NETWORK_EGRESS.test(line))
491
+ return true;
492
+ const isRead = readOp.test(line);
493
+ for (let k = Math.max(0, at - radius); k <= Math.min(lines.length - 1, at + radius); k++) {
494
+ if (k !== at && code(k) && NETWORK_EGRESS.test(lines[k]) && (isRead || readOp.test(lines[k])))
495
+ return true;
496
+ }
497
+ // `creds = open(path).read()` … later `fetch(url, { body: creds })`
498
+ const assign = /^\s*(?:(?:const|let|var|val|my|local|export)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[\w<>\[\]|.]+)?\s*=[^=]/.exec(line);
499
+ if (assign && isRead) {
500
+ const id = assign[1].replace(/\$/g, '\\$');
501
+ const used = new RegExp(`\\b${id}\\b`);
502
+ for (let k = at + 1; k < lines.length; k++) {
503
+ if (code(k) && used.test(lines[k]) && NETWORK_EGRESS.test(lines[k]))
504
+ return true;
505
+ }
506
+ }
507
+ return false;
508
+ }
343
509
  export function inDenylistContext(lines, i, radius = 6) {
344
510
  const lo = Math.max(0, i - radius);
345
511
  const hi = Math.min(lines.length - 1, i + radius);
@@ -519,7 +685,7 @@ function scanFile(path, content, ctx) {
519
685
  (/\btarfile\.open\s*\(/.test(content) && /\.add\s*\(\s*(?:pathlib\.Path\s*\(\s*['"]\.['"]\s*\)|['"]\.['"]|os\.getcwd\s*\(\s*\)|Path\.cwd\s*\(\s*\))/.test(content)) ||
520
686
  (/\bzipfile\.ZipFile\s*\(/.test(content) && /os\.walk\s*\(\s*(?:['"]\.['"]|os\.getcwd\(\)|Path\.cwd\(\))/.test(content)) ||
521
687
  /\b(?:archiver|tar)\s*\.\s*(?:c|create|directory)\s*\([^\n]*['"](?:\.|\.\/|process\.cwd\(\))['"]?/.test(content);
522
- if (archivesWorkspace && fileHasEgress) {
688
+ if (archivesWorkspace && fileHasEgress && !isDeclaredDeployUpload(content, ctx?.skillMd)) {
523
689
  const line = firstLineMatching(lines, /git\s+archive|tar\s+|zip\s+-r|make_archive/i);
524
690
  hits.push({
525
691
  key: 'codebase_exfiltration',
@@ -778,17 +944,50 @@ function scanFile(path, content, ctx) {
778
944
  // hosts, .netrc) AND ships it out. A skill that reads credentials without
779
945
  // egress, or has egress without a credential read, does not fire.
780
946
  {
947
+ // cognium-ai#412 (SR full-cohort, 208/2,265 rows): this fired on CO-PRESENCE — a credential
948
+ // path anywhere in the file plus an egress token anywhere else. Of 40 rows resolved to source
949
+ // by the SR team, NONE was exfiltration: a skill's own OAuth store (`~/.config/<product>/
950
+ // credentials.json`), a filesystem jail whose denylist contains `id_rsa`, a JSON-schema
951
+ // `description` naming `~/.ssh/id_rsa`, and the sentence "No credentials stored in Reqly".
952
+ //
953
+ // The discriminator, from the one certified TP (`danduh/mcp-security`): the credential is
954
+ // READ and the read REACHES an egress call. Four gates, in order of how much they clear:
955
+ // 1. the path must be in real code — not a comment, not a JSON-schema description, and not
956
+ // prose (`stripLiterals` + the comment mask + the prose rule).
957
+ // 2. not a denylist / guard entry — `inDenylistContext`, the #414 inverted-detection class.
958
+ // 3. not the skill's OWN credential store — a path under the product's own config dir.
959
+ // 4. the read must reach egress: same line, within a short radius, or via the identifier the
960
+ // read was assigned to. Co-presence alone no longer fires.
781
961
  const CRED_FILE = /\.aws\/credentials|\.ssh\/id_(rsa|ed25519|dsa)\b|\.config\/gh\/hosts|\.netrc\b|\/etc\/(shadow|passwd)\b|\bcredentials\.json\b|\.docker\/config\.json/i;
782
- if (CRED_FILE.test(content) && fileHasEgress) {
783
- const at = firstLineMatching(lines, CRED_FILE);
962
+ const READ_OP = /\b(?:open|read_text|read_bytes|readFile|readFileSync|read|load|loads|cat|Get-Content|File\.(?:read|open)|fs\.read)\w*\s*\(|\bwith\s+open\s*\(|<\s*["'`]?[~/$][^\s"'`]*\.(?:pem|json)\b/;
963
+ for (let i = 0; i < lines.length; i++) {
964
+ const raw = lines[i];
965
+ if (!CRED_FILE.test(raw))
966
+ continue;
967
+ // 1 — real code only. A path that survives only inside a literal still counts (that is how
968
+ // paths are written), but a commented line, a doc line, or a schema description does not.
969
+ if (mask[i] || isCommentOrDoc(raw) || isSchemaDescription(raw))
970
+ continue;
971
+ if (isProseMention(raw, CRED_FILE))
972
+ continue;
973
+ // 2 — a guard's own denylist entry is not an exfiltration (cognium-ai#414).
974
+ if (inDenylistContext(lines, i))
975
+ continue;
976
+ // 3 — the skill reading its OWN token store is every OAuth client on earth.
977
+ if (isOwnCredentialStore(raw))
978
+ continue;
979
+ // 4 — the read has to reach egress.
980
+ if (!readReachesEgress(lines, mask, i, READ_OP))
981
+ continue;
784
982
  hits.push({
785
983
  key: 'credential_exfiltration',
786
- line: at.n,
787
- snippet: at.text,
788
- match: at.match,
984
+ line: i + 1,
985
+ snippet: raw.trim().slice(0, 200),
986
+ match: (CRED_FILE.exec(raw) ?? [''])[0].slice(0, 80),
789
987
  confidence: 0.9,
790
- detail: 'reads local credential files and sends them to a network endpoint',
988
+ detail: 'reads a local credential file and the read reaches a network egress call',
791
989
  });
990
+ break;
792
991
  }
793
992
  }
794
993
  // ---- SR#14 config_injection ------------------------------------------
@@ -1195,7 +1394,11 @@ function scanFile(path, content, ctx) {
1195
1394
  detail: 'embedded PEM private key material',
1196
1395
  });
1197
1396
  }
1198
- return hits.map((h) => hitToFinding(path, h));
1397
+ // cognium-ai#414 drop the inverted class before it becomes a finding. `hardcoded_secret`
1398
+ // is exempt: real key material in a fixture is still real key material.
1399
+ return hits
1400
+ .filter((h) => h.key === 'hardcoded_secret' || !inDefensiveContext(lines, mask, (h.line ?? 1) - 1, path, h.match ?? ''))
1401
+ .map((h) => hitToFinding(path, h));
1199
1402
  }
1200
1403
  // A PEM PRIVATE KEY header immediately followed (within a few chars/newlines)
1201
1404
  // by a long base64 body run — i.e. real key material, not a doc header mention.
@@ -1470,6 +1673,55 @@ function scanMultimodalDeference(skillMd) {
1470
1673
  },
1471
1674
  ];
1472
1675
  }
1676
+ /**
1677
+ * Lines inside a fenced markdown block (``` or ~~~), for SKILL.md prose detectors.
1678
+ * A fenced block is an example being *shown* — a template, a sample header, a command
1679
+ * transcript — not something the skill asserts about itself (cognium-ai#417 / #418).
1680
+ */
1681
+ export function fenceMask(lines) {
1682
+ const mask = new Array(lines.length).fill(false);
1683
+ let open = null;
1684
+ for (let i = 0; i < lines.length; i++) {
1685
+ const m = /^\s{0,3}(`{3,}|~{3,})/.exec(lines[i]);
1686
+ if (m) {
1687
+ if (open === null) {
1688
+ open = m[1][0];
1689
+ mask[i] = true;
1690
+ continue;
1691
+ }
1692
+ if (m[1][0] === open) {
1693
+ open = null;
1694
+ mask[i] = true;
1695
+ continue;
1696
+ }
1697
+ }
1698
+ mask[i] = open !== null;
1699
+ }
1700
+ return mask;
1701
+ }
1702
+ /**
1703
+ * cognium-ai#416 — packaging a project and POSTing it to a deployment service is the
1704
+ * *product*, not exfiltration. `vercel-deploy-claimable`'s `deploy.sh` tars the project
1705
+ * and posts it to `api.vercel.com` with the operator's own token, and fired CRITICAL.
1706
+ *
1707
+ * Two conditions, both required, mirroring the ticket's "declared destinations" ask:
1708
+ * 1. every absolute host the file talks to is a known deploy / publish endpoint, and
1709
+ * 2. the skill DECLARES that intent in its own SKILL.md.
1710
+ *
1711
+ * Requirement 1 alone would let an attacker use a deploy host as a drop; requirement 2
1712
+ * alone would let a "deployment helper" ship the tree anywhere. An archive posted to a
1713
+ * deploy endpoint *plus* any other host still fires — that is the exfiltration shape.
1714
+ */
1715
+ const DEPLOY_ENDPOINT_HOST = /\b(?:api\.vercel\.com|vercel\.app|api\.netlify\.com|netlify\.app|uploads?\.github\.com|registry\.npmjs\.org|upload\.pypi\.org|api\.cloudflare\.com|pages\.dev|api\.heroku\.com|api\.fly\.io|api\.render\.com|backboard\.railway\.app|api\.digitalocean\.com|surge\.sh|firebaseio\.com|[\w.-]*\.firebaseapp\.com|storage\.googleapis\.com|[\w.-]*\.blob\.core\.windows\.net|s3[.-][\w-]*\.amazonaws\.com)\b/i;
1716
+ const DEPLOY_INTENT = /\b(?:deploy(?:s|ing|ment)?|publish(?:es|ing)?|release(?:s|ing)?|upload(?:s|ing)?\s+(?:the\s+)?(?:site|project|build|bundle|artifact))\b/i;
1717
+ export function isDeclaredDeployUpload(content, skillMd) {
1718
+ if (!skillMd || !DEPLOY_INTENT.test(skillMd))
1719
+ return false;
1720
+ const hosts = [...content.matchAll(/https?:\/\/([A-Za-z0-9._-]+)/g)].map((m) => m[1]);
1721
+ if (hosts.length === 0)
1722
+ return false;
1723
+ return hosts.every((h) => DEPLOY_ENDPOINT_HOST.test(h));
1724
+ }
1473
1725
  // ---------------------------------------------------------------------------
1474
1726
  // SR-D2 #24 — always-on / auto-attach sprawl
1475
1727
  // ---------------------------------------------------------------------------
@@ -1479,7 +1731,10 @@ function scanMultimodalDeference(skillMd) {
1479
1731
  // opt-in helper ("only run when invoked; never auto-attach") stays clean — so the
1480
1732
  // detector needs the affirmative claim AND the absence of an explicit negation
1481
1733
  // (the safe twin literally contains the word "auto-attach", but negated).
1482
- const ALWAYS_ON_AFFIRMATIVE = /\bfor all tasks\b|\binvoked first\b|\bbefore any other skill\b|\b(?:use this skill |run |activate |attach )?automatically (?:for|on|in|to) (?:every|all|each|any)\b|\bauto-?attach(?:es|ing)?\s+to\s+(?:all|every|any)\b|\bwithout waiting for the user to invoke\b|\b(?:runs?|loads?|activates?|attach(?:es)?|applies|applied|inject(?:s|ed)|active|enabled|invoked|fires?)\b[^.\n]{0,30}\bin every (?:session|conversation)\b|\bin every (?:session|conversation)\b[^.\n]{0,40}\b(?:without|regardless)\b|\balways[- ]?on\b(?=[^.\n]{0,40}\b(?:skill|plugin|helper|agent|hook|assistant|rule|instruction)s?\b)|\b(?:skill|plugin|helper|agent|hook|assistant)s?\b[^.\n]{0,40}\balways[- ]?on\b|\b(?:for|to) every repository\b|\bprefer this skill over (?:project|local|user)\b/i;
1734
+ const ALWAYS_ON_AFFIRMATIVE = /\b(?:this skill|use|used|uses|run|runs|apply|applies|applied|activate|activates|attach(?:es)?|invoke[ds]?|enabled?)\b[^.\n]{0,40}\bfor all tasks\b|\binvoked first\b|\bbefore any other skill\b|\b(?:use this skill |run |activate |attach )?automatically (?:for|on|in|to) (?:every|all|each|any)\b|\bauto-?attach(?:es|ing)?\s+to\s+(?:all|every|any)\b|\bwithout waiting for the user to invoke\b|\b(?:runs?|loads?|activates?|attach(?:es)?|applies|applied|inject(?:s|ed)|active|enabled|invoked|fires?)\b[^.\n]{0,30}\bin every (?:session|conversation)\b|\bin every (?:session|conversation)\b[^.\n]{0,40}\b(?:without|regardless)\b|\balways[- ]?on\b(?=[^.\n]{0,40}\b(?:skill|plugin|helper|agent|hook|assistant|rule|instruction)s?\b)|\b(?:skill|plugin|helper|agent|hook|assistant)s?\b[^.\n]{0,40}\balways[- ]?on\b|\b(?:for|to) every repository\b|\bprefer this skill over (?:project|local|user)\b/i;
1735
+ // cognium-ai#417: `for all tasks` had no subject requirement, so the documentation
1736
+ // checkbox `- [ ] [Create todos for all tasks]` read as an auto-attach claim. It now
1737
+ // needs a skill-activation verb within 40 chars, matching the #352-pass-2 subject rule.
1483
1738
  // #352 pass 2: "always-on" / "in every session" needed the SKILL as subject. 7/7 sampled
1484
1739
  // rows (skillsregistry#93) were prose about an always-on *process / endpoint / HTTPS
1485
1740
  // service* or a CLI "available in every session" — infrastructure, not auto-attach.
@@ -1501,7 +1756,13 @@ function scanTriggerHijack(skillMd) {
1501
1756
  if (!alwaysOnClaim(skillMd))
1502
1757
  return [];
1503
1758
  const lines = skillMd.split(/\r?\n/);
1504
- const line = firstLineMatching(lines, ALWAYS_ON_AFFIRMATIVE);
1759
+ // cognium-ai#417: the claim has to be prose the skill asserts about itself. A fenced
1760
+ // block or a `- [ ]` task-list item is an example being shown, not a declaration.
1761
+ const fenced = fenceMask(lines);
1762
+ const claimLines = lines.map((l, i) => (fenced[i] || /^\s*[-*]\s*\[[ xX]?\]/.test(l) ? '' : l));
1763
+ if (!alwaysOnClaim(claimLines.join('\n')))
1764
+ return [];
1765
+ const line = firstLineMatching(claimLines, ALWAYS_ON_AFFIRMATIVE);
1505
1766
  const meta = META.trigger_hijack;
1506
1767
  return [
1507
1768
  {
@@ -1916,22 +2177,53 @@ export function scanBrokenReferences(bundle) {
1916
2177
  inlineRefs.push(raw);
1917
2178
  }
1918
2179
  }
2180
+ // cognium-ai#418 — a link inside a fenced block is a TEMPLATE being shown, not an asset
2181
+ // the skill ships: CowAgent's `[Related Page](../category/related-page.md)` sits in a
2182
+ // page-template code block. Reference positions inside a fence are dropped.
2183
+ const fenced = fenceMask(lines);
2184
+ const lineOfOffset = (() => {
2185
+ const starts = [];
2186
+ let acc = 0;
2187
+ for (const l of lines) {
2188
+ starts.push(acc);
2189
+ acc += l.length + 1;
2190
+ }
2191
+ return (off) => { let lo = 0, hi = starts.length - 1; while (lo < hi) {
2192
+ const mid = (lo + hi + 1) >> 1;
2193
+ if (starts[mid] <= off)
2194
+ lo = mid;
2195
+ else
2196
+ hi = mid - 1;
2197
+ } return lo; };
2198
+ })();
2199
+ // cognium-ai#420 — a reference that ESCAPES the bundle root is provable from the string
2200
+ // alone: `../using-superpowers/references/` is outside the bundle whether or not it
2201
+ // exists on disk. The extension test and the `rootPath` requirement were both hiding
2202
+ // this class, so obra/executing-plans returned 0 findings for a plain sibling escape.
2203
+ const escapingRefs = [];
2204
+ for (const em of md.matchAll(/`(\.\.\/[A-Za-z0-9_./-]+)`/g))
2205
+ escapingRefs.push({ raw: em[1], off: em.index ?? 0 });
1919
2206
  const refIter = [];
1920
2207
  while ((m = linkRe.exec(md)) !== null)
1921
- refIter.push({ raw: m[1].trim() });
2208
+ refIter.push({ raw: m[1].trim(), off: m.index });
1922
2209
  for (const raw of inlineRefs)
1923
- refIter.push({ raw });
1924
- for (const { raw } of refIter) {
2210
+ refIter.push({ raw, off: md.indexOf('`' + raw + '`') });
2211
+ for (const e of escapingRefs)
2212
+ refIter.push(e);
2213
+ for (const { raw, off } of refIter) {
1925
2214
  // Skip URL schemes, anchors, protocol-relative, mailto/tel.
1926
2215
  if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('#') || raw.startsWith('//'))
1927
2216
  continue;
2217
+ if (off >= 0 && fenced[lineOfOffset(off)])
2218
+ continue; // #418: an example, not an asset
1928
2219
  const ref = norm(raw);
1929
2220
  if (!ref || seen.has(ref))
1930
2221
  continue;
2222
+ const escapes = ref.includes('../');
1931
2223
  // Only flag things that look like a file (have an extension) — avoids prose refs.
1932
- if (!/\.[a-z0-9]{1,8}$/i.test(ref))
2224
+ // An escaping path is exempt: the escape itself is the finding (#420).
2225
+ if (!escapes && !/\.[a-z0-9]{1,8}$/i.test(ref))
1933
2226
  continue;
1934
- const escapes = ref.includes('../');
1935
2227
  // Present if an exact path, or a bundle file ends with this ref (basename/suffix match).
1936
2228
  const found = present.has(ref) ||
1937
2229
  bundleFiles.some((p) => norm(p) === ref || norm(p).endsWith('/' + ref) || norm(p).endsWith(ref.replace(/^.*\//, ''))) ||
@@ -1945,7 +2237,9 @@ export function scanBrokenReferences(bundle) {
1945
2237
  severity: 'low',
1946
2238
  artifact: 'SKILL.md',
1947
2239
  title: META.broken_reference.title,
1948
- description: `SKILL.md links "${raw}" which is not present in the bundle${escapes ? ' (path escapes the bundle root)' : ''}; it would be resolved from an external location at runtime.`,
2240
+ description: escapes
2241
+ ? `SKILL.md references "${raw}", a path outside the bundle root; it would be resolved from an external location at runtime.`
2242
+ : `SKILL.md links "${raw}" which is not present in the bundle; it would be resolved from an external location at runtime.`,
1949
2243
  evidence: { detector: 'broken_reference', reference: raw, escapesRoot: escapes },
1950
2244
  confidence: 0.7,
1951
2245
  cwe: META.broken_reference.cwe,