From a4bf776b9229d8aa2d1924211479180d55169165 Mon Sep 17 00:00:00 2001 From: Cedric Karungu Date: Sun, 4 Oct 2026 15:46:40 +0200 Subject: [PATCH 1/2] fix(core): rule on a publish whichever client ran it --- .changeset/rule-on-every-publish.md | 12 ++++ packages/core/src/intercept/binary-class.ts | 12 +++- packages/core/src/intercept/npm-workalike.ts | 26 ++++++++ packages/core/src/intercept/resolve.ts | 14 +++++ packages/core/src/verbs/tables.ts | 3 + packages/core/src/verbs/tables/code.ts | 21 +++++++ packages/core/src/verbs/tables/deploy.ts | 29 +++++++++ packages/core/test/resolve.test.ts | 63 ++++++++++++++++++++ 8 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 .changeset/rule-on-every-publish.md create mode 100644 packages/core/src/intercept/npm-workalike.ts diff --git a/.changeset/rule-on-every-publish.md b/.changeset/rule-on-every-publish.md new file mode 100644 index 00000000..8503b383 --- /dev/null +++ b/.changeset/rule-on-every-publish.md @@ -0,0 +1,12 @@ +--- +'@memnox/core': patch +'@memnox/proxy': patch +'@memnox/interceptors': patch +'memnox': patch +--- + +Publishing is ruled on whichever client ran it. `npm publish` resolved to `npm.publish`, and `pnpm publish`, `yarn npm publish` and `bun publish` were ordinary shell lines, so a rule naming `npm.publish` covered the one spelling most projects do not use. All four now reach the same action, along with `unpublish` and `dist-tag`, and the reason still names the client that ran it. Only the publishing verbs route that way: `pnpm add` is its own install and stays one. + +`cargo`, `helm` and `pulumi` have tables of their own. Destructive: `cargo yank`, `helm uninstall`, `helm delete`, `pulumi destroy`. Write: `cargo publish` and `cargo install`, `helm install`, `helm upgrade` and `helm rollback`, `pulumi up` and `pulumi config set`. Read: `cargo search`, `helm list`, `helm status`, `helm get`, `pulumi preview` and `pulumi stack ls`. + +`bun`, `uv`, `pipx`, `gem` and `brew` are recognised as installing code that then runs on the machine, which only npm, pnpm, yarn and pip were. diff --git a/packages/core/src/intercept/binary-class.ts b/packages/core/src/intercept/binary-class.ts index 1af4a17d..8b6a1adc 100644 --- a/packages/core/src/intercept/binary-class.ts +++ b/packages/core/src/intercept/binary-class.ts @@ -227,8 +227,13 @@ const PACKAGE_VALUE_FLAGS: Readonly>> = { /** Managers whose bare invocation installs from the lockfile rather than printing help. */ const BARE_INSTALLS = new Set(['yarn', 'pnpm']); +/** `uv pip install x` puts the install one word further along than every other manager. */ +const PIP_FRONTED = new Set(['uv']); + function classifyPackageManager(binary: string, args: readonly string[]): BinaryVerdict { - const [verb, target] = positionalArgs(args, PACKAGE_VALUE_FLAGS[binary] ?? new Set()); + const words = positionalArgs(args, PACKAGE_VALUE_FLAGS[binary] ?? new Set()); + const [verb, target] = + PIP_FRONTED.has(binary) && words[0] === 'pip' ? words.slice(1) : words; const installing = verb === undefined ? BARE_INSTALLS.has(binary) : PACKAGE_INSTALL_VERBS.includes(verb); return { @@ -436,6 +441,11 @@ const CLASSIFIERS: Readonly> = { yarn: classifyPackageManager, pip: classifyPackageManager, pip3: classifyPackageManager, + bun: classifyPackageManager, + uv: classifyPackageManager, + pipx: classifyPackageManager, + gem: classifyPackageManager, + brew: classifyPackageManager, }; export function interceptedBinaries(): readonly string[] { diff --git a/packages/core/src/intercept/npm-workalike.ts b/packages/core/src/intercept/npm-workalike.ts new file mode 100644 index 00000000..56962cd5 --- /dev/null +++ b/packages/core/src/intercept/npm-workalike.ts @@ -0,0 +1,26 @@ +/** + * pnpm, yarn and bun push to the registry npm pushes to, so a rule naming `npm.publish` + * has to cover the publish whichever of them ran it. Only the publishing verbs: an + * install is each client's own and belongs with the package-manager classifier. + */ + +/** The publishing verbs all four clients spell the way npm does. */ +const PUBLISH_VERBS: readonly string[] = ['publish', 'unpublish', 'dist-tag']; + +const WORKALIKES: readonly string[] = ['pnpm', 'yarn', 'bun']; + +interface NpmPublish { + /** The command as npm's own table reads it. */ + words: readonly string[]; + /** The client that actually ran it, so a row names what a person typed. */ + because: string; +} + +/** Null when this is not one of npm's workalikes publishing. */ +export function npmPublishIn(binary: string, args: readonly string[]): NpmPublish | null { + if (!WORKALIKES.includes(binary)) return null; + // `yarn npm publish` is yarn's own spelling of the same command. + const words = binary === 'yarn' && args[0] === 'npm' ? args.slice(1) : args; + if (!PUBLISH_VERBS.includes(words[0] ?? '')) return null; + return { words, because: `${binary} ${words.join(' ')}`.trim() }; +} diff --git a/packages/core/src/intercept/resolve.ts b/packages/core/src/intercept/resolve.ts index 84b9ccde..7820a65c 100644 --- a/packages/core/src/intercept/resolve.ts +++ b/packages/core/src/intercept/resolve.ts @@ -2,6 +2,7 @@ * One command line, one action name, for every surface with an opinion about a command, * because two resolvers would mean a rule written from one screen failing at another. */ +import { npmPublishIn } from './npm-workalike'; import { classifyBinary, classifyReader, COMMAND_CLASS } from './binary-class'; import { classifyWriter } from './writers'; import { loosens, MEMNOX_ACTION_PREFIX } from '../gate/self-protection'; @@ -75,6 +76,7 @@ export function resolveAction( return ( resolveMemnox(binary, args) ?? resolveSqlStatement(binary, args, env, options.stdin) ?? + resolveNpmWorkalike(binary, args, env) ?? resolveFromVerbTable(binary, args, env) ?? resolveReader(binary, args, env) ?? resolveWriter(binary, args, env) ?? @@ -132,6 +134,18 @@ function resolveSqlStatement( }; } +/** npm's workalikes publish to the same registry, so one rule covers all four. */ +function resolveNpmWorkalike( + binary: string, + args: readonly string[], + env: NodeJS.ProcessEnv, +): ResolvedAction | null { + const publishing = npmPublishIn(binary, args); + if (publishing === null) return null; + const ruled = resolveFromVerbTable('npm', publishing.words, env); + return ruled === null ? null : { ...ruled, because: publishing.because }; +} + function resolveFromVerbTable( binary: string, args: readonly string[], diff --git a/packages/core/src/verbs/tables.ts b/packages/core/src/verbs/tables.ts index d945c6cd..60486cdf 100644 --- a/packages/core/src/verbs/tables.ts +++ b/packages/core/src/verbs/tables.ts @@ -27,6 +27,9 @@ const ORDER = [ 'mysql', 'mongosh', 'npm', + 'cargo', + 'helm', + 'pulumi', 'stripe', 'git', 'playwright', diff --git a/packages/core/src/verbs/tables/code.ts b/packages/core/src/verbs/tables/code.ts index 0aa86f70..19711dd9 100644 --- a/packages/core/src/verbs/tables/code.ts +++ b/packages/core/src/verbs/tables/code.ts @@ -252,6 +252,27 @@ export const CODE_TABLES: readonly VerbTable[] = [ { match: 'help **', class: READ }, ], }, + { + name: 'cargo', + credential: ['~/.cargo/credentials.toml', 'CARGO_REGISTRY_TOKEN'], + headline: 'can publish crates', + verbs: [ + { match: 'yank **', class: GONE, note: 'nobody can depend on that version again' }, + { + match: 'publish **', + class: WRITE, + tags: [PROD], + note: 'everyone can install it', + }, + { match: 'owner **', class: WRITE }, + { + match: 'install **', + class: WRITE, + note: 'builds and runs code from the registry', + }, + { match: 'search **', class: READ }, + ], + }, { name: 'npm', credential: ['~/.npmrc', 'NPM_TOKEN'], diff --git a/packages/core/src/verbs/tables/deploy.ts b/packages/core/src/verbs/tables/deploy.ts index a8783c2f..5de1d3bb 100644 --- a/packages/core/src/verbs/tables/deploy.ts +++ b/packages/core/src/verbs/tables/deploy.ts @@ -9,6 +9,35 @@ const PROD = VERB_TAG.PRODUCTION; const SECRETS = VERB_TAG.SECRETS; export const DEPLOY_TABLES: readonly VerbTable[] = [ + { + name: 'helm', + credential: ['~/.kube/config', 'KUBECONFIG'], + headline: 'can change what runs in a cluster', + globalFlags: ['--kube-context', '-n', '--namespace', '--kubeconfig'], + verbs: [ + { match: 'uninstall **', class: GONE, note: 'the release and its resources go' }, + { match: 'delete **', class: GONE }, + { match: 'rollback **', class: WRITE }, + { match: 'upgrade **', class: WRITE, tags: [PROD] }, + { match: 'install **', class: WRITE, tags: [PROD] }, + { match: 'list **', class: READ }, + { match: 'status **', class: READ }, + { match: 'get **', class: READ }, + ], + }, + { + name: 'pulumi', + credential: ['~/.pulumi/credentials.json', 'PULUMI_ACCESS_TOKEN'], + headline: 'can change infrastructure', + globalFlags: ['-s', '--stack', '--cwd'], + verbs: [ + { match: 'destroy **', class: GONE, note: 'every resource in the stack goes' }, + { match: 'up **', class: WRITE, tags: [PROD] }, + { match: 'config set **', class: WRITE, tags: [SECRETS] }, + { match: 'preview **', class: READ }, + { match: 'stack ls **', class: READ }, + ], + }, { name: 'vercel', credential: ['~/.vercel/auth.json', 'VERCEL_TOKEN'], diff --git a/packages/core/test/resolve.test.ts b/packages/core/test/resolve.test.ts index dcb0ce21..de4db92f 100644 --- a/packages/core/test/resolve.test.ts +++ b/packages/core/test/resolve.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { TOOL_CLASS } from '../src/discovery/classify'; +import { COMMAND_CLASS } from '../src/intercept/binary-class'; import { resolveAction, resolveShellLine } from '../src/intercept/resolve'; import { takesLease } from '../src/coordination/writes'; @@ -163,3 +165,64 @@ describe('a line whose only dollar is quoted code', () => { expect(actions.filter((each) => each.class === 'write')).toEqual([]); }); }); + +/* Somebody who writes a rule on `npm.publish` reasonably believes publishing is covered. + Every spelling but npm's own reached the registry as an ordinary shell line. */ +describe('publishing, however it was run', () => { + const ruled = (words: string[]) => { + const [binary, ...rest] = words; + return resolveAction(binary ?? '', rest); + }; + + it.each([[['pnpm', 'publish']], [['yarn', 'npm', 'publish']], [['bun', 'publish']]])( + '%j resolves to the action npm publish does', + (words) => { + expect(ruled(words as string[]).action).toBe(ruled(['npm', 'publish']).action); + }, + ); + + it('names the client that ran it, even though the action is npm.publish', () => { + expect(ruled(['pnpm', 'publish']).because).toContain('pnpm'); + }); + + it.each([ + [['pnpm', 'unpublish', 'pkg'], 'npm.unpublish', TOOL_CLASS.DESTRUCTIVE], + [ + ['pnpm', 'dist-tag', 'add', 'pkg@1', 'latest'], + 'npm.dist-tag-add', + TOOL_CLASS.WRITE, + ], + [['cargo', 'yank', '--version', '1.0.0'], 'cargo.yank', TOOL_CLASS.DESTRUCTIVE], + [['cargo', 'publish'], 'cargo.publish', TOOL_CLASS.WRITE], + [['cargo', 'install', 'ripgrep'], 'cargo.install', TOOL_CLASS.WRITE], + [['cargo', 'search', 'serde'], 'cargo.search', TOOL_CLASS.READ], + [['helm', 'uninstall', 'api'], 'helm.uninstall', TOOL_CLASS.DESTRUCTIVE], + [['helm', 'upgrade', 'api', './chart'], 'helm.upgrade', TOOL_CLASS.WRITE], + [['helm', 'list'], 'helm.list', TOOL_CLASS.READ], + [['pulumi', 'destroy'], 'pulumi.destroy', TOOL_CLASS.DESTRUCTIVE], + [['pulumi', 'up'], 'pulumi.up', TOOL_CLASS.WRITE], + [['pulumi', 'preview'], 'pulumi.preview', TOOL_CLASS.READ], + ])('%j is %s', (words, action, expected) => { + const resolved = ruled(words as string[]); + expect(resolved.action).toBe(action); + expect(resolved.class).toBe(expected); + }); + + it.each([ + [['bun', 'install']], + [['uv', 'pip', 'install', 'ruff']], + [['pipx', 'install', 'black']], + [['gem', 'install', 'rails']], + [['brew', 'install', 'jq']], + ])('%j installs code that then runs here', (words) => { + expect(ruled(words as string[]).class).toBe(COMMAND_CLASS.PACKAGE_INSTALL); + }); + + /* Only the publishing verbs route onto npm's table: an install is its own thing. */ + it.each([[['pnpm', 'add', 'x']], [['pnpm', 'install']], [['yarn', 'add', 'x']]])( + '%j is still a package install, not an npm table verb', + (words) => { + expect(ruled(words as string[]).class).toBe(COMMAND_CLASS.PACKAGE_INSTALL); + }, + ); +}); From 5afe2f51c95f3b268bee56622223844c17fe6651 Mon Sep 17 00:00:00 2001 From: moise10r Date: Mon, 12 Oct 2026 01:17:23 +0200 Subject: [PATCH 2/2] chore: hold source-map-js above its advisory and ignore the unfixed braces one --- pnpm-lock.yaml | 13 ++++++++----- pnpm-workspace.yaml | 12 ++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f238721..6e8bba7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + source-map-js: '>=1.2.2' + importers: .: @@ -1380,8 +1383,8 @@ packages: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map@0.7.6: @@ -2532,7 +2535,7 @@ snapshots: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -2652,7 +2655,7 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 prettier@2.8.8: {} @@ -2750,7 +2753,7 @@ snapshots: smol-toml@1.8.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map@0.7.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 01e6153c..c97fd79d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,15 @@ packages: allowBuilds: better-sqlite3: true esbuild: true + +# source-map-js is a transitive dev dependency of vitest and tsup pulled below +# GHSA-68fv-2mgg-jv7q; the audit fails the build at high, so hold it at the patched line. +overrides: + source-map-js: '>=1.2.2' + +# braces is a transitive dev dependency of changesets, knip and vitest with no patched +# release yet (GHSA-vfj7-8cjw-p6xm lists none and 3.0.3 is still the latest), so the only +# way past the gate is to ignore it. Drop this line the day a fixed braces ships. +auditConfig: + ignoreGhsas: + - GHSA-vfj7-8cjw-p6xm