pi-mono-all 1.2.1 → 1.2.3
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/CHANGELOG.md +12 -0
- package/node_modules/pi-mono-linear/CHANGELOG.md +7 -0
- package/node_modules/pi-mono-linear/README.md +1 -0
- package/node_modules/pi-mono-linear/package.json +1 -1
- package/node_modules/pi-mono-linear/src/linear-client.ts +37 -2
- package/node_modules/pi-mono-linear/src/linear-queries.ts +3 -3
- package/node_modules/pi-mono-linear/src/linear-schemas.ts +1 -1
- package/node_modules/pi-mono-linear/src/linear-tools.ts +1 -1
- package/node_modules/pi-mono-sentinel/.pi/extensions/sentinel.json +7 -0
- package/node_modules/pi-mono-sentinel/CHANGELOG.md +15 -0
- package/node_modules/pi-mono-sentinel/README.md +59 -0
- package/node_modules/pi-mono-sentinel/__tests__/config.test.ts +60 -0
- package/node_modules/pi-mono-sentinel/__tests__/events.test.ts +45 -0
- package/node_modules/pi-mono-sentinel/__tests__/path-access.test.ts +75 -0
- package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +4 -0
- package/node_modules/pi-mono-sentinel/config.ts +193 -0
- package/node_modules/pi-mono-sentinel/events.ts +46 -0
- package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +13 -6
- package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +75 -44
- package/node_modules/pi-mono-sentinel/guards/path-access.ts +102 -0
- package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +34 -16
- package/node_modules/pi-mono-sentinel/index.ts +19 -3
- package/node_modules/pi-mono-sentinel/package.json +1 -1
- package/node_modules/pi-mono-sentinel/path-access.ts +74 -0
- package/node_modules/pi-mono-sentinel/patterns/bash-paths.ts +98 -0
- package/node_modules/pi-mono-sentinel/patterns/permissions.ts +146 -24
- package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +3 -1
- package/node_modules/pi-mono-sentinel/session.ts +6 -1
- package/node_modules/pi-mono-sentinel/utils/shell.ts +172 -0
- package/node_modules/pi-mono-sentinel/whitelist.ts +2 -12
- package/package.json +10 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# pi-mono-all
|
|
2
2
|
|
|
3
|
+
## 1.2.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Bundle `pi-mono-linear@0.2.3` with Linear schema drift fixes and team-key resolution for issue creation.
|
|
8
|
+
|
|
9
|
+
## 1.2.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Bundle `pi-mono-sentinel@1.11.0` with guardrails hardening, path-access grants, event emission, and internal simplifications.
|
|
14
|
+
|
|
3
15
|
## 1.2.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# pi-mono-linear
|
|
2
2
|
|
|
3
|
+
## 0.2.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Update Project GraphQL selections to use `teams` instead of removed `team` fields.
|
|
8
|
+
- Resolve `linear_create_issue` team keys to UUIDs before calling the Linear mutation and return clearer errors for unknown keys.
|
|
9
|
+
|
|
3
10
|
## 0.2.2
|
|
4
11
|
|
|
5
12
|
### Patch Changes
|
|
@@ -129,6 +129,7 @@ Linear API keys are sent in the `Authorization` header as the raw key value; do
|
|
|
129
129
|
## Usage tips
|
|
130
130
|
|
|
131
131
|
- Use `linear_workspace_metadata` first when team/project/state/label/user IDs are unknown.
|
|
132
|
+
- `linear_create_issue` accepts either a team UUID or a team key; keys are resolved to UUIDs before the Linear mutation.
|
|
132
133
|
- Use `linear_search_issues` for keyword lookup.
|
|
133
134
|
- Use `linear_get_issue` before updating an issue or creating a comment.
|
|
134
135
|
- Use `linear_list_issues` for filtered issue lists by team, assignee, status, and limit.
|
|
@@ -68,6 +68,18 @@ interface FileUploadMutationResponse {
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
interface LinearTeamNode {
|
|
72
|
+
id: string;
|
|
73
|
+
name?: string;
|
|
74
|
+
key?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ListTeamsResponse {
|
|
78
|
+
teams?: {
|
|
79
|
+
nodes?: LinearTeamNode[];
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
export interface UploadedFileResult {
|
|
72
84
|
filename: string;
|
|
73
85
|
contentType: string;
|
|
@@ -134,8 +146,9 @@ export class LinearClient {
|
|
|
134
146
|
return this.cached(`myIssues:${limit}`, () => this.graphql(queries.LIST_MY_ISSUES, { first: limit }));
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
createIssue(input: CreateIssueInput): Promise<unknown> {
|
|
138
|
-
|
|
149
|
+
async createIssue(input: CreateIssueInput): Promise<unknown> {
|
|
150
|
+
const teamId = await this.resolveTeamId(input.teamId);
|
|
151
|
+
return this.graphql(queries.CREATE_ISSUE, { input: compact({ ...input, teamId }) });
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
updateIssue(issueId: string, input: UpdateIssueInput): Promise<unknown> {
|
|
@@ -227,6 +240,24 @@ export class LinearClient {
|
|
|
227
240
|
return this.cached(`document:${documentId}`, () => this.graphql(queries.GET_DOCUMENT, { id: documentId }));
|
|
228
241
|
}
|
|
229
242
|
|
|
243
|
+
private async resolveTeamId(teamIdOrKey: string): Promise<string> {
|
|
244
|
+
const value = teamIdOrKey.trim();
|
|
245
|
+
if (!value) throw new ApiError("teamId is required", 400, undefined, "Linear");
|
|
246
|
+
if (isUuid(value)) return value;
|
|
247
|
+
|
|
248
|
+
const teams = await this.cached("teams", () => this.graphql<ListTeamsResponse>(queries.LIST_TEAMS));
|
|
249
|
+
const nodes = teams.teams?.nodes ?? [];
|
|
250
|
+
const match = nodes.find((team) => team.key?.toLowerCase() === value.toLowerCase());
|
|
251
|
+
if (match?.id) return match.id;
|
|
252
|
+
|
|
253
|
+
throw new ApiError(
|
|
254
|
+
`teamId must be a Linear team UUID or a known team key; "${value}" did not match any team key`,
|
|
255
|
+
400,
|
|
256
|
+
{ providedTeamId: value, availableTeamKeys: nodes.map((team) => team.key).filter(Boolean) },
|
|
257
|
+
"Linear",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
230
261
|
private async graphql<T = unknown>(query: string, variables: Variables = {}): Promise<T> {
|
|
231
262
|
return this.limiter.schedule(async () => {
|
|
232
263
|
const response = await this.http.post<GraphQlResponse<T>>("", { query, variables });
|
|
@@ -246,6 +277,10 @@ export function readLinearToken(): Promise<string> {
|
|
|
246
277
|
return readAuthToken({ envName: "LINEAR_API_KEY", authPath: ["linear", "key"] });
|
|
247
278
|
}
|
|
248
279
|
|
|
280
|
+
function isUuid(value: string): boolean {
|
|
281
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
282
|
+
}
|
|
283
|
+
|
|
249
284
|
function buildIssueFilter(options: ListIssuesOptions): Variables {
|
|
250
285
|
const filter: Variables = {};
|
|
251
286
|
if (options.teamId) filter.team = { id: { eq: options.teamId } };
|
|
@@ -51,9 +51,9 @@ export const UPDATE_ISSUE = `mutation($id: String!, $input: IssueUpdateInput!) {
|
|
|
51
51
|
issueUpdate(id: $id, input: $input) { success issue { id identifier title priority state { id name } } }
|
|
52
52
|
}`;
|
|
53
53
|
|
|
54
|
-
export const LIST_PROJECTS = `query { projects { nodes { id name description state
|
|
54
|
+
export const LIST_PROJECTS = `query { projects { nodes { id name description state teams { nodes { id name key } } } } }`;
|
|
55
55
|
export const LIST_TEAM_PROJECTS = `query($id: String!) { team(id: $id) { id name projects { nodes { id name description state } } } }`;
|
|
56
|
-
export const GET_PROJECT = `query($id: String!) { project(id: $id) { id name description state url
|
|
56
|
+
export const GET_PROJECT = `query($id: String!) { project(id: $id) { id name description state url teams { nodes { id name key } } lead { id name } } }`;
|
|
57
57
|
|
|
58
58
|
export const LIST_STATUSES = `query { workflowStates { nodes { id name type color position team { id name key } } } }`;
|
|
59
59
|
export const LIST_TEAM_STATUSES = `query($id: String!) { team(id: $id) { id name states { nodes { id name type color position } } } }`;
|
|
@@ -94,7 +94,7 @@ export const GET_DOCUMENT = `query($id: String!) { document(id: $id) { id title
|
|
|
94
94
|
|
|
95
95
|
export const WORKSPACE_METADATA = `query {
|
|
96
96
|
teams { nodes { id name key } }
|
|
97
|
-
projects { nodes { id name description state
|
|
97
|
+
projects { nodes { id name description state teams { nodes { id name key } } } }
|
|
98
98
|
workflowStates { nodes { id name type color position team { id name key } } }
|
|
99
99
|
issueLabels { nodes { id name color team { id name key } } }
|
|
100
100
|
users { nodes { id name email displayName } }
|
|
@@ -5,7 +5,7 @@ export const MaxResponseCharsSchema = Type.Optional(
|
|
|
5
5
|
);
|
|
6
6
|
|
|
7
7
|
export const LimitSchema = Type.Optional(Type.Number({ description: "Maximum number of records to fetch", minimum: 1, maximum: 250 }));
|
|
8
|
-
export const TeamIdSchema = Type.String({ description: "Linear team UUID or key
|
|
8
|
+
export const TeamIdSchema = Type.String({ description: "Linear team UUID or key (keys are resolved to UUIDs for issue creation)" });
|
|
9
9
|
export const IssueIdSchema = Type.String({ description: "Linear issue UUID or identifier such as ENG-123" });
|
|
10
10
|
export const UserIdSchema = Type.String({ description: "Linear user UUID" });
|
|
11
11
|
export const ProjectIdSchema = Type.String({ description: "Linear project UUID" });
|
|
@@ -157,7 +157,7 @@ export function registerLinearTools(pi: ExtensionAPI): void {
|
|
|
157
157
|
pi.registerTool({
|
|
158
158
|
name: "linear_create_issue",
|
|
159
159
|
label: "Linear Create Issue",
|
|
160
|
-
description: "Create a Linear issue. Use linear_workspace_metadata first if team/state/user/project IDs are unknown.",
|
|
160
|
+
description: "Create a Linear issue. Accepts a team UUID or team key; keys are resolved before calling Linear. Use linear_workspace_metadata first if team/state/user/project IDs are unknown.",
|
|
161
161
|
parameters: LinearCreateIssueParams,
|
|
162
162
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
163
163
|
const result = await withLinearAuth(ctx, () => client.createIssue({
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# pi-mono-sentinel
|
|
2
2
|
|
|
3
|
+
## 1.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
### Enhanced: guardrails hardening
|
|
8
|
+
|
|
9
|
+
- Added merged Sentinel configuration scopes: global (`~/.pi/agent/extensions/sentinel.json`), local (`.pi/extensions/sentinel.json`), and session memory.
|
|
10
|
+
- Added optional path-access guard with allow/ask/block modes and file/directory grant persistence.
|
|
11
|
+
- Added `sentinel:dangerous` and `sentinel:blocked` event emission from guards.
|
|
12
|
+
- Added internal shell-aware bash command analysis with fallback matching.
|
|
13
|
+
|
|
14
|
+
### Maintenance
|
|
15
|
+
|
|
16
|
+
- Simplified Sentinel config persistence, path-access grants, event emission, and shell traversal internals without changing guard behavior.
|
|
17
|
+
|
|
3
18
|
## 1.10.2
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
|
@@ -9,6 +9,63 @@ It addresses cross-cutting security gaps that pure command-based guardrails miss
|
|
|
9
9
|
- **Out-of-scope operations** — a raw `bash` command performs a system-level action (sudo, `curl | bash`, `brew install`, `rm -rf /Library/...`) or a `write`/`edit` targets a file outside the project root (shell config, system directory)
|
|
10
10
|
- **Credential safety** — the LLM never hardcodes API keys or secrets in tool calls
|
|
11
11
|
|
|
12
|
+
## Configuration
|
|
13
|
+
|
|
14
|
+
Sentinel reads and merges optional JSON config from three scopes:
|
|
15
|
+
|
|
16
|
+
1. Global: `$PI_CODING_AGENT_DIR/extensions/sentinel.json` or `~/.pi/agent/extensions/sentinel.json`
|
|
17
|
+
2. Local/project: `.pi/extensions/sentinel.json` under the current working directory
|
|
18
|
+
3. Memory: session-only grants written internally while Pi is running
|
|
19
|
+
|
|
20
|
+
Merge priority is `memory > local > global > defaults`.
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"enabled": true,
|
|
25
|
+
"features": {
|
|
26
|
+
"outputScanner": true,
|
|
27
|
+
"executionTracker": true,
|
|
28
|
+
"permissionGate": true,
|
|
29
|
+
"pathAccess": false
|
|
30
|
+
},
|
|
31
|
+
"pathAccess": {
|
|
32
|
+
"mode": "ask",
|
|
33
|
+
"allowedPaths": []
|
|
34
|
+
},
|
|
35
|
+
"permissionGate": {
|
|
36
|
+
"requireConfirmation": true,
|
|
37
|
+
"allowedPatterns": [],
|
|
38
|
+
"autoDenyPatterns": []
|
|
39
|
+
},
|
|
40
|
+
"outputScanner": {
|
|
41
|
+
"readAllowedPaths": []
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
All fields are optional. Path access is available but disabled by default to avoid surprising existing users.
|
|
47
|
+
|
|
48
|
+
### Path access grants
|
|
49
|
+
|
|
50
|
+
When `features.pathAccess` is enabled, Sentinel checks `read`, `write`, `edit`, and path-like `bash` arguments that point outside `ctx.cwd`.
|
|
51
|
+
|
|
52
|
+
Modes:
|
|
53
|
+
|
|
54
|
+
- `allow` — no outside-project restrictions
|
|
55
|
+
- `ask` — prompt to allow once, allow file/directory for the session, allow file/directory always, or deny
|
|
56
|
+
- `block` — block outside-project paths unless they match `pathAccess.allowedPaths`
|
|
57
|
+
|
|
58
|
+
Allowed directory grants use a trailing slash, e.g. `/tmp/shared/`; exact file grants omit it.
|
|
59
|
+
|
|
60
|
+
### Events
|
|
61
|
+
|
|
62
|
+
Sentinel emits best-effort extension events for other extensions:
|
|
63
|
+
|
|
64
|
+
- `sentinel:dangerous` when a guard detects risky content or behavior
|
|
65
|
+
- `sentinel:blocked` when a guard blocks a tool call
|
|
66
|
+
|
|
67
|
+
Payloads include `feature`, `toolName`, `input`, and either `description`/`labels` or `reason`/`userDenied`.
|
|
68
|
+
|
|
12
69
|
## Guards
|
|
13
70
|
|
|
14
71
|
### 1. output-scanner — secret detection on read
|
|
@@ -41,6 +98,8 @@ If the target file was modified after the tracked write, it is re-read and re-sc
|
|
|
41
98
|
|
|
42
99
|
Where `execution-tracker` only fires for _session-written_ scripts, `permission-gate` intercepts every `bash` command and every `write` / `edit` and matches them against a fixed set of risk classes. It runs in addition to the other two guards.
|
|
43
100
|
|
|
101
|
+
Bash analysis uses Sentinel's small internal shell parser for quotes, redirects, pipelines, and command boundaries, with regex fallbacks when parsing fails.
|
|
102
|
+
|
|
44
103
|
**Bash risk classes**
|
|
45
104
|
|
|
46
105
|
| Risk class | Example |
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, test } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { SentinelConfigLoader } from "../config.ts";
|
|
8
|
+
|
|
9
|
+
describe("SentinelConfigLoader", () => {
|
|
10
|
+
let agentDir: string;
|
|
11
|
+
let cwd: string;
|
|
12
|
+
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
agentDir = mkdtempSync(join(tmpdir(), "sentinel-config-agent-"));
|
|
16
|
+
cwd = mkdtempSync(join(tmpdir(), "sentinel-config-cwd-"));
|
|
17
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (originalAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
22
|
+
else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
|
|
23
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
24
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("uses defaults when no config files exist", () => {
|
|
28
|
+
const loader = new SentinelConfigLoader();
|
|
29
|
+
loader.load(cwd);
|
|
30
|
+
const config = loader.getConfig();
|
|
31
|
+
assert.equal(config.enabled, true);
|
|
32
|
+
assert.equal(config.features.outputScanner, true);
|
|
33
|
+
assert.equal(config.features.pathAccess, false);
|
|
34
|
+
assert.equal(config.pathAccess.mode, "ask");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("merges global, local, and memory with expected precedence", () => {
|
|
38
|
+
const loader = new SentinelConfigLoader();
|
|
39
|
+
mkdirSync(join(agentDir, "extensions"), { recursive: true });
|
|
40
|
+
writeFileSync(loader.getConfigPath("global"), JSON.stringify({ pathAccess: { allowedPaths: ["/global"] } }), { flag: "w" });
|
|
41
|
+
loader.load(cwd);
|
|
42
|
+
mkdirSync(join(cwd, ".pi", "extensions"), { recursive: true });
|
|
43
|
+
writeFileSync(loader.getConfigPath("local"), JSON.stringify({ features: { pathAccess: true }, pathAccess: { allowedPaths: ["/local"] } }), { flag: "w" });
|
|
44
|
+
loader.load(cwd);
|
|
45
|
+
loader.save("memory", { pathAccess: { mode: "block", allowedPaths: ["/memory"] } });
|
|
46
|
+
const config = loader.getConfig();
|
|
47
|
+
assert.equal(config.features.pathAccess, true);
|
|
48
|
+
assert.equal(config.pathAccess.mode, "block");
|
|
49
|
+
assert.deepEqual(config.pathAccess.allowedPaths, ["/memory"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("save writes global and local config files", () => {
|
|
53
|
+
const loader = new SentinelConfigLoader();
|
|
54
|
+
loader.load(cwd);
|
|
55
|
+
loader.save("global", { enabled: false });
|
|
56
|
+
loader.save("local", { features: { pathAccess: true } });
|
|
57
|
+
assert.deepEqual(loader.getRawConfig("global"), { enabled: false });
|
|
58
|
+
assert.deepEqual(loader.getRawConfig("local"), { features: { pathAccess: true } });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { emitBlocked, emitDangerous } from "../events.ts";
|
|
5
|
+
|
|
6
|
+
describe("sentinel events", () => {
|
|
7
|
+
test("emits blocked and dangerous events", () => {
|
|
8
|
+
const emitted: Array<{ name: string; payload: unknown }> = [];
|
|
9
|
+
const pi = {
|
|
10
|
+
events: {
|
|
11
|
+
emit(name: string, payload: unknown) {
|
|
12
|
+
emitted.push({ name, payload });
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
emitDangerous(pi as never, {
|
|
18
|
+
feature: "permissionGate",
|
|
19
|
+
toolName: "bash",
|
|
20
|
+
input: { command: "sudo true" },
|
|
21
|
+
description: "danger",
|
|
22
|
+
labels: ["privilege-escalation"],
|
|
23
|
+
});
|
|
24
|
+
emitBlocked(pi as never, {
|
|
25
|
+
feature: "permissionGate",
|
|
26
|
+
toolName: "bash",
|
|
27
|
+
input: { command: "sudo true" },
|
|
28
|
+
reason: "blocked",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.equal(emitted[0].name, "sentinel:dangerous");
|
|
32
|
+
assert.equal(emitted[1].name, "sentinel:blocked");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not throw when event emitter is unavailable", () => {
|
|
36
|
+
assert.doesNotThrow(() =>
|
|
37
|
+
emitBlocked({} as never, {
|
|
38
|
+
feature: "pathAccess",
|
|
39
|
+
toolName: "read",
|
|
40
|
+
input: {},
|
|
41
|
+
reason: "blocked",
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, test } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { configLoader } from "../config.ts";
|
|
8
|
+
import {
|
|
9
|
+
checkPathAccess,
|
|
10
|
+
directoryGrantFor,
|
|
11
|
+
isInsideCwd,
|
|
12
|
+
isPathAllowed,
|
|
13
|
+
pathAccessGrantForChoice,
|
|
14
|
+
toStoragePath,
|
|
15
|
+
} from "../path-access.ts";
|
|
16
|
+
|
|
17
|
+
const CWD = "/tmp/sentinel-project";
|
|
18
|
+
|
|
19
|
+
describe("path-access helpers", () => {
|
|
20
|
+
test("allows paths inside cwd", () => {
|
|
21
|
+
assert.equal(isInsideCwd("/tmp/sentinel-project/src/index.ts", CWD), true);
|
|
22
|
+
assert.equal(checkPathAccess("/tmp/sentinel-project/src/index.ts", CWD, []).allowed, true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("detects paths outside cwd", () => {
|
|
26
|
+
const result = checkPathAccess("/tmp/other/file.txt", CWD, []);
|
|
27
|
+
assert.equal(result.allowed, false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("allows exact file grants", () => {
|
|
31
|
+
assert.equal(isPathAllowed("/tmp/other/file.txt", ["/tmp/other/file.txt"], CWD), true);
|
|
32
|
+
assert.equal(isPathAllowed("/tmp/other/else.txt", ["/tmp/other/file.txt"], CWD), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("allows directory grants with trailing slash", () => {
|
|
36
|
+
assert.equal(isPathAllowed("/tmp/other/file.txt", ["/tmp/other/"], CWD), true);
|
|
37
|
+
assert.equal(isPathAllowed("/tmp/other/nested/file.txt", ["/tmp/other/"], CWD), true);
|
|
38
|
+
assert.equal(isPathAllowed("/tmp/otherness/file.txt", ["/tmp/other/"], CWD), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("formats storage paths", () => {
|
|
42
|
+
assert.equal(toStoragePath("/tmp/other", true), "/tmp/other/");
|
|
43
|
+
assert.equal(directoryGrantFor("/tmp/other/file.txt"), "/tmp/other/");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("derives and persists selected path-access grants with the correct scope and target", () => {
|
|
47
|
+
const cwd = mkdtempSync(join(tmpdir(), "sentinel-path-access-cwd-"));
|
|
48
|
+
try {
|
|
49
|
+
configLoader.load(cwd);
|
|
50
|
+
configLoader.save("memory", { features: { pathAccess: true }, pathAccess: { mode: "ask", allowedPaths: [] } });
|
|
51
|
+
|
|
52
|
+
const sessionDirectoryGrant = pathAccessGrantForChoice("allow_directory_session", "/tmp/outside-dir/file.txt", cwd);
|
|
53
|
+
assert.deepEqual(sessionDirectoryGrant, {
|
|
54
|
+
grant: "/tmp/outside-dir/",
|
|
55
|
+
broadCheckPath: "/tmp/outside-dir",
|
|
56
|
+
scope: "memory",
|
|
57
|
+
directory: true,
|
|
58
|
+
});
|
|
59
|
+
configLoader.addAllowedPath(sessionDirectoryGrant.scope, sessionDirectoryGrant.grant);
|
|
60
|
+
assert.ok(configLoader.getConfig().pathAccess.allowedPaths.includes("/tmp/outside-dir/"));
|
|
61
|
+
|
|
62
|
+
const localFileGrant = pathAccessGrantForChoice("allow_file_always", "/tmp/outside-file.txt", cwd);
|
|
63
|
+
assert.deepEqual(localFileGrant, {
|
|
64
|
+
grant: "/tmp/outside-file.txt",
|
|
65
|
+
broadCheckPath: "/tmp/outside-file.txt",
|
|
66
|
+
scope: "local",
|
|
67
|
+
directory: false,
|
|
68
|
+
});
|
|
69
|
+
configLoader.addAllowedPath(localFileGrant.scope, localFileGrant.grant);
|
|
70
|
+
assert.ok(configLoader.getRawConfig("local")?.pathAccess?.allowedPaths?.includes("/tmp/outside-file.txt"));
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -118,6 +118,10 @@ describe("classifyBashCommand", () => {
|
|
|
118
118
|
assert.ok(matched.includes("remote-pipe-exec"));
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
test("does NOT flag dangerous words inside quoted strings", () => {
|
|
122
|
+
assert.deepEqual(classifyBashCommand('echo "sudo rm -rf /Library"'), []);
|
|
123
|
+
});
|
|
124
|
+
|
|
121
125
|
test("does NOT flag safe commands", () => {
|
|
122
126
|
assert.deepEqual(classifyBashCommand("echo hello"), []);
|
|
123
127
|
assert.deepEqual(classifyBashCommand("ls -la"), []);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type SentinelConfigScope = "global" | "local" | "memory";
|
|
6
|
+
export type SentinelPathAccessMode = "allow" | "ask" | "block";
|
|
7
|
+
|
|
8
|
+
export interface SentinelConfig {
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
features?: {
|
|
11
|
+
outputScanner?: boolean;
|
|
12
|
+
executionTracker?: boolean;
|
|
13
|
+
permissionGate?: boolean;
|
|
14
|
+
pathAccess?: boolean;
|
|
15
|
+
};
|
|
16
|
+
pathAccess?: {
|
|
17
|
+
mode?: SentinelPathAccessMode;
|
|
18
|
+
allowedPaths?: string[];
|
|
19
|
+
};
|
|
20
|
+
permissionGate?: {
|
|
21
|
+
requireConfirmation?: boolean;
|
|
22
|
+
allowedPatterns?: string[];
|
|
23
|
+
autoDenyPatterns?: string[];
|
|
24
|
+
};
|
|
25
|
+
outputScanner?: {
|
|
26
|
+
readAllowedPaths?: string[];
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedSentinelConfig {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
features: {
|
|
33
|
+
outputScanner: boolean;
|
|
34
|
+
executionTracker: boolean;
|
|
35
|
+
permissionGate: boolean;
|
|
36
|
+
pathAccess: boolean;
|
|
37
|
+
};
|
|
38
|
+
pathAccess: {
|
|
39
|
+
mode: SentinelPathAccessMode;
|
|
40
|
+
allowedPaths: string[];
|
|
41
|
+
};
|
|
42
|
+
permissionGate: {
|
|
43
|
+
requireConfirmation: boolean;
|
|
44
|
+
allowedPatterns: string[];
|
|
45
|
+
autoDenyPatterns: string[];
|
|
46
|
+
};
|
|
47
|
+
outputScanner: {
|
|
48
|
+
readAllowedPaths: string[];
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_SENTINEL_CONFIG: ResolvedSentinelConfig = {
|
|
53
|
+
enabled: true,
|
|
54
|
+
features: {
|
|
55
|
+
outputScanner: true,
|
|
56
|
+
executionTracker: true,
|
|
57
|
+
permissionGate: true,
|
|
58
|
+
pathAccess: false,
|
|
59
|
+
},
|
|
60
|
+
pathAccess: {
|
|
61
|
+
mode: "ask",
|
|
62
|
+
allowedPaths: [],
|
|
63
|
+
},
|
|
64
|
+
permissionGate: {
|
|
65
|
+
requireConfirmation: true,
|
|
66
|
+
allowedPatterns: [],
|
|
67
|
+
autoDenyPatterns: [],
|
|
68
|
+
},
|
|
69
|
+
outputScanner: {
|
|
70
|
+
readAllowedPaths: [],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function getAgentDir(): string {
|
|
75
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
76
|
+
if (!envDir) return join(homedir(), ".pi", "agent");
|
|
77
|
+
if (envDir === "~") return homedir();
|
|
78
|
+
if (envDir.startsWith("~/")) return join(homedir(), envDir.slice(2));
|
|
79
|
+
return envDir;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readJson(path: string): SentinelConfig | undefined {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(readFileSync(path, "utf-8")) as SentinelConfig;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeJson(path: string, value: SentinelConfig): void {
|
|
91
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
92
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
96
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function mergeConfig<T>(base: T, override?: SentinelConfig | Record<string, unknown>): T {
|
|
100
|
+
if (!override) return structuredClone(base);
|
|
101
|
+
const result = structuredClone(base) as Record<string, unknown>;
|
|
102
|
+
for (const [key, value] of Object.entries(override)) {
|
|
103
|
+
if (value === undefined) continue;
|
|
104
|
+
const existing = result[key];
|
|
105
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
106
|
+
result[key] = mergeConfig(existing, value);
|
|
107
|
+
} else if (Array.isArray(value)) {
|
|
108
|
+
result[key] = [...value];
|
|
109
|
+
} else {
|
|
110
|
+
result[key] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result as T;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function dedupe(values: string[]): string[] {
|
|
117
|
+
return [...new Set(values.filter((v) => typeof v === "string" && v.length > 0))];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class SentinelConfigLoader {
|
|
121
|
+
private globalConfig: SentinelConfig | undefined;
|
|
122
|
+
private localConfig: SentinelConfig | undefined;
|
|
123
|
+
private memoryConfig: SentinelConfig = {};
|
|
124
|
+
private resolvedConfig: ResolvedSentinelConfig | undefined;
|
|
125
|
+
private localCwd = process.cwd();
|
|
126
|
+
|
|
127
|
+
load(cwd = process.cwd()): void {
|
|
128
|
+
this.localCwd = cwd;
|
|
129
|
+
this.globalConfig = readJson(this.getConfigPath("global"));
|
|
130
|
+
this.localConfig = readJson(this.getConfigPath("local"));
|
|
131
|
+
this.resolvedConfig = undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getConfig(): ResolvedSentinelConfig {
|
|
135
|
+
if (this.resolvedConfig) return this.resolvedConfig;
|
|
136
|
+
let resolved = structuredClone(DEFAULT_SENTINEL_CONFIG) as ResolvedSentinelConfig;
|
|
137
|
+
resolved = mergeConfig(resolved, this.globalConfig);
|
|
138
|
+
resolved = mergeConfig(resolved, this.localConfig);
|
|
139
|
+
resolved = mergeConfig(resolved, this.memoryConfig);
|
|
140
|
+
resolved.pathAccess.allowedPaths = dedupe(resolved.pathAccess.allowedPaths);
|
|
141
|
+
resolved.permissionGate.allowedPatterns = dedupe(resolved.permissionGate.allowedPatterns);
|
|
142
|
+
resolved.permissionGate.autoDenyPatterns = dedupe(resolved.permissionGate.autoDenyPatterns);
|
|
143
|
+
resolved.outputScanner.readAllowedPaths = dedupe(resolved.outputScanner.readAllowedPaths);
|
|
144
|
+
this.resolvedConfig = resolved;
|
|
145
|
+
return resolved;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getRawConfig(scope: SentinelConfigScope): SentinelConfig | undefined {
|
|
149
|
+
return scope === "global" ? this.globalConfig : scope === "local" ? this.localConfig : this.memoryConfig;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getConfigPath(scope: Exclude<SentinelConfigScope, "memory">): string {
|
|
153
|
+
return scope === "global"
|
|
154
|
+
? join(getAgentDir(), "extensions", "sentinel.json")
|
|
155
|
+
: join(this.localCwd, ".pi", "extensions", "sentinel.json");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
save(scope: SentinelConfigScope, partial: SentinelConfig): void {
|
|
159
|
+
if (scope === "memory") {
|
|
160
|
+
this.memoryConfig = mergeConfig(this.memoryConfig, partial);
|
|
161
|
+
this.resolvedConfig = undefined;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const current = scope === "global" ? (this.globalConfig ?? {}) : (this.localConfig ?? {});
|
|
166
|
+
const next = mergeConfig(current, partial);
|
|
167
|
+
writeJson(this.getConfigPath(scope), next);
|
|
168
|
+
if (scope === "global") this.globalConfig = next;
|
|
169
|
+
else this.localConfig = next;
|
|
170
|
+
this.resolvedConfig = undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private addListValue(scope: SentinelConfigScope, path: string, list: "allowedPaths" | "readAllowedPaths"): void {
|
|
174
|
+
const raw = this.getRawConfig(scope);
|
|
175
|
+
const current = list === "allowedPaths"
|
|
176
|
+
? raw?.pathAccess?.allowedPaths ?? []
|
|
177
|
+
: raw?.outputScanner?.readAllowedPaths ?? [];
|
|
178
|
+
const values = dedupe([...current, path]);
|
|
179
|
+
this.save(scope, list === "allowedPaths"
|
|
180
|
+
? { pathAccess: { allowedPaths: values } }
|
|
181
|
+
: { outputScanner: { readAllowedPaths: values } });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
addAllowedPath(scope: SentinelConfigScope, path: string): void {
|
|
185
|
+
this.addListValue(scope, path, "allowedPaths");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
addReadAllowedPath(scope: SentinelConfigScope, path: string): void {
|
|
189
|
+
this.addListValue(scope, path, "readAllowedPaths");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const configLoader = new SentinelConfigLoader();
|