pi-lego 0.1.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/LICENSE +21 -0
- package/README.md +142 -0
- package/THIRD_PARTY_NOTICES.md +32 -0
- package/extension.ts +9 -0
- package/package.json +68 -0
- package/src/blocks/head.ts +13 -0
- package/src/blocks/tail.ts +13 -0
- package/src/detectors.ts +36 -0
- package/src/index.ts +110 -0
- package/src/shell.ts +55 -0
- package/src/wrappers.ts +190 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grant Hutchins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# pi-lego
|
|
2
|
+
|
|
3
|
+
Turn repeated agent corrections into reusable blocks.
|
|
4
|
+
|
|
5
|
+
pi-lego is a framework for executable agent conventions. A block detects a command pattern, explains why it is unhelpful, gives an actionable alternative, and can offer a reason-bearing local exception. The default extension includes the `head` and `tail` blocks.
|
|
6
|
+
|
|
7
|
+
This is corrective feedback, not a sandbox or permissions engine. It does not verify authorization, parse every shell construct, show confirmation dialogs, or grant permissions through slash commands.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
From a checkout:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install /path/to/pi-lego
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
After the package is published:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:pi-lego
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Restart pi or run `/reload`. The package's default extension registers the included blocks for `bash` and `cmux_open_terminal` tool calls.
|
|
24
|
+
|
|
25
|
+
## Included blocks
|
|
26
|
+
|
|
27
|
+
Ordinary command output should stream because pi already bounds model-visible output and preserves the full result when it truncates. Using `head` or `tail` to hide ordinary output loses evidence; let the full output stream and rely on the harness bounds instead.
|
|
28
|
+
|
|
29
|
+
### `head`
|
|
30
|
+
|
|
31
|
+
`head` waits for N lines or EOF. A finite producer that closes after fewer lines returns normally, but a live producer that keeps stdout open can wait indefinitely. Prefer producer-native bounds or a timeout for live streams. An initial-lines query can declare its narrow intent in the leading comment block:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# allow head: the initial lines are the query
|
|
35
|
+
head -n 50 app.log
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `tail`
|
|
39
|
+
|
|
40
|
+
A last-lines query can declare its narrow intent in the leading comment block:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# allow tail: the final lines are the query
|
|
44
|
+
journalctl --unit app | tail -n 50
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The leading comment block may contain blank lines and multiple standalone comments, so each matching block can have its own exception. Parsing stops at the first executable line. Reasons must be nonempty, but may contain ordinary punctuation because the comment is inert. Quoted strings, inline comments, malformed comments, and comments after an executable line do not bypass a block.
|
|
48
|
+
|
|
49
|
+
## Write a block
|
|
50
|
+
|
|
51
|
+
Blocks are small TypeScript objects. There is no JSON DSL.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
55
|
+
import { registerBlocks, type ConventionBlock } from "pi-lego";
|
|
56
|
+
|
|
57
|
+
const archive: ConventionBlock = {
|
|
58
|
+
id: "archive",
|
|
59
|
+
pattern: { command: "archive" },
|
|
60
|
+
rationale: "Archiving during an edit loop hides the files under review.",
|
|
61
|
+
alternative: "Inspect the working files directly.",
|
|
62
|
+
exception: {
|
|
63
|
+
description: "specific reason",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default function (pi: ExtensionAPI): void {
|
|
68
|
+
registerBlocks(pi, [archive]);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A command pattern matches executable positions through pipelines, control flow, substitutions, nested shell commands, and the built-in wrappers `command`, `nohup`, `sudo`, `env`, `timeout`, `xargs`, `find -exec`, `bash`/`sh`/`zsh`/`fish -c`, `op run --`, `op plugin run --`, and `mise exec`/`mise x ... --`. Use `detect(command)` instead when a convention needs custom matching; a block cannot define both. If `exception.comment` is omitted, it defaults to `allow <id>`. Omit `exception` entirely for a block that cannot be overridden.
|
|
73
|
+
|
|
74
|
+
If several blocks match, pi-lego reports all of them. Each exception only bypasses the block that owns its exact comment marker; all other matching blocks are still reported.
|
|
75
|
+
|
|
76
|
+
### Add command wrappers
|
|
77
|
+
|
|
78
|
+
Custom wrapper definitions compose with the built-ins. A declarative prefix can be a shell string or an exact token array:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
82
|
+
import { defineWrapper, registerBlocks } from "pi-lego";
|
|
83
|
+
|
|
84
|
+
const wrappers = [
|
|
85
|
+
defineWrapper({ prefix: "launcher start --" }),
|
|
86
|
+
defineWrapper({ prefix: ["runner", "exec", "--"] }),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export default function (pi: ExtensionAPI): void {
|
|
90
|
+
registerBlocks(pi, [archive], { wrappers });
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
String prefixes are parsed once by `defineWrapper`, so quoting is honored: `launcher 'special mode' --` contains three tokens. A string prefix must be one static simple command; assignments, redirects, pipelines, control operators, parameter expansion, and command substitution are rejected. Token arrays are already-tokenized exact prefixes and are not shell-expanded.
|
|
95
|
+
|
|
96
|
+
Use a resolver for wrappers whose options do not have one fixed prefix:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const unusual = defineWrapper({
|
|
100
|
+
command: "launcher",
|
|
101
|
+
resolve(args) {
|
|
102
|
+
const marker = args.indexOf("execute:");
|
|
103
|
+
return marker < 0 ? undefined : { words: args.slice(marker + 1) };
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A resolver can return `{ words: [...] }` for an already-tokenized command or `{ script: "..." }` for nested Bash source. It receives dequoted argument values; it never executes or expands them.
|
|
109
|
+
|
|
110
|
+
## Scope and limitations
|
|
111
|
+
|
|
112
|
+
- Matchers inspect command text before tool execution. They do not constrain other tools or commands launched outside these two pi tool calls.
|
|
113
|
+
- Shell structure is parsed without execution by [unbash](https://github.com/webpro-nl/unbash). It targets Bash (with much POSIX `sh` syntax), not PowerShell, `cmd.exe`, or every construct of other shells. Malformed input is inspected through unbash's best-effort partial AST; parser recovery can still omit an invocation.
|
|
114
|
+
- Wrapper definitions model command-specific argument semantics. The built-ins cover only the forms listed above; unsupported flags or an unusual form may require a custom resolver. Wrapper expansion is capped at 64 commands to stop cyclic custom definitions.
|
|
115
|
+
- Exceptions are local declarations of intent. They are not capabilities, signed approvals, or an audit system.
|
|
116
|
+
- A block author owns false-positive and false-negative behavior in its matcher.
|
|
117
|
+
|
|
118
|
+
## Positioning
|
|
119
|
+
|
|
120
|
+
Custom blocking hooks are already part of pi's extension API. pi-lego focuses on composing corrections for safe-but-wrong approaches: explain the convention, offer a useful alternative, and allow a narrow local exception when warranted.
|
|
121
|
+
|
|
122
|
+
The wrapper registry and flag-boundary approach were adapted from [`pi-guard`'s `src/wrappers.ts`](https://github.com/jdiamond/pi-guard/blob/main/src/wrappers.ts), by Jason Diamond, under the MIT license. See `THIRD_PARTY_NOTICES.md`. Shell parsing uses unbash 4.0.11 under the ISC license.
|
|
123
|
+
|
|
124
|
+
Related projects cover adjacent needs:
|
|
125
|
+
|
|
126
|
+
- [pi-guardrails](https://github.com/aliou/pi-guardrails) focuses on dangerous operations, secrets, and protected files.
|
|
127
|
+
- [pi-permission-system](https://github.com/MasuRii/pi-permission-system) provides centralized allow, deny, and ask decisions.
|
|
128
|
+
- [pi-guard](https://github.com/jdiamond/pi-guard) provides extensible matchers and shell parsing.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
Requires Node.js 22.19 or newer.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm install
|
|
136
|
+
npm run check
|
|
137
|
+
npm pack --dry-run
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## pi-guard wrapper machinery
|
|
4
|
+
|
|
5
|
+
Parts of the wrapper registry and flag-boundary approach in `src/wrappers.ts`
|
|
6
|
+
were adapted from
|
|
7
|
+
[`pi-guard/src/wrappers.ts`](https://github.com/jdiamond/pi-guard/blob/main/src/wrappers.ts),
|
|
8
|
+
copyright Jason Diamond, licensed under the MIT License:
|
|
9
|
+
|
|
10
|
+
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
> of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
> in the Software without restriction, including without limitation the rights
|
|
13
|
+
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
> copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
> furnished to do so, subject to the following conditions:
|
|
16
|
+
>
|
|
17
|
+
> The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
> copies or substantial portions of the Software.
|
|
19
|
+
>
|
|
20
|
+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
> SOFTWARE.
|
|
27
|
+
|
|
28
|
+
## unbash
|
|
29
|
+
|
|
30
|
+
Shell parsing is provided by [unbash](https://github.com/webpro-nl/unbash),
|
|
31
|
+
copyright Lars Kappert, licensed under the ISC License. Its license is included
|
|
32
|
+
with the installed `unbash` package.
|
package/extension.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { headBlock } from "./src/blocks/head.ts";
|
|
4
|
+
import { tailBlock } from "./src/blocks/tail.ts";
|
|
5
|
+
import { registerBlocks } from "./src/index.ts";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI): void {
|
|
8
|
+
registerBlocks(pi, [headBlock, tailBlock]);
|
|
9
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-lego",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turn repeated agent corrections into reusable blocks.",
|
|
5
|
+
"author": "Grant Hutchins",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/nertzy/pi-lego.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/nertzy/pi-lego#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/nertzy/pi-lego/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"agent-conventions",
|
|
21
|
+
"guardrails"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.19.0"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"extension.ts",
|
|
28
|
+
"src/**/*.ts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"THIRD_PARTY_NOTICES.md"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./src/index.ts",
|
|
35
|
+
"./blocks/head": "./src/blocks/head.ts",
|
|
36
|
+
"./blocks/tail": "./src/blocks/tail.ts"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"provenance": true
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test test/*.test.ts",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"check": "npm test && npm run typecheck"
|
|
46
|
+
},
|
|
47
|
+
"pi": {
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./extension.ts"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@earendil-works/pi-coding-agent": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
62
|
+
"@types/node": "^22.15.0",
|
|
63
|
+
"typescript": "^5.8.0"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"unbash": "4.0.11"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ConventionBlock } from "../index.ts";
|
|
2
|
+
|
|
3
|
+
export const headBlock: ConventionBlock = {
|
|
4
|
+
id: "head",
|
|
5
|
+
pattern: { command: "head" },
|
|
6
|
+
rationale:
|
|
7
|
+
"Pi already bounds model-visible output and preserves the full result when it truncates, so hiding ordinary output with `head` loses useful evidence. `head` also waits for N lines or EOF; a live producer that emits fewer lines while keeping stdout open can wait indefinitely.",
|
|
8
|
+
alternative:
|
|
9
|
+
"Let the full output stream and rely on pi's bounds. For live streams, use producer-native bounds or a timeout; use `head` only when the initial lines are the actual query.",
|
|
10
|
+
exception: {
|
|
11
|
+
description: "specific reason",
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ConventionBlock } from "../index.ts";
|
|
2
|
+
|
|
3
|
+
export const tailBlock: ConventionBlock = {
|
|
4
|
+
id: "tail",
|
|
5
|
+
pattern: { command: "tail" },
|
|
6
|
+
rationale:
|
|
7
|
+
"Pi already bounds model-visible output and preserves the full result when it truncates, so hiding ordinary output with `tail` loses useful evidence.",
|
|
8
|
+
alternative:
|
|
9
|
+
"Let the output stream. Use a producer's own filters when the query itself is narrow.",
|
|
10
|
+
exception: {
|
|
11
|
+
description: "specific reason",
|
|
12
|
+
},
|
|
13
|
+
};
|
package/src/detectors.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { shellCommands, type ShellCommand } from "./shell.ts";
|
|
2
|
+
import { expandWrappers, type WrapperDefinition, type WrapperTarget } from "./wrappers.ts";
|
|
3
|
+
|
|
4
|
+
const basename = (word: string): string => word.split("/").pop() ?? word;
|
|
5
|
+
|
|
6
|
+
function named(word: string | undefined, name: string): boolean {
|
|
7
|
+
if (word === undefined) return false;
|
|
8
|
+
return name.includes("/") ? word === name || word.endsWith(`/${name}`) : basename(word) === name;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function targetCommands(target: WrapperTarget): ShellCommand[] {
|
|
12
|
+
if ("script" in target) return shellCommands(target.script);
|
|
13
|
+
return target.words.length === 0 ? [] : [{ words: [...target.words] }];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function invokesCommand(
|
|
17
|
+
name: string,
|
|
18
|
+
wrappers: readonly WrapperDefinition[] = [],
|
|
19
|
+
): (command: string) => boolean {
|
|
20
|
+
return (source: string): boolean => {
|
|
21
|
+
const queue = shellCommands(source);
|
|
22
|
+
|
|
23
|
+
// Wrapper recursion is deliberately bounded: definitions should unwrap toward
|
|
24
|
+
// a payload command. Raise this ceiling if a real command stack exceeds it.
|
|
25
|
+
for (let index = 0; index < queue.length && index < 64; index += 1) {
|
|
26
|
+
const command = queue[index];
|
|
27
|
+
if (command && named(command.words[0], name)) return true;
|
|
28
|
+
if (command) {
|
|
29
|
+
for (const target of expandWrappers(command, wrappers)) {
|
|
30
|
+
queue.push(...targetCommands(target));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import { invokesCommand } from "./detectors.ts";
|
|
5
|
+
export {
|
|
6
|
+
builtInWrappers,
|
|
7
|
+
defineWrapper,
|
|
8
|
+
type WrapperDefinition,
|
|
9
|
+
type WrapperInput,
|
|
10
|
+
type WrapperInvocation,
|
|
11
|
+
type WrapperTarget,
|
|
12
|
+
} from "./wrappers.ts";
|
|
13
|
+
import type { WrapperDefinition } from "./wrappers.ts";
|
|
14
|
+
|
|
15
|
+
interface ConventionBlockBase {
|
|
16
|
+
id: string;
|
|
17
|
+
rationale: string;
|
|
18
|
+
alternative: string;
|
|
19
|
+
exception?: {
|
|
20
|
+
comment?: string;
|
|
21
|
+
description: string;
|
|
22
|
+
note?: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ConventionBlock = ConventionBlockBase &
|
|
27
|
+
(
|
|
28
|
+
| { pattern: { command: string }; detect?: never }
|
|
29
|
+
| { detect(command: string): boolean; pattern?: never }
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
function exceptionComment(block: ConventionBlock): string {
|
|
33
|
+
return block.exception?.comment ?? `allow ${block.id}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EvaluationOptions {
|
|
37
|
+
wrappers?: readonly WrapperDefinition[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function detects(
|
|
41
|
+
command: string,
|
|
42
|
+
block: ConventionBlock,
|
|
43
|
+
wrappers: readonly WrapperDefinition[],
|
|
44
|
+
): boolean {
|
|
45
|
+
return block.pattern
|
|
46
|
+
? invokesCommand(block.pattern.command, wrappers)(command)
|
|
47
|
+
: block.detect(command);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasException(command: string, block: ConventionBlock): boolean {
|
|
51
|
+
if (!block.exception) return false;
|
|
52
|
+
const prefix = `# ${exceptionComment(block)}:`;
|
|
53
|
+
|
|
54
|
+
for (const sourceLine of command.split("\n")) {
|
|
55
|
+
const line = sourceLine.trim();
|
|
56
|
+
if (line === "") continue;
|
|
57
|
+
if (!line.startsWith("#")) return false;
|
|
58
|
+
if (line.startsWith(prefix) && line.slice(prefix.length).trim() !== "") return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function feedback(block: ConventionBlock): string {
|
|
65
|
+
const lines = [
|
|
66
|
+
`Blocked by ${block.id}.`,
|
|
67
|
+
block.rationale,
|
|
68
|
+
`Instead: ${block.alternative}`,
|
|
69
|
+
];
|
|
70
|
+
if (block.exception) {
|
|
71
|
+
lines.push(
|
|
72
|
+
`Exception: put \`# ${exceptionComment(block)}: <${block.exception.description}>\` in the leading comment block.`,
|
|
73
|
+
);
|
|
74
|
+
if (block.exception.note) lines.push(block.exception.note);
|
|
75
|
+
}
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function evaluateCommand(
|
|
80
|
+
command: string,
|
|
81
|
+
blocks: readonly ConventionBlock[],
|
|
82
|
+
options: EvaluationOptions = {},
|
|
83
|
+
): string | undefined {
|
|
84
|
+
const wrappers = options.wrappers ?? [];
|
|
85
|
+
const violations = blocks.filter(
|
|
86
|
+
(block) => detects(command, block, wrappers) && !hasException(command, block),
|
|
87
|
+
);
|
|
88
|
+
if (violations.length === 0) return undefined;
|
|
89
|
+
return violations.map(feedback).join("\n\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function registerBlocks(
|
|
93
|
+
pi: ExtensionAPI,
|
|
94
|
+
blocks: readonly ConventionBlock[],
|
|
95
|
+
options: EvaluationOptions = {},
|
|
96
|
+
): void {
|
|
97
|
+
pi.on("tool_call", (event) => {
|
|
98
|
+
let command: string | undefined;
|
|
99
|
+
if (isToolCallEventType("bash", event)) {
|
|
100
|
+
command = event.input.command;
|
|
101
|
+
} else if (event.toolName === "cmux_open_terminal") {
|
|
102
|
+
const input = event.input as { command?: unknown };
|
|
103
|
+
if (typeof input.command === "string") command = input.command;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (command === undefined) return undefined;
|
|
107
|
+
const reason = evaluateCommand(command, blocks, options);
|
|
108
|
+
return reason ? { block: true, reason } : undefined;
|
|
109
|
+
});
|
|
110
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parse, type Command, type ParsedScript, type Word, type WordPart } from "unbash";
|
|
2
|
+
|
|
3
|
+
export interface ShellCommand {
|
|
4
|
+
words: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function visitWordPart(part: WordPart, visit: (value: unknown) => void): void {
|
|
8
|
+
visit(part);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function visitWord(word: Word | undefined, visit: (value: unknown) => void): void {
|
|
12
|
+
if (!word) return;
|
|
13
|
+
for (const part of word.parts ?? []) visitWordPart(part, visit);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extract simple command invocations, including commands nested in expansions. */
|
|
17
|
+
export function shellCommands(source: string): ShellCommand[] {
|
|
18
|
+
const commands: ShellCommand[] = [];
|
|
19
|
+
const seen = new Set<object>();
|
|
20
|
+
|
|
21
|
+
const visit = (value: unknown): void => {
|
|
22
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
23
|
+
seen.add(value);
|
|
24
|
+
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
for (const child of value) visit(child);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const candidate = value as { type?: string };
|
|
31
|
+
if (candidate.type === "Command") {
|
|
32
|
+
const command = candidate as Command;
|
|
33
|
+
if (command.name) {
|
|
34
|
+
commands.push({
|
|
35
|
+
words: [command.name.value, ...command.suffix.map((word) => word.value)],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
visitWord(command.name, visit);
|
|
39
|
+
for (const word of command.suffix) visitWord(word, visit);
|
|
40
|
+
for (const assignment of command.prefix) visitWord(assignment.value, visit);
|
|
41
|
+
for (const redirect of command.redirects) {
|
|
42
|
+
visitWord(redirect.target, visit);
|
|
43
|
+
visitWord(redirect.body, visit);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const child of Object.values(value)) visit(child);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// unbash intentionally returns a useful partial tree alongside parse errors.
|
|
52
|
+
// Convention detection uses that tree; it does not claim malformed input is valid.
|
|
53
|
+
visit(parse(source) satisfies ParsedScript);
|
|
54
|
+
return commands;
|
|
55
|
+
}
|
package/src/wrappers.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { parse, type Command, type Word, type WordPart } from "unbash";
|
|
2
|
+
|
|
3
|
+
import type { ShellCommand } from "./shell.ts";
|
|
4
|
+
|
|
5
|
+
export interface WrapperInvocation {
|
|
6
|
+
command: string;
|
|
7
|
+
args: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type WrapperTarget =
|
|
11
|
+
| { words: readonly string[] }
|
|
12
|
+
| { script: string };
|
|
13
|
+
|
|
14
|
+
export interface WrapperDefinition {
|
|
15
|
+
command: string;
|
|
16
|
+
resolve(invocation: WrapperInvocation): WrapperTarget | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type WrapperInput =
|
|
20
|
+
| { prefix: string | readonly string[] }
|
|
21
|
+
| {
|
|
22
|
+
command: string;
|
|
23
|
+
resolve(args: readonly string[]): WrapperTarget | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const basename = (word: string): string => word.split("/").pop() ?? word;
|
|
27
|
+
const assignment = (word: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
|
|
28
|
+
|
|
29
|
+
function staticPart(part: WordPart): boolean {
|
|
30
|
+
switch (part.type) {
|
|
31
|
+
case "Literal":
|
|
32
|
+
case "SingleQuoted":
|
|
33
|
+
case "AnsiCQuoted":
|
|
34
|
+
return true;
|
|
35
|
+
case "DoubleQuoted":
|
|
36
|
+
case "LocaleString":
|
|
37
|
+
return part.parts.every(staticPart);
|
|
38
|
+
default:
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function staticWord(word: Word): boolean {
|
|
44
|
+
return (word.parts ?? []).every(staticPart);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parsePrefix(prefix: string): string[] {
|
|
48
|
+
const ast = parse(prefix);
|
|
49
|
+
const statement = ast.commands[0];
|
|
50
|
+
const node = statement?.command;
|
|
51
|
+
if (
|
|
52
|
+
prefix.trim() === "" ||
|
|
53
|
+
ast.errors?.length ||
|
|
54
|
+
ast.commands.length !== 1 ||
|
|
55
|
+
!node ||
|
|
56
|
+
node.type !== "Command" ||
|
|
57
|
+
!node.name ||
|
|
58
|
+
node.prefix.length > 0 ||
|
|
59
|
+
node.redirects.length > 0
|
|
60
|
+
) {
|
|
61
|
+
throw new TypeError("Wrapper prefix must be a single static command prefix");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const command = node as Command;
|
|
65
|
+
const words: Word[] = [node.name, ...command.suffix];
|
|
66
|
+
if (!words.every(staticWord)) {
|
|
67
|
+
throw new TypeError("Wrapper prefix must be a single static command prefix");
|
|
68
|
+
}
|
|
69
|
+
return words.map((word) => word.value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Normalize a declarative or custom wrapper once at its configuration boundary. */
|
|
73
|
+
export function defineWrapper(input: WrapperInput): WrapperDefinition {
|
|
74
|
+
if ("prefix" in input) {
|
|
75
|
+
const prefix = typeof input.prefix === "string" ? parsePrefix(input.prefix) : [...input.prefix];
|
|
76
|
+
if (prefix.length === 0 || prefix.some((word) => word.length === 0)) {
|
|
77
|
+
throw new TypeError("Wrapper prefix must be a single static command prefix");
|
|
78
|
+
}
|
|
79
|
+
const [command = "", ...expectedArgs] = prefix;
|
|
80
|
+
return {
|
|
81
|
+
command: basename(command),
|
|
82
|
+
resolve({ args }) {
|
|
83
|
+
if (!expectedArgs.every((word, index) => args[index] === word)) return undefined;
|
|
84
|
+
return { words: args.slice(expectedArgs.length) };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
command: basename(input.command),
|
|
91
|
+
resolve: ({ args }) => input.resolve(args),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function skipFlags(
|
|
96
|
+
args: readonly string[],
|
|
97
|
+
valueFlags: readonly string[] = [],
|
|
98
|
+
skipAssignments = false,
|
|
99
|
+
): number {
|
|
100
|
+
let index = 0;
|
|
101
|
+
while (index < args.length) {
|
|
102
|
+
const arg = args[index] ?? "";
|
|
103
|
+
if (skipAssignments && assignment(arg)) {
|
|
104
|
+
index += 1;
|
|
105
|
+
} else if (arg === "--") {
|
|
106
|
+
return index + 1;
|
|
107
|
+
} else if (!arg.startsWith("-")) {
|
|
108
|
+
return index;
|
|
109
|
+
} else if (
|
|
110
|
+
!arg.includes("=") &&
|
|
111
|
+
valueFlags.includes(arg) &&
|
|
112
|
+
index + 1 < args.length
|
|
113
|
+
) {
|
|
114
|
+
index += 2;
|
|
115
|
+
} else {
|
|
116
|
+
index += 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return index;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const prefix = (value: string | readonly string[]): WrapperDefinition =>
|
|
123
|
+
defineWrapper({ prefix: value });
|
|
124
|
+
|
|
125
|
+
const custom = (
|
|
126
|
+
command: string,
|
|
127
|
+
resolve: (args: readonly string[]) => WrapperTarget | undefined,
|
|
128
|
+
): WrapperDefinition => defineWrapper({ command, resolve });
|
|
129
|
+
|
|
130
|
+
export const builtInWrappers: readonly WrapperDefinition[] = [
|
|
131
|
+
custom("command", (args) => ({ words: args.slice(skipFlags(args)) })),
|
|
132
|
+
prefix(["nohup"]),
|
|
133
|
+
custom("sudo", (args) => ({
|
|
134
|
+
words: args.slice(skipFlags(args, ["-C", "-D", "-g", "-p", "-r", "-R", "-t", "-T", "-U", "-u"])),
|
|
135
|
+
})),
|
|
136
|
+
custom("env", (args) => ({
|
|
137
|
+
words: args.slice(skipFlags(args, ["-C", "-S", "-u"], true)),
|
|
138
|
+
})),
|
|
139
|
+
custom("timeout", (args) => {
|
|
140
|
+
const boundary = skipFlags(args, ["-k", "--kill-after", "-s", "--signal"]);
|
|
141
|
+
return { words: args.slice(boundary + 1) };
|
|
142
|
+
}),
|
|
143
|
+
custom("xargs", (args) => ({
|
|
144
|
+
words: args.slice(skipFlags(args, ["-a", "-d", "-E", "-e", "-I", "-i", "-L", "-l", "-n", "-P", "-s"])),
|
|
145
|
+
})),
|
|
146
|
+
custom("find", (args) => {
|
|
147
|
+
const marker = args.findIndex((arg) => arg === "-exec" || arg === "-execdir");
|
|
148
|
+
if (marker < 0) return undefined;
|
|
149
|
+
const words = args.slice(marker + 1);
|
|
150
|
+
const end = words.findIndex((word) => word === ";" || word === "+");
|
|
151
|
+
return { words: end < 0 ? words : words.slice(0, end) };
|
|
152
|
+
}),
|
|
153
|
+
custom("bash", shellScript),
|
|
154
|
+
custom("sh", shellScript),
|
|
155
|
+
custom("zsh", shellScript),
|
|
156
|
+
custom("fish", shellScript),
|
|
157
|
+
custom("op", (args) => {
|
|
158
|
+
if (args[0] !== "run" && !(args[0] === "plugin" && args[1] === "run")) return undefined;
|
|
159
|
+
const separator = args.indexOf("--");
|
|
160
|
+
return separator < 0 ? undefined : { words: args.slice(separator + 1) };
|
|
161
|
+
}),
|
|
162
|
+
custom("mise", (args) => {
|
|
163
|
+
if (args[0] !== "exec" && args[0] !== "x") return undefined;
|
|
164
|
+
const separator = args.indexOf("--");
|
|
165
|
+
return separator < 0 ? undefined : { words: args.slice(separator + 1) };
|
|
166
|
+
}),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
function shellScript(args: readonly string[]): WrapperTarget | undefined {
|
|
170
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
171
|
+
const arg = args[index] ?? "";
|
|
172
|
+
if (/^-[^-]*c/.test(arg)) {
|
|
173
|
+
const script = args[index + 1];
|
|
174
|
+
return script === undefined ? undefined : { script };
|
|
175
|
+
}
|
|
176
|
+
if (!arg.startsWith("-")) return undefined;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function expandWrappers(
|
|
182
|
+
command: ShellCommand,
|
|
183
|
+
customWrappers: readonly WrapperDefinition[],
|
|
184
|
+
): WrapperTarget[] {
|
|
185
|
+
const name = basename(command.words[0] ?? "");
|
|
186
|
+
const invocation = { command: name, args: command.words.slice(1) };
|
|
187
|
+
return [...builtInWrappers, ...customWrappers]
|
|
188
|
+
.filter((wrapper) => wrapper.command === name)
|
|
189
|
+
.flatMap((wrapper) => wrapper.resolve(invocation) ?? []);
|
|
190
|
+
}
|