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 +97 -0
- package/dist/skills/code-threat-patterns.d.ts +35 -0
- package/dist/skills/code-threat-patterns.d.ts.map +1 -1
- package/dist/skills/code-threat-patterns.js +310 -16
- package/dist/skills/code-threat-patterns.js.map +1 -1
- package/dist/skills/precision.d.ts.map +1 -1
- package/dist/skills/precision.js +30 -2
- package/dist/skills/precision.js.map +1 -1
- package/dist/skills/skill-analyzer.d.ts.map +1 -1
- package/dist/skills/skill-analyzer.js +54 -11
- package/dist/skills/skill-analyzer.js.map +1 -1
- package/dist/skills/types.d.ts +7 -0
- package/dist/skills/types.d.ts.map +1 -1
- package/package.json +2 -2
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;
|
|
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
|
-
|
|
783
|
-
|
|
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:
|
|
787
|
-
snippet:
|
|
788
|
-
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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,
|