diff --git a/eslint-rules/__tests__/no-claude-home-reads.test.cjs b/eslint-rules/__tests__/no-claude-home-reads.test.cjs index d41cc5b7..49aab6a2 100644 --- a/eslint-rules/__tests__/no-claude-home-reads.test.cjs +++ b/eslint-rules/__tests__/no-claude-home-reads.test.cjs @@ -171,6 +171,29 @@ ruleTester.run('no-claude-home-reads', rule, { code: "const p = join(os.homedir(), '.claude')", errors: [{ messageId: 'forbidden' }], }, + // WP-9 Track 4 — explicit homedir() + '.claude' concatenation forms. + // These exercise the same `concatenatesClaudeDir` branch but with the + // brief's exact patterns, locking in the invariant under WP-9 sign-off. + { + code: "const p = homedir() + '/.claude'", + errors: [{ messageId: 'forbidden' }], + }, + { + code: "const p = homedir() + '/.claude/sessions'", + errors: [{ messageId: 'forbidden' }], + }, + { + code: "const p = os.homedir() + '/.claude'", + errors: [{ messageId: 'forbidden' }], + }, + { + code: "const p = '/.claude/' + homedir()", + errors: [{ messageId: 'forbidden' }], + }, + { + code: 'const p = `${homedir()}/.claude`', + errors: [{ messageId: 'forbidden' }], + }, ], }) diff --git a/eslint-rules/__tests__/no-unsafe-anchor-href.test.cjs b/eslint-rules/__tests__/no-unsafe-anchor-href.test.cjs new file mode 100644 index 00000000..5b550cba --- /dev/null +++ b/eslint-rules/__tests__/no-unsafe-anchor-href.test.cjs @@ -0,0 +1,169 @@ +/** + * WP-9 Track 3 — Tests for `no-unsafe-anchor-href`. + * + * Two surfaces: + * - Plain JS/TS: covered by ESLint's `RuleTester` against the default + * parser. + * - Vue templates: validated with the standalone `Linter` API and + * `vue-eslint-parser` so we exercise the same visitor wiring as the + * project lint pass. + */ + +'use strict' + +const { RuleTester, Linter } = require('eslint') +const rule = require('../no-unsafe-anchor-href.cjs') + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}) + +ruleTester.run('no-unsafe-anchor-href (script)', rule, { + valid: [ + // Static href assignments. + 'anchor.href = "/about"', + 'anchor.href = `static`', + // safeHref-wrapped values. + 'anchor.href = safeHref(userVar)', + 'anchor.href = utils.safeHref(userVar)', + // window.open with static URL. + 'window.open("https://example.com")', + 'window.open(safeHref(userVar))', + // setAttribute on something other than href. + 'el.setAttribute("data-id", userVar)', + // setAttribute with safeHref. + 'el.setAttribute("href", safeHref(userVar))', + // Unrelated assignments. + 'obj.value = userVar', + // Unrelated function calls. + 'someOtherFn(userVar)', + ], + invalid: [ + { + code: 'anchor.href = userVar', + errors: [{ messageId: 'unsafeHrefAssignment' }], + }, + { + code: 'anchor.href = `prefix${userVar}`', + errors: [{ messageId: 'unsafeHrefAssignment' }], + }, + { + code: 'window.open(userVar)', + errors: [{ messageId: 'unsafeWindowOpen' }], + }, + { + code: 'window.open(`${userVar}`)', + errors: [{ messageId: 'unsafeWindowOpen' }], + }, + { + code: 'el.setAttribute("href", userVar)', + errors: [{ messageId: 'unsafeSetAttribute' }], + }, + { + code: 'el.setAttribute("HREF", userVar)', + errors: [{ messageId: 'unsafeSetAttribute' }], + }, + ], +}) + +// ── Vue template surface ─────────────────────────────────────────────────── +const linter = new Linter() + +function lintVue(template) { + return linter.verify( + template, + [ + { + files: ['**/*.vue'], + plugins: { local: { rules: { 'no-unsafe-anchor-href': rule } } }, + rules: { 'local/no-unsafe-anchor-href': 'error' }, + languageOptions: { + parser: require('vue-eslint-parser'), + ecmaVersion: 2022, + sourceType: 'module', + }, + }, + ], + { filename: 'sample.vue' }, + ) +} + +const vueValidSafe = ` + +` +const vueValidStatic = ` + +` +const vueValidNonAnchor = ` + +` +const vueInvalid = ` + +` +const vueInvalidVBind = ` + +` + +const safeRes = lintVue(vueValidSafe) +if (safeRes.length !== 0) { + throw new Error( + 'no-unsafe-anchor-href Vue safe case: expected zero violations, got ' + + JSON.stringify(safeRes), + ) +} + +const staticRes = lintVue(vueValidStatic) +if (staticRes.length !== 0) { + throw new Error( + 'no-unsafe-anchor-href Vue static case: expected zero violations, got ' + + JSON.stringify(staticRes), + ) +} + +const nonAnchorRes = lintVue(vueValidNonAnchor) +if (nonAnchorRes.length !== 0) { + throw new Error( + 'no-unsafe-anchor-href Vue non-anchor case: expected zero violations, got ' + + JSON.stringify(nonAnchorRes), + ) +} + +const invalidRes = lintVue(vueInvalid) +if ( + invalidRes.length !== 1 || + invalidRes[0].messageId !== 'unsafeAnchorHref' +) { + throw new Error( + 'no-unsafe-anchor-href Vue unsafe case: expected one unsafeAnchorHref violation, got ' + + JSON.stringify(invalidRes), + ) +} + +const invalidVBindRes = lintVue(vueInvalidVBind) +if ( + invalidVBindRes.length !== 1 || + invalidVBindRes[0].messageId !== 'unsafeAnchorHref' +) { + throw new Error( + 'no-unsafe-anchor-href Vue v-bind unsafe case: expected one unsafeAnchorHref violation, got ' + + JSON.stringify(invalidVBindRes), + ) +} + +// eslint-disable-next-line no-console +console.log( + 'eslint-rules/no-unsafe-anchor-href: all RuleTester cases + Vue template assertions passed.', +) diff --git a/eslint-rules/no-unsafe-anchor-href.cjs b/eslint-rules/no-unsafe-anchor-href.cjs new file mode 100644 index 00000000..e04e770d --- /dev/null +++ b/eslint-rules/no-unsafe-anchor-href.cjs @@ -0,0 +1,180 @@ +/** + * WP-9 Track 3 — `no-unsafe-anchor-href` ESLint rule. + * + * Preventive rule: flags any future Vue `` or JSX/TS pattern + * that hands a non-`safeHref(...)`-wrapped expression to an anchor `href`, + * `window.open`, or `location.href` assignment. + * + * Scope at the time of authoring (WP-9 audit): + * - The only `safeHref` consumer in the agent-sidepanel surface is + * `MarkdownBlock.vue`, owned by WP-4. + * - Every other Vue file under `src/ui/components/{agent,chat}/` was + * hand-audited and contains no anchor that interpolates user-controlled + * content into `href`. The rule is preventive — it should fire on any + * FUTURE unsafe anchor. + * + * Patterns flagged (at WARN severity initially): + * 1. `` where `` is anything other than + * `safeHref(...)`, a static string literal, or a clearly-safe constant + * such as a vue-router `to` binding (only handled in templates with + * Vue's ``, not raw anchors). + * 2. Direct DOM assignments: `someAnchor.href = userVar` / + * `someAnchor.setAttribute('href', userVar)` outside of `safeHref`. + * 3. `window.open(userVar)` outside of `safeHref(...)`. + * 4. `location.href = userVar` / `window.location.href = userVar`. + * + * CommonJS so it loads through the same `createRequire` path used by the + * existing `no-claude-home-reads` rule. + */ + +'use strict' + +/** Whitelist of expression shapes considered safe for use as an `href`. */ +function isSafeHrefSource(node) { + if (!node) return false + // Static string literal: `` is safe. + if (node.type === 'Literal' && typeof node.value === 'string') return true + // Template literal with no expressions: still a static string. + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return true + // `safeHref(...)` call — any function literally named `safeHref` at the + // call site is accepted. We intentionally do NOT require a specific import + // path because the rule is preventive and import-path tracking is brittle. + if (node.type === 'CallExpression') { + const callee = node.callee + if (callee.type === 'Identifier' && callee.name === 'safeHref') return true + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'safeHref' + ) { + return true + } + } + return false +} + +/** + * Vue template visitor: walk the AST emitted by `vue-eslint-parser` and flag + * `` / `` bindings whose value expression + * is not on the safe-source allow-list. + */ +function buildTemplateVisitor(context) { + return { + "VAttribute[directive=true][key.name.name='bind'][key.argument.name='href']"(node) { + // The bound element must be an anchor. `node.parent.parent` is the + // VElement; check its `rawName` for case-insensitive `a`. + const element = node.parent && node.parent.parent + if (!element || element.type !== 'VElement') return + const tagName = + element.rawName !== undefined ? String(element.rawName).toLowerCase() : '' + if (tagName !== 'a') return + + const expr = node.value && node.value.expression + if (expr === null || expr === undefined) return + if (isSafeHrefSource(expr)) return + + context.report({ + node, + messageId: 'unsafeAnchorHref', + }) + }, + } +} + +/** JS/TS visitor for direct DOM `.href` / `location.href` / `window.open` use. */ +function buildScriptVisitor(context) { + return { + AssignmentExpression(node) { + if (node.operator !== '=') return + const left = node.left + if (left.type !== 'MemberExpression') return + const prop = left.property + const propName = + prop.type === 'Identifier' + ? prop.name + : prop.type === 'Literal' && typeof prop.value === 'string' + ? prop.value + : null + if (propName !== 'href') return + // Allow `obj.href = 'static-string'` and `obj.href = safeHref(...)`. + if (isSafeHrefSource(node.right)) return + context.report({ node, messageId: 'unsafeHrefAssignment' }) + }, + CallExpression(node) { + const callee = node.callee + if (callee.type !== 'MemberExpression') return + const prop = callee.property + const propName = prop.type === 'Identifier' ? prop.name : null + // window.open() — flag when first arg isn't a safe source. + if (propName === 'open') { + const obj = callee.object + const objName = + obj.type === 'Identifier' + ? obj.name + : obj.type === 'MemberExpression' && obj.property.type === 'Identifier' + ? obj.property.name + : null + if (objName !== 'window') return + const firstArg = node.arguments[0] + if (firstArg === undefined) return + if (isSafeHrefSource(firstArg)) return + context.report({ node, messageId: 'unsafeWindowOpen' }) + } + // anchor.setAttribute('href', ) — flag when value arg isn't safe. + if (propName === 'setAttribute') { + const firstArg = node.arguments[0] + const secondArg = node.arguments[1] + if (firstArg === undefined || secondArg === undefined) return + const attrName = + firstArg.type === 'Literal' && typeof firstArg.value === 'string' + ? firstArg.value.toLowerCase() + : null + if (attrName !== 'href') return + if (isSafeHrefSource(secondArg)) return + context.report({ node, messageId: 'unsafeSetAttribute' }) + } + }, + } +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Forbid passing user-controllable expressions to anchor href / ' + + 'window.open / location.href. Wrap with safeHref() instead ' + + '(WP-9 Track 3, NFR-ASM-005 link-surface hardening).', + }, + schema: [], + messages: { + unsafeAnchorHref: + 'Anchor `:href` binding must use `safeHref(...)` or a static string. ' + + 'Raw user-controllable expressions enable javascript:/data: URL XSS.', + unsafeHrefAssignment: + 'Direct `.href` assignment must use `safeHref(...)` or a static string ' + + '(WP-9 Track 3 link-surface hardening).', + unsafeWindowOpen: + '`window.open(...)` first argument must be `safeHref(...)` or a static ' + + 'string (WP-9 Track 3 link-surface hardening).', + unsafeSetAttribute: + '`setAttribute(\'href\', ...)` value must be `safeHref(...)` or a static ' + + 'string (WP-9 Track 3 link-surface hardening).', + }, + }, + create(context) { + const sourceCode = context.sourceCode || context.getSourceCode() + const parserServices = sourceCode.parserServices || {} + + // Vue template visitor is only available when the file was parsed by + // vue-eslint-parser. Combine via `defineTemplateBodyVisitor` when present; + // otherwise return the script-only visitor. + if (typeof parserServices.defineTemplateBodyVisitor === 'function') { + return parserServices.defineTemplateBodyVisitor( + buildTemplateVisitor(context), + buildScriptVisitor(context), + ) + } + return buildScriptVisitor(context) + }, +} diff --git a/eslint.config.js b/eslint.config.js index 29d38cde..fe704144 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,8 @@ import globals from 'globals'; const localRequire = createRequire(import.meta.url); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const noClaudeHomeReadsRule = localRequire('./eslint-rules/no-claude-home-reads.cjs'); +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const noUnsafeAnchorHrefRule = localRequire('./eslint-rules/no-unsafe-anchor-href.cjs'); const tsconfigRootDir = fileURLToPath(new URL('.', import.meta.url)); @@ -126,6 +128,8 @@ export default defineConfig( rules: { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'no-claude-home-reads': noClaudeHomeReadsRule, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 'no-unsafe-anchor-href': noUnsafeAnchorHrefRule, }, }, }, @@ -134,6 +138,20 @@ export default defineConfig( }, }, + // WP-9 Track 3 — preventive anchor-href guard. Scoped to the agent / + // chat surface where the audit was performed. WARN severity initially: + // the only `safeHref` consumer today is `MarkdownBlock.vue` (WP-4); the + // rule is preventive against future regressions. Promote to error once + // the WP-4 surface lands and the audit clears at error severity. The + // `local` plugin (with both rules registered) is bound to its files in + // the block above; this block only flips the second rule on. + { + files: ['src/ui/components/agent/**/*.vue', 'src/ui/components/chat/**/*.vue'], + rules: { + 'local/no-unsafe-anchor-href': 'warn', + }, + }, + // Wire @typescript-eslint/parser into vue-eslint-parser for