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 = `
+
+ click
+
+`
+const vueValidStatic = `
+
+ click
+
+`
+const vueValidNonAnchor = `
+
+ not an anchor
+
+`
+const vueInvalid = `
+
+ click
+
+`
+const vueInvalidVBind = `
+
+ click
+
+`
+
+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