@gotgenes/pi-permission-system 5.1.1 → 5.2.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/CHANGELOG.md +23 -0
- package/README.md +5 -6
- package/package.json +1 -1
- package/src/forwarded-permissions/polling.ts +5 -1
- package/src/permission-forwarding.ts +25 -4
- package/tests/permission-forwarding.test.ts +143 -0
- package/tests/permission-system.test.ts +183 -1
- package/tests/subagent-context.test.ts +76 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.2.0](https://github.com/gotgenes/pi-permission-system/compare/v5.1.2...v5.2.0) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add SUBAGENT_PARENT_SESSION_ENV_CANDIDATES, iterate in resolver ([#96](https://github.com/gotgenes/pi-permission-system/issues/96)) ([ac6831d](https://github.com/gotgenes/pi-permission-system/commit/ac6831d3418db0aee5d5ed4757d5833730a6e130))
|
|
14
|
+
* broaden SUBAGENT_ENV_HINT_KEYS for nicobailon + HazAT extensions ([#96](https://github.com/gotgenes/pi-permission-system/issues/96)) ([8adafdb](https://github.com/gotgenes/pi-permission-system/commit/8adafdb45af66cf99b000b3ad011bee5a2c90476))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* plan broaden subagent env hint keys ([#96](https://github.com/gotgenes/pi-permission-system/issues/96)) ([9fa97b7](https://github.com/gotgenes/pi-permission-system/commit/9fa97b7385e5b9203a35c80d15f980ac4501f788))
|
|
20
|
+
* update target-architecture subagent detection for [#96](https://github.com/gotgenes/pi-permission-system/issues/96) ([64cce35](https://github.com/gotgenes/pi-permission-system/commit/64cce3569c09cede690858af41d2f35611a8705f))
|
|
21
|
+
|
|
22
|
+
## [5.1.2](https://github.com/gotgenes/pi-permission-system/compare/v5.1.1...v5.1.2) (2026-05-05)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* fix README per-agent frontmatter example to flat format ([#78](https://github.com/gotgenes/pi-permission-system/issues/78)) ([1295427](https://github.com/gotgenes/pi-permission-system/commit/129542795218a6ada1f8d069a22b5ace5ec6c445))
|
|
28
|
+
* plan fix README frontmatter example and add missing tests ([#78](https://github.com/gotgenes/pi-permission-system/issues/78)) ([3fc99e1](https://github.com/gotgenes/pi-permission-system/commit/3fc99e1b3adf8193c94ac778617ca830488fa621))
|
|
29
|
+
* **retro:** add retro notes for issue [#93](https://github.com/gotgenes/pi-permission-system/issues/93) ([c9e8e89](https://github.com/gotgenes/pi-permission-system/commit/c9e8e89eb4057866198402add374d72a90a2fa2e))
|
|
30
|
+
|
|
8
31
|
## [5.1.1](https://github.com/gotgenes/pi-permission-system/compare/v5.1.0...v5.1.1) (2026-05-05)
|
|
9
32
|
|
|
10
33
|
|
package/README.md
CHANGED
|
@@ -170,22 +170,21 @@ Override global permissions for specific agents via YAML frontmatter in the glob
|
|
|
170
170
|
---
|
|
171
171
|
name: my-agent
|
|
172
172
|
permission:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
mcp: allow
|
|
173
|
+
read: allow
|
|
174
|
+
write: deny
|
|
175
|
+
mcp: allow
|
|
177
176
|
bash:
|
|
178
177
|
git status: allow
|
|
179
178
|
git *: ask
|
|
180
179
|
mcp:
|
|
181
180
|
chrome_devtools_*: deny
|
|
182
181
|
exa_*: allow
|
|
183
|
-
|
|
182
|
+
skill:
|
|
184
183
|
"*": ask
|
|
185
184
|
---
|
|
186
185
|
```
|
|
187
186
|
|
|
188
|
-
**MCP behavior:** `permission.
|
|
187
|
+
**MCP behavior:** `permission.mcp` is the coarse entry/fallback permission for a registered `mcp` tool when one is available. More specific `permission.mcp` target rules override that fallback when they match.
|
|
189
188
|
|
|
190
189
|
**Limitations:** The frontmatter parser is intentionally minimal. Use only `key: value` scalars and nested maps. Avoid arrays, multi-line scalars, and YAML anchors.
|
|
191
190
|
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
19
19
|
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
20
20
|
resolvePermissionForwardingTargetSessionId,
|
|
21
|
+
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
21
22
|
} from "../permission-forwarding";
|
|
22
23
|
import { isSubagentExecutionContext } from "../subagent-context";
|
|
23
24
|
|
|
@@ -110,7 +111,10 @@ export async function waitForForwardedPermissionApproval(
|
|
|
110
111
|
if (!targetSessionId) {
|
|
111
112
|
logPermissionForwardingError(
|
|
112
113
|
deps.logger,
|
|
113
|
-
|
|
114
|
+
`Permission forwarding target session could not be resolved. ` +
|
|
115
|
+
`Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
|
|
116
|
+
`If you are using nicobailon/pi-subagents or HazAT/pi-interactive-subagents, ` +
|
|
117
|
+
`parent-session forwarding is not yet supported for those extensions (see issue #98).`,
|
|
114
118
|
);
|
|
115
119
|
return { approved: false, state: "denied" };
|
|
116
120
|
}
|
|
@@ -5,12 +5,30 @@ import type { PermissionDecisionState } from "./permission-dialog";
|
|
|
5
5
|
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
|
6
6
|
export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
7
|
export const SUBAGENT_ENV_HINT_KEYS = [
|
|
8
|
+
// pi-agent-router (original)
|
|
8
9
|
"PI_IS_SUBAGENT",
|
|
9
10
|
"PI_SUBAGENT_SESSION_ID",
|
|
10
11
|
"PI_AGENT_ROUTER_SUBAGENT",
|
|
12
|
+
// nicobailon/pi-subagents
|
|
13
|
+
"PI_SUBAGENT_CHILD",
|
|
14
|
+
"PI_SUBAGENT_RUN_ID",
|
|
15
|
+
"PI_SUBAGENT_CHILD_AGENT",
|
|
16
|
+
"PI_SUBAGENT_DEPTH",
|
|
17
|
+
// HazAT/pi-interactive-subagents
|
|
18
|
+
"PI_SUBAGENT_NAME",
|
|
19
|
+
"PI_SUBAGENT_ID",
|
|
20
|
+
"PI_SUBAGENT_SESSION",
|
|
21
|
+
"PI_SUBAGENT_ACTIVITY_FILE",
|
|
11
22
|
] as const;
|
|
23
|
+
/** Ordered list of env var names to check for the parent session ID. First match wins. */
|
|
24
|
+
export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
|
|
25
|
+
// pi-agent-router (original)
|
|
26
|
+
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
|
|
12
30
|
export const SUBAGENT_PARENT_SESSION_ENV_KEY =
|
|
13
|
-
|
|
31
|
+
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0];
|
|
14
32
|
|
|
15
33
|
const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
|
|
16
34
|
const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
|
|
@@ -106,9 +124,12 @@ export function resolvePermissionForwardingTargetSessionId(options: {
|
|
|
106
124
|
return null;
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
127
|
+
const env = options.env ?? process.env;
|
|
128
|
+
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
|
|
129
|
+
const resolved = normalizePermissionForwardingSessionId(env[key]);
|
|
130
|
+
if (resolved) return resolved;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
112
133
|
}
|
|
113
134
|
|
|
114
135
|
export function isForwardedPermissionRequestForSession(
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolvePermissionForwardingTargetSessionId,
|
|
4
|
+
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
5
|
+
SUBAGENT_PARENT_SESSION_ENV_KEY,
|
|
6
|
+
} from "../src/permission-forwarding";
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllEnvs();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("SUBAGENT_PARENT_SESSION_ENV_CANDIDATES", () => {
|
|
13
|
+
test("is an array containing PI_AGENT_ROUTER_PARENT_SESSION_ID", () => {
|
|
14
|
+
expect(Array.isArray(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES)).toBe(true);
|
|
15
|
+
expect(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES).toContain(
|
|
16
|
+
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("deprecated SUBAGENT_PARENT_SESSION_ENV_KEY equals the first candidate", () => {
|
|
21
|
+
expect(SUBAGENT_PARENT_SESSION_ENV_KEY).toBe(
|
|
22
|
+
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0],
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("resolvePermissionForwardingTargetSessionId", () => {
|
|
28
|
+
test("hasUI=true returns the current session ID (UI host owns forwarding)", () => {
|
|
29
|
+
expect(
|
|
30
|
+
resolvePermissionForwardingTargetSessionId({
|
|
31
|
+
hasUI: true,
|
|
32
|
+
isSubagent: false,
|
|
33
|
+
currentSessionId: "parent-session-abc",
|
|
34
|
+
env: {},
|
|
35
|
+
}),
|
|
36
|
+
).toBe("parent-session-abc");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("hasUI=true with isSubagent=true still returns current session ID", () => {
|
|
40
|
+
expect(
|
|
41
|
+
resolvePermissionForwardingTargetSessionId({
|
|
42
|
+
hasUI: true,
|
|
43
|
+
isSubagent: true,
|
|
44
|
+
currentSessionId: "session-xyz",
|
|
45
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "other" },
|
|
46
|
+
}),
|
|
47
|
+
).toBe("session-xyz");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("hasUI=false, isSubagent=false returns null", () => {
|
|
51
|
+
expect(
|
|
52
|
+
resolvePermissionForwardingTargetSessionId({
|
|
53
|
+
hasUI: false,
|
|
54
|
+
isSubagent: false,
|
|
55
|
+
currentSessionId: "session-xyz",
|
|
56
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session-abc" },
|
|
57
|
+
}),
|
|
58
|
+
).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("isSubagent=true, no candidates set returns null", () => {
|
|
62
|
+
expect(
|
|
63
|
+
resolvePermissionForwardingTargetSessionId({
|
|
64
|
+
hasUI: false,
|
|
65
|
+
isSubagent: true,
|
|
66
|
+
currentSessionId: "session-xyz",
|
|
67
|
+
env: {},
|
|
68
|
+
}),
|
|
69
|
+
).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("isSubagent=true, PI_AGENT_ROUTER_PARENT_SESSION_ID set returns its value", () => {
|
|
73
|
+
expect(
|
|
74
|
+
resolvePermissionForwardingTargetSessionId({
|
|
75
|
+
hasUI: false,
|
|
76
|
+
isSubagent: true,
|
|
77
|
+
currentSessionId: "session-xyz",
|
|
78
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session-abc" },
|
|
79
|
+
}),
|
|
80
|
+
).toBe("parent-session-abc");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("isSubagent=true, first candidate absent but second set returns second value", () => {
|
|
84
|
+
// Inject a second candidate at test-time to validate the iteration logic
|
|
85
|
+
// without waiting for a real extension to adopt the convention.
|
|
86
|
+
const originalCandidates = [...SUBAGENT_PARENT_SESSION_ENV_CANDIDATES];
|
|
87
|
+
// Mutate the array via index-assignment through a cast so we can test
|
|
88
|
+
// multi-candidate iteration without changing the exported constant type.
|
|
89
|
+
// This is test-only; production code never mutates the array.
|
|
90
|
+
(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES as unknown as string[]).push(
|
|
91
|
+
"PI_SUBAGENT_PARENT_SESSION_ID_TEST_ONLY",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
expect(
|
|
96
|
+
resolvePermissionForwardingTargetSessionId({
|
|
97
|
+
hasUI: false,
|
|
98
|
+
isSubagent: true,
|
|
99
|
+
currentSessionId: "session-xyz",
|
|
100
|
+
env: {
|
|
101
|
+
PI_SUBAGENT_PARENT_SESSION_ID_TEST_ONLY: "parent-from-second",
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
).toBe("parent-from-second");
|
|
105
|
+
} finally {
|
|
106
|
+
// Restore original array contents.
|
|
107
|
+
(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES as unknown as string[]).length =
|
|
108
|
+
originalCandidates.length;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("isSubagent=true, candidate value is empty string returns null", () => {
|
|
113
|
+
expect(
|
|
114
|
+
resolvePermissionForwardingTargetSessionId({
|
|
115
|
+
hasUI: false,
|
|
116
|
+
isSubagent: true,
|
|
117
|
+
currentSessionId: "session-xyz",
|
|
118
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "" },
|
|
119
|
+
}),
|
|
120
|
+
).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("isSubagent=true, candidate value is 'unknown' returns null", () => {
|
|
124
|
+
expect(
|
|
125
|
+
resolvePermissionForwardingTargetSessionId({
|
|
126
|
+
hasUI: false,
|
|
127
|
+
isSubagent: true,
|
|
128
|
+
currentSessionId: "session-xyz",
|
|
129
|
+
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "unknown" },
|
|
130
|
+
}),
|
|
131
|
+
).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("env defaults to process.env when omitted", () => {
|
|
135
|
+
vi.stubEnv("PI_AGENT_ROUTER_PARENT_SESSION_ID", "env-session-abc");
|
|
136
|
+
expect(
|
|
137
|
+
resolvePermissionForwardingTargetSessionId({
|
|
138
|
+
hasUI: false,
|
|
139
|
+
isSubagent: true,
|
|
140
|
+
}),
|
|
141
|
+
).toBe("env-session-abc");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
rmSync,
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
11
|
import { dirname, join, resolve } from "node:path";
|
|
12
12
|
import { test } from "vitest";
|
|
13
13
|
|
|
@@ -1831,6 +1831,188 @@ test("external_directory permission is not affected by unrelated surface keys",
|
|
|
1831
1831
|
}
|
|
1832
1832
|
});
|
|
1833
1833
|
|
|
1834
|
+
test("skill pattern map in agent frontmatter overrides global skill policy", () => {
|
|
1835
|
+
const { manager, cleanup } = createManager(
|
|
1836
|
+
{
|
|
1837
|
+
permission: { "*": "deny", skill: "deny" },
|
|
1838
|
+
},
|
|
1839
|
+
{
|
|
1840
|
+
reviewer: `---
|
|
1841
|
+
name: reviewer
|
|
1842
|
+
permission:
|
|
1843
|
+
skill:
|
|
1844
|
+
"*": ask
|
|
1845
|
+
"pi-*": allow
|
|
1846
|
+
---
|
|
1847
|
+
`,
|
|
1848
|
+
},
|
|
1849
|
+
);
|
|
1850
|
+
|
|
1851
|
+
try {
|
|
1852
|
+
// Matches agent frontmatter pi-* pattern
|
|
1853
|
+
const allowed = manager.checkPermission(
|
|
1854
|
+
"skill",
|
|
1855
|
+
{ name: "pi-code-review" },
|
|
1856
|
+
"reviewer",
|
|
1857
|
+
);
|
|
1858
|
+
assert.equal(allowed.state, "allow");
|
|
1859
|
+
assert.equal(allowed.matchedPattern, "pi-*");
|
|
1860
|
+
assert.equal(allowed.source, "skill");
|
|
1861
|
+
|
|
1862
|
+
// Falls through to agent frontmatter catch-all
|
|
1863
|
+
const asked = manager.checkPermission(
|
|
1864
|
+
"skill",
|
|
1865
|
+
{ name: "other-skill" },
|
|
1866
|
+
"reviewer",
|
|
1867
|
+
);
|
|
1868
|
+
assert.equal(asked.state, "ask");
|
|
1869
|
+
assert.equal(asked.matchedPattern, "*");
|
|
1870
|
+
|
|
1871
|
+
// No agent override — global deny applies
|
|
1872
|
+
const denied = manager.checkPermission("skill", { name: "pi-code-review" });
|
|
1873
|
+
assert.equal(denied.state, "deny");
|
|
1874
|
+
assert.equal(denied.source, "skill");
|
|
1875
|
+
} finally {
|
|
1876
|
+
cleanup();
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
test("external_directory pattern map in agent frontmatter overrides global policy", () => {
|
|
1881
|
+
const { manager, cleanup } = createManager(
|
|
1882
|
+
{
|
|
1883
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
1884
|
+
},
|
|
1885
|
+
{
|
|
1886
|
+
trusted: `---
|
|
1887
|
+
name: trusted
|
|
1888
|
+
permission:
|
|
1889
|
+
external_directory:
|
|
1890
|
+
"*": deny
|
|
1891
|
+
"~/Downloads/*": allow
|
|
1892
|
+
---
|
|
1893
|
+
`,
|
|
1894
|
+
},
|
|
1895
|
+
);
|
|
1896
|
+
|
|
1897
|
+
try {
|
|
1898
|
+
// Matches agent frontmatter ~/Downloads/* pattern
|
|
1899
|
+
const allowed = manager.checkPermission(
|
|
1900
|
+
"external_directory",
|
|
1901
|
+
{ path: `${homedir()}/Downloads/file.txt` },
|
|
1902
|
+
"trusted",
|
|
1903
|
+
);
|
|
1904
|
+
assert.equal(allowed.state, "allow");
|
|
1905
|
+
assert.equal(allowed.matchedPattern, "~/Downloads/*");
|
|
1906
|
+
assert.equal(allowed.source, "special");
|
|
1907
|
+
|
|
1908
|
+
// Falls through to agent frontmatter catch-all deny
|
|
1909
|
+
const denied = manager.checkPermission(
|
|
1910
|
+
"external_directory",
|
|
1911
|
+
{ path: `${homedir()}/Documents/secret.txt` },
|
|
1912
|
+
"trusted",
|
|
1913
|
+
);
|
|
1914
|
+
assert.equal(denied.state, "deny");
|
|
1915
|
+
assert.equal(denied.matchedPattern, "*");
|
|
1916
|
+
|
|
1917
|
+
// No agent override — global deny applies
|
|
1918
|
+
const globalDenied = manager.checkPermission("external_directory", {});
|
|
1919
|
+
assert.equal(globalDenied.state, "deny");
|
|
1920
|
+
assert.equal(globalDenied.source, "special");
|
|
1921
|
+
} finally {
|
|
1922
|
+
cleanup();
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
test("project-agent frontmatter skill rules override global-agent frontmatter skill rules", () => {
|
|
1927
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1928
|
+
{
|
|
1929
|
+
permission: { "*": "deny" },
|
|
1930
|
+
},
|
|
1931
|
+
{
|
|
1932
|
+
analyst: `---
|
|
1933
|
+
name: analyst
|
|
1934
|
+
permission:
|
|
1935
|
+
skill:
|
|
1936
|
+
"*": ask
|
|
1937
|
+
---
|
|
1938
|
+
`,
|
|
1939
|
+
},
|
|
1940
|
+
{
|
|
1941
|
+
projectAgentFiles: {
|
|
1942
|
+
analyst: `---
|
|
1943
|
+
name: analyst
|
|
1944
|
+
permission:
|
|
1945
|
+
skill:
|
|
1946
|
+
"pi-*": allow
|
|
1947
|
+
"*": deny
|
|
1948
|
+
---
|
|
1949
|
+
`,
|
|
1950
|
+
},
|
|
1951
|
+
},
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1954
|
+
try {
|
|
1955
|
+
// Project-agent pi-* wins over global-agent *: ask
|
|
1956
|
+
const allowed = manager.checkPermission(
|
|
1957
|
+
"skill",
|
|
1958
|
+
{ name: "pi-code-review" },
|
|
1959
|
+
"analyst",
|
|
1960
|
+
);
|
|
1961
|
+
assert.equal(allowed.state, "allow");
|
|
1962
|
+
assert.equal(allowed.matchedPattern, "pi-*");
|
|
1963
|
+
|
|
1964
|
+
// Project-agent *: deny wins over global-agent *: ask
|
|
1965
|
+
const denied = manager.checkPermission(
|
|
1966
|
+
"skill",
|
|
1967
|
+
{ name: "other-skill" },
|
|
1968
|
+
"analyst",
|
|
1969
|
+
);
|
|
1970
|
+
assert.equal(denied.state, "deny");
|
|
1971
|
+
assert.equal(denied.matchedPattern, "*");
|
|
1972
|
+
} finally {
|
|
1973
|
+
cleanup();
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
test("project-agent frontmatter external_directory rules override global-agent frontmatter rules", () => {
|
|
1978
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1979
|
+
{
|
|
1980
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
1981
|
+
},
|
|
1982
|
+
{
|
|
1983
|
+
analyst: `---
|
|
1984
|
+
name: analyst
|
|
1985
|
+
permission:
|
|
1986
|
+
external_directory: ask
|
|
1987
|
+
---
|
|
1988
|
+
`,
|
|
1989
|
+
},
|
|
1990
|
+
{
|
|
1991
|
+
projectAgentFiles: {
|
|
1992
|
+
analyst: `---
|
|
1993
|
+
name: analyst
|
|
1994
|
+
permission:
|
|
1995
|
+
external_directory: allow
|
|
1996
|
+
---
|
|
1997
|
+
`,
|
|
1998
|
+
},
|
|
1999
|
+
},
|
|
2000
|
+
);
|
|
2001
|
+
|
|
2002
|
+
try {
|
|
2003
|
+
// Project-agent allow wins over global-agent ask
|
|
2004
|
+
const result = manager.checkPermission("external_directory", {}, "analyst");
|
|
2005
|
+
assert.equal(result.state, "allow");
|
|
2006
|
+
assert.equal(result.source, "special");
|
|
2007
|
+
|
|
2008
|
+
// Without agent context, global config deny applies
|
|
2009
|
+
const globalResult = manager.checkPermission("external_directory", {});
|
|
2010
|
+
assert.equal(globalResult.state, "deny");
|
|
2011
|
+
} finally {
|
|
2012
|
+
cleanup();
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
|
|
1834
2016
|
test("tool_call blocks path-bearing tools outside cwd when external_directory is denied", async () => {
|
|
1835
2017
|
const rootDir = mkdtempSync(join(tmpdir(), "pi-permission-system-boundary-"));
|
|
1836
2018
|
const cwd = join(rootDir, "repo");
|
|
@@ -61,11 +61,86 @@ describe("isSubagentExecutionContext — env hint detection", () => {
|
|
|
61
61
|
).toBe(true);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// nicobailon/pi-subagents keys
|
|
65
|
+
test("returns true when PI_SUBAGENT_CHILD is set", () => {
|
|
66
|
+
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
|
|
67
|
+
expect(
|
|
68
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
69
|
+
).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("returns true when PI_SUBAGENT_RUN_ID is set", () => {
|
|
73
|
+
vi.stubEnv("PI_SUBAGENT_RUN_ID", "run-abc");
|
|
74
|
+
expect(
|
|
75
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns true when PI_SUBAGENT_CHILD_AGENT is set", () => {
|
|
80
|
+
vi.stubEnv("PI_SUBAGENT_CHILD_AGENT", "worker");
|
|
81
|
+
expect(
|
|
82
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
83
|
+
).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("returns true when PI_SUBAGENT_DEPTH is set", () => {
|
|
87
|
+
vi.stubEnv("PI_SUBAGENT_DEPTH", "1");
|
|
88
|
+
expect(
|
|
89
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("returns true when PI_SUBAGENT_DEPTH is zero (depth-0 is still a subagent context)", () => {
|
|
94
|
+
vi.stubEnv("PI_SUBAGENT_DEPTH", "0");
|
|
95
|
+
expect(
|
|
96
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
97
|
+
).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// HazAT/pi-interactive-subagents keys
|
|
101
|
+
test("returns true when PI_SUBAGENT_NAME is set", () => {
|
|
102
|
+
vi.stubEnv("PI_SUBAGENT_NAME", "my-agent");
|
|
103
|
+
expect(
|
|
104
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
105
|
+
).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("returns true when PI_SUBAGENT_ID is set", () => {
|
|
109
|
+
vi.stubEnv("PI_SUBAGENT_ID", "id-xyz");
|
|
110
|
+
expect(
|
|
111
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
112
|
+
).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns true when PI_SUBAGENT_SESSION is set", () => {
|
|
116
|
+
vi.stubEnv("PI_SUBAGENT_SESSION", "session-xyz");
|
|
117
|
+
expect(
|
|
118
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
119
|
+
).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("returns true when PI_SUBAGENT_ACTIVITY_FILE is set", () => {
|
|
123
|
+
vi.stubEnv("PI_SUBAGENT_ACTIVITY_FILE", "/tmp/activity.json");
|
|
124
|
+
expect(
|
|
125
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
126
|
+
).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("covers all declared SUBAGENT_ENV_HINT_KEYS", () => {
|
|
65
130
|
// Verify the keys we test match what the module declares.
|
|
66
131
|
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_IS_SUBAGENT");
|
|
67
132
|
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION_ID");
|
|
68
133
|
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_AGENT_ROUTER_SUBAGENT");
|
|
134
|
+
// nicobailon/pi-subagents
|
|
135
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD");
|
|
136
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_RUN_ID");
|
|
137
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD_AGENT");
|
|
138
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_DEPTH");
|
|
139
|
+
// HazAT/pi-interactive-subagents
|
|
140
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_NAME");
|
|
141
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ID");
|
|
142
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION");
|
|
143
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ACTIVITY_FILE");
|
|
69
144
|
});
|
|
70
145
|
|
|
71
146
|
test("returns false when env hint value is empty string", () => {
|