pi-mono-sentinel 1.10.2 → 1.12.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/.pi/extensions/sentinel.json +7 -0
- package/CHANGELOG.md +21 -0
- package/README.md +61 -0
- package/__tests__/config.test.ts +74 -0
- package/__tests__/events.test.ts +45 -0
- package/__tests__/path-access.test.ts +81 -0
- package/__tests__/permissions.test.ts +4 -0
- package/config.ts +212 -0
- package/events.ts +46 -0
- package/guards/execution-tracker.ts +13 -6
- package/guards/output-scanner.ts +75 -44
- package/guards/path-access.ts +102 -0
- package/guards/permission-gate.ts +34 -16
- package/index.ts +19 -3
- package/package.json +1 -1
- package/path-access.ts +74 -0
- package/patterns/bash-paths.ts +98 -0
- package/patterns/permissions.ts +146 -24
- package/patterns/read-targets.ts +3 -1
- package/session.ts +6 -1
- package/utils/shell.ts +172 -0
- package/whitelist.ts +2 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# pi-mono-sentinel
|
|
2
2
|
|
|
3
|
+
## 1.12.0
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Store project-scoped Sentinel config under the Pi agent directory (`~/.pi/agent/extensions/sentinel/projects/`) instead of creating `.pi/extensions/sentinel.json` in the current working directory. Existing cwd-local config files are still read for compatibility.
|
|
8
|
+
|
|
9
|
+
## 1.11.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
### Enhanced: guardrails hardening
|
|
14
|
+
|
|
15
|
+
- Added merged Sentinel configuration scopes: global (`~/.pi/agent/extensions/sentinel.json`), local (`.pi/extensions/sentinel.json`), and session memory.
|
|
16
|
+
- Added optional path-access guard with allow/ask/block modes and file/directory grant persistence.
|
|
17
|
+
- Added `sentinel:dangerous` and `sentinel:blocked` event emission from guards.
|
|
18
|
+
- Added internal shell-aware bash command analysis with fallback matching.
|
|
19
|
+
|
|
20
|
+
### Maintenance
|
|
21
|
+
|
|
22
|
+
- Simplified Sentinel config persistence, path-access grants, event emission, and shell traversal internals without changing guard behavior.
|
|
23
|
+
|
|
3
24
|
## 1.10.2
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -9,6 +9,65 @@ 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: a current-working-directory scoped file under `$PI_CODING_AGENT_DIR/extensions/sentinel/projects/` or `~/.pi/agent/extensions/sentinel/projects/`
|
|
18
|
+
3. Memory: session-only grants written internally while Pi is running
|
|
19
|
+
|
|
20
|
+
Merge priority is `memory > local > global > defaults`.
|
|
21
|
+
|
|
22
|
+
Local/project config is stored in Pi's agent directory instead of the user's working directory, so Sentinel does not create `.pi/` files in arbitrary project folders. Existing legacy `.pi/extensions/sentinel.json` files are still read for compatibility, but new local/project writes go to the agent directory.
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"enabled": true,
|
|
27
|
+
"features": {
|
|
28
|
+
"outputScanner": true,
|
|
29
|
+
"executionTracker": true,
|
|
30
|
+
"permissionGate": true,
|
|
31
|
+
"pathAccess": false
|
|
32
|
+
},
|
|
33
|
+
"pathAccess": {
|
|
34
|
+
"mode": "ask",
|
|
35
|
+
"allowedPaths": []
|
|
36
|
+
},
|
|
37
|
+
"permissionGate": {
|
|
38
|
+
"requireConfirmation": true,
|
|
39
|
+
"allowedPatterns": [],
|
|
40
|
+
"autoDenyPatterns": []
|
|
41
|
+
},
|
|
42
|
+
"outputScanner": {
|
|
43
|
+
"readAllowedPaths": []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
All fields are optional. Path access is available but disabled by default to avoid surprising existing users.
|
|
49
|
+
|
|
50
|
+
### Path access grants
|
|
51
|
+
|
|
52
|
+
When `features.pathAccess` is enabled, Sentinel checks `read`, `write`, `edit`, and path-like `bash` arguments that point outside `ctx.cwd`.
|
|
53
|
+
|
|
54
|
+
Modes:
|
|
55
|
+
|
|
56
|
+
- `allow` — no outside-project restrictions
|
|
57
|
+
- `ask` — prompt to allow once, allow file/directory for the session, allow file/directory always, or deny
|
|
58
|
+
- `block` — block outside-project paths unless they match `pathAccess.allowedPaths`
|
|
59
|
+
|
|
60
|
+
Allowed directory grants use a trailing slash, e.g. `/tmp/shared/`; exact file grants omit it.
|
|
61
|
+
|
|
62
|
+
### Events
|
|
63
|
+
|
|
64
|
+
Sentinel emits best-effort extension events for other extensions:
|
|
65
|
+
|
|
66
|
+
- `sentinel:dangerous` when a guard detects risky content or behavior
|
|
67
|
+
- `sentinel:blocked` when a guard blocks a tool call
|
|
68
|
+
|
|
69
|
+
Payloads include `feature`, `toolName`, `input`, and either `description`/`labels` or `reason`/`userDenied`.
|
|
70
|
+
|
|
12
71
|
## Guards
|
|
13
72
|
|
|
14
73
|
### 1. output-scanner — secret detection on read
|
|
@@ -41,6 +100,8 @@ If the target file was modified after the tracked write, it is re-read and re-sc
|
|
|
41
100
|
|
|
42
101
|
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
102
|
|
|
103
|
+
Bash analysis uses Sentinel's small internal shell parser for quotes, redirects, pipelines, and command boundaries, with regex fallbacks when parsing fails.
|
|
104
|
+
|
|
44
105
|
**Bash risk classes**
|
|
45
106
|
|
|
46
107
|
| Risk class | Example |
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, 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(dirname(loader.getConfigPath("local")), { 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 cwd-scoped local config files under the agent dir", () => {
|
|
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
|
+
assert.equal(loader.getConfigPath("local").startsWith(join(agentDir, "extensions", "sentinel", "projects")), true);
|
|
60
|
+
assert.equal(existsSync(join(cwd, ".pi", "extensions", "sentinel.json")), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("reads legacy cwd-local config without writing back to cwd", () => {
|
|
64
|
+
const loader = new SentinelConfigLoader();
|
|
65
|
+
mkdirSync(join(cwd, ".pi", "extensions"), { recursive: true });
|
|
66
|
+
writeFileSync(join(cwd, ".pi", "extensions", "sentinel.json"), JSON.stringify({ features: { pathAccess: true } }), { flag: "w" });
|
|
67
|
+
|
|
68
|
+
loader.load(cwd);
|
|
69
|
+
assert.equal(loader.getConfig().features.pathAccess, true);
|
|
70
|
+
|
|
71
|
+
loader.save("local", { pathAccess: { allowedPaths: ["/outside"] } });
|
|
72
|
+
assert.equal(existsSync(loader.getConfigPath("local")), true);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -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,81 @@
|
|
|
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 originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
48
|
+
const agentDir = mkdtempSync(join(tmpdir(), "sentinel-path-access-agent-"));
|
|
49
|
+
const cwd = mkdtempSync(join(tmpdir(), "sentinel-path-access-cwd-"));
|
|
50
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
51
|
+
try {
|
|
52
|
+
configLoader.load(cwd);
|
|
53
|
+
configLoader.save("memory", { features: { pathAccess: true }, pathAccess: { mode: "ask", allowedPaths: [] } });
|
|
54
|
+
|
|
55
|
+
const sessionDirectoryGrant = pathAccessGrantForChoice("allow_directory_session", "/tmp/outside-dir/file.txt", cwd);
|
|
56
|
+
assert.deepEqual(sessionDirectoryGrant, {
|
|
57
|
+
grant: "/tmp/outside-dir/",
|
|
58
|
+
broadCheckPath: "/tmp/outside-dir",
|
|
59
|
+
scope: "memory",
|
|
60
|
+
directory: true,
|
|
61
|
+
});
|
|
62
|
+
configLoader.addAllowedPath(sessionDirectoryGrant.scope, sessionDirectoryGrant.grant);
|
|
63
|
+
assert.ok(configLoader.getConfig().pathAccess.allowedPaths.includes("/tmp/outside-dir/"));
|
|
64
|
+
|
|
65
|
+
const localFileGrant = pathAccessGrantForChoice("allow_file_always", "/tmp/outside-file.txt", cwd);
|
|
66
|
+
assert.deepEqual(localFileGrant, {
|
|
67
|
+
grant: "/tmp/outside-file.txt",
|
|
68
|
+
broadCheckPath: "/tmp/outside-file.txt",
|
|
69
|
+
scope: "local",
|
|
70
|
+
directory: false,
|
|
71
|
+
});
|
|
72
|
+
configLoader.addAllowedPath(localFileGrant.scope, localFileGrant.grant);
|
|
73
|
+
assert.ok(configLoader.getRawConfig("local")?.pathAccess?.allowedPaths?.includes("/tmp/outside-file.txt"));
|
|
74
|
+
} finally {
|
|
75
|
+
if (originalAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
76
|
+
else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
|
|
77
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
78
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -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"), []);
|
package/config.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type SentinelConfigScope = "global" | "local" | "memory";
|
|
7
|
+
export type SentinelPathAccessMode = "allow" | "ask" | "block";
|
|
8
|
+
|
|
9
|
+
export interface SentinelConfig {
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
features?: {
|
|
12
|
+
outputScanner?: boolean;
|
|
13
|
+
executionTracker?: boolean;
|
|
14
|
+
permissionGate?: boolean;
|
|
15
|
+
pathAccess?: boolean;
|
|
16
|
+
};
|
|
17
|
+
pathAccess?: {
|
|
18
|
+
mode?: SentinelPathAccessMode;
|
|
19
|
+
allowedPaths?: string[];
|
|
20
|
+
};
|
|
21
|
+
permissionGate?: {
|
|
22
|
+
requireConfirmation?: boolean;
|
|
23
|
+
allowedPatterns?: string[];
|
|
24
|
+
autoDenyPatterns?: string[];
|
|
25
|
+
};
|
|
26
|
+
outputScanner?: {
|
|
27
|
+
readAllowedPaths?: string[];
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResolvedSentinelConfig {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
features: {
|
|
34
|
+
outputScanner: boolean;
|
|
35
|
+
executionTracker: boolean;
|
|
36
|
+
permissionGate: boolean;
|
|
37
|
+
pathAccess: boolean;
|
|
38
|
+
};
|
|
39
|
+
pathAccess: {
|
|
40
|
+
mode: SentinelPathAccessMode;
|
|
41
|
+
allowedPaths: string[];
|
|
42
|
+
};
|
|
43
|
+
permissionGate: {
|
|
44
|
+
requireConfirmation: boolean;
|
|
45
|
+
allowedPatterns: string[];
|
|
46
|
+
autoDenyPatterns: string[];
|
|
47
|
+
};
|
|
48
|
+
outputScanner: {
|
|
49
|
+
readAllowedPaths: string[];
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_SENTINEL_CONFIG: ResolvedSentinelConfig = {
|
|
54
|
+
enabled: true,
|
|
55
|
+
features: {
|
|
56
|
+
outputScanner: true,
|
|
57
|
+
executionTracker: true,
|
|
58
|
+
permissionGate: true,
|
|
59
|
+
pathAccess: false,
|
|
60
|
+
},
|
|
61
|
+
pathAccess: {
|
|
62
|
+
mode: "ask",
|
|
63
|
+
allowedPaths: [],
|
|
64
|
+
},
|
|
65
|
+
permissionGate: {
|
|
66
|
+
requireConfirmation: true,
|
|
67
|
+
allowedPatterns: [],
|
|
68
|
+
autoDenyPatterns: [],
|
|
69
|
+
},
|
|
70
|
+
outputScanner: {
|
|
71
|
+
readAllowedPaths: [],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function getAgentDir(): string {
|
|
76
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
77
|
+
if (!envDir) return join(homedir(), ".pi", "agent");
|
|
78
|
+
if (envDir === "~") return homedir();
|
|
79
|
+
if (envDir.startsWith("~/")) return join(homedir(), envDir.slice(2));
|
|
80
|
+
return envDir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readJson(path: string): SentinelConfig | undefined {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(readFileSync(path, "utf-8")) as SentinelConfig;
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeJson(path: string, value: SentinelConfig): void {
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
93
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function localScopeId(cwd: string): string {
|
|
97
|
+
const normalizedCwd = resolve(cwd);
|
|
98
|
+
const slug = (basename(normalizedCwd) || "root")
|
|
99
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
100
|
+
.replace(/^-+|-+$/g, "")
|
|
101
|
+
.slice(0, 48) || "root";
|
|
102
|
+
const hash = createHash("sha256").update(normalizedCwd).digest("hex").slice(0, 12);
|
|
103
|
+
return `${slug}-${hash}.json`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function legacyLocalConfigPath(cwd: string): string {
|
|
107
|
+
return join(resolve(cwd), ".pi", "extensions", "sentinel.json");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
111
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function mergeConfig<T>(base: T, override?: SentinelConfig | Record<string, unknown>): T {
|
|
115
|
+
if (!override) return structuredClone(base);
|
|
116
|
+
const result = structuredClone(base) as Record<string, unknown>;
|
|
117
|
+
for (const [key, value] of Object.entries(override)) {
|
|
118
|
+
if (value === undefined) continue;
|
|
119
|
+
const existing = result[key];
|
|
120
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
121
|
+
result[key] = mergeConfig(existing, value);
|
|
122
|
+
} else if (Array.isArray(value)) {
|
|
123
|
+
result[key] = [...value];
|
|
124
|
+
} else {
|
|
125
|
+
result[key] = value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result as T;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function dedupe(values: string[]): string[] {
|
|
132
|
+
return [...new Set(values.filter((v) => typeof v === "string" && v.length > 0))];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class SentinelConfigLoader {
|
|
136
|
+
private globalConfig: SentinelConfig | undefined;
|
|
137
|
+
private localConfig: SentinelConfig | undefined;
|
|
138
|
+
private memoryConfig: SentinelConfig = {};
|
|
139
|
+
private resolvedConfig: ResolvedSentinelConfig | undefined;
|
|
140
|
+
private localCwd = process.cwd();
|
|
141
|
+
|
|
142
|
+
load(cwd = process.cwd()): void {
|
|
143
|
+
this.localCwd = cwd;
|
|
144
|
+
this.globalConfig = readJson(this.getConfigPath("global"));
|
|
145
|
+
const legacyLocalConfig = readJson(legacyLocalConfigPath(this.localCwd));
|
|
146
|
+
const scopedLocalConfig = readJson(this.getConfigPath("local"));
|
|
147
|
+
this.localConfig = legacyLocalConfig && scopedLocalConfig
|
|
148
|
+
? mergeConfig(legacyLocalConfig, scopedLocalConfig)
|
|
149
|
+
: scopedLocalConfig ?? legacyLocalConfig;
|
|
150
|
+
this.resolvedConfig = undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getConfig(): ResolvedSentinelConfig {
|
|
154
|
+
if (this.resolvedConfig) return this.resolvedConfig;
|
|
155
|
+
let resolved = structuredClone(DEFAULT_SENTINEL_CONFIG) as ResolvedSentinelConfig;
|
|
156
|
+
resolved = mergeConfig(resolved, this.globalConfig);
|
|
157
|
+
resolved = mergeConfig(resolved, this.localConfig);
|
|
158
|
+
resolved = mergeConfig(resolved, this.memoryConfig);
|
|
159
|
+
resolved.pathAccess.allowedPaths = dedupe(resolved.pathAccess.allowedPaths);
|
|
160
|
+
resolved.permissionGate.allowedPatterns = dedupe(resolved.permissionGate.allowedPatterns);
|
|
161
|
+
resolved.permissionGate.autoDenyPatterns = dedupe(resolved.permissionGate.autoDenyPatterns);
|
|
162
|
+
resolved.outputScanner.readAllowedPaths = dedupe(resolved.outputScanner.readAllowedPaths);
|
|
163
|
+
this.resolvedConfig = resolved;
|
|
164
|
+
return resolved;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getRawConfig(scope: SentinelConfigScope): SentinelConfig | undefined {
|
|
168
|
+
return scope === "global" ? this.globalConfig : scope === "local" ? this.localConfig : this.memoryConfig;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getConfigPath(scope: Exclude<SentinelConfigScope, "memory">): string {
|
|
172
|
+
return scope === "global"
|
|
173
|
+
? join(getAgentDir(), "extensions", "sentinel.json")
|
|
174
|
+
: join(getAgentDir(), "extensions", "sentinel", "projects", localScopeId(this.localCwd));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
save(scope: SentinelConfigScope, partial: SentinelConfig): void {
|
|
178
|
+
if (scope === "memory") {
|
|
179
|
+
this.memoryConfig = mergeConfig(this.memoryConfig, partial);
|
|
180
|
+
this.resolvedConfig = undefined;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const current = scope === "global" ? (this.globalConfig ?? {}) : (this.localConfig ?? {});
|
|
185
|
+
const next = mergeConfig(current, partial);
|
|
186
|
+
writeJson(this.getConfigPath(scope), next);
|
|
187
|
+
if (scope === "global") this.globalConfig = next;
|
|
188
|
+
else this.localConfig = next;
|
|
189
|
+
this.resolvedConfig = undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private addListValue(scope: SentinelConfigScope, path: string, list: "allowedPaths" | "readAllowedPaths"): void {
|
|
193
|
+
const raw = this.getRawConfig(scope);
|
|
194
|
+
const current = list === "allowedPaths"
|
|
195
|
+
? raw?.pathAccess?.allowedPaths ?? []
|
|
196
|
+
: raw?.outputScanner?.readAllowedPaths ?? [];
|
|
197
|
+
const values = dedupe([...current, path]);
|
|
198
|
+
this.save(scope, list === "allowedPaths"
|
|
199
|
+
? { pathAccess: { allowedPaths: values } }
|
|
200
|
+
: { outputScanner: { readAllowedPaths: values } });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
addAllowedPath(scope: SentinelConfigScope, path: string): void {
|
|
204
|
+
this.addListValue(scope, path, "allowedPaths");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
addReadAllowedPath(scope: SentinelConfigScope, path: string): void {
|
|
208
|
+
this.addListValue(scope, path, "readAllowedPaths");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const configLoader = new SentinelConfigLoader();
|
package/events.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type SentinelFeature =
|
|
4
|
+
| "outputScanner"
|
|
5
|
+
| "executionTracker"
|
|
6
|
+
| "permissionGate"
|
|
7
|
+
| "pathAccess";
|
|
8
|
+
|
|
9
|
+
export interface SentinelBlockedEvent {
|
|
10
|
+
feature: SentinelFeature;
|
|
11
|
+
toolName: string;
|
|
12
|
+
input: Record<string, unknown>;
|
|
13
|
+
reason: string;
|
|
14
|
+
userDenied?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SentinelDangerousEvent {
|
|
18
|
+
feature: SentinelFeature;
|
|
19
|
+
toolName: string;
|
|
20
|
+
input: Record<string, unknown>;
|
|
21
|
+
description: string;
|
|
22
|
+
labels?: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SentinelBlockResult = { block: true; reason: string };
|
|
26
|
+
|
|
27
|
+
function emitSentinelEvent(pi: ExtensionAPI, name: "sentinel:blocked" | "sentinel:dangerous", event: unknown): void {
|
|
28
|
+
try {
|
|
29
|
+
pi.events?.emit?.(name, event);
|
|
30
|
+
} catch {
|
|
31
|
+
// Event emission is best-effort and must never affect guard decisions.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function emitBlocked(pi: ExtensionAPI, event: SentinelBlockedEvent): void {
|
|
36
|
+
emitSentinelEvent(pi, "sentinel:blocked", event);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function blockToolCall(pi: ExtensionAPI, event: SentinelBlockedEvent, reason = event.reason): SentinelBlockResult {
|
|
40
|
+
emitBlocked(pi, event);
|
|
41
|
+
return { block: true, reason };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function emitDangerous(pi: ExtensionAPI, event: SentinelDangerousEvent): void {
|
|
45
|
+
emitSentinelEvent(pi, "sentinel:dangerous", event);
|
|
46
|
+
}
|
|
@@ -20,6 +20,7 @@ import { resolve } from "node:path";
|
|
|
20
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
|
|
23
|
+
import { blockToolCall, emitDangerous } from "../events.js";
|
|
23
24
|
import type { SentinelSession } from "../session.js";
|
|
24
25
|
import type { DangerousPattern, WriteEntry } from "../types.js";
|
|
25
26
|
|
|
@@ -221,6 +222,14 @@ export function registerExecutionTracker(
|
|
|
221
222
|
continue;
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
emitDangerous(pi, {
|
|
226
|
+
feature: "executionTracker",
|
|
227
|
+
toolName: "bash",
|
|
228
|
+
input: event.input,
|
|
229
|
+
description: "Bash command executes a session-written file with dangerous content.",
|
|
230
|
+
labels: currentPatterns,
|
|
231
|
+
});
|
|
232
|
+
|
|
224
233
|
// Dangerous content confirmed — escalate
|
|
225
234
|
const message = [
|
|
226
235
|
`About to execute a file written earlier in this session:`,
|
|
@@ -239,12 +248,10 @@ export function registerExecutionTracker(
|
|
|
239
248
|
}
|
|
240
249
|
|
|
241
250
|
// No UI or user denied — block
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
`with dangerous patterns: ${currentPatterns.join(", ")}.`,
|
|
247
|
-
};
|
|
251
|
+
const reason =
|
|
252
|
+
`[sentinel] Blocked: bash executes ${scriptPath}, written this session ` +
|
|
253
|
+
`with dangerous patterns: ${currentPatterns.join(", ")}.`;
|
|
254
|
+
return blockToolCall(pi, { feature: "executionTracker", toolName: "bash", input: event.input, reason, userDenied: ctx.hasUI });
|
|
248
255
|
}
|
|
249
256
|
});
|
|
250
257
|
}
|