Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/rule-on-every-publish.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion packages/core/src/intercept/binary-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,13 @@ const PACKAGE_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
/** 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 {
Expand Down Expand Up @@ -436,6 +441,11 @@ const CLASSIFIERS: Readonly<Record<string, Classifier>> = {
yarn: classifyPackageManager,
pip: classifyPackageManager,
pip3: classifyPackageManager,
bun: classifyPackageManager,
uv: classifyPackageManager,
pipx: classifyPackageManager,
gem: classifyPackageManager,
brew: classifyPackageManager,
};

export function interceptedBinaries(): readonly string[] {
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/intercept/npm-workalike.ts
Original file line number Diff line number Diff line change
@@ -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() };
}
14 changes: 14 additions & 0 deletions packages/core/src/intercept/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) ??
Expand Down Expand Up @@ -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[],
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/verbs/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const ORDER = [
'mysql',
'mongosh',
'npm',
'cargo',
'helm',
'pulumi',
'stripe',
'git',
'playwright',
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/verbs/tables/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/verbs/tables/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
63 changes: 63 additions & 0 deletions packages/core/test/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
},
);
});
Loading