@orchestrator-claude/definitions 3.34.0 → 3.35.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/agents/ciclo-engineer.md +267 -0
- package/agents/orchestrator.md +14 -3
- package/dist/install/DevCheckoutDetector.d.ts +30 -13
- package/dist/install/DevCheckoutDetector.d.ts.map +1 -1
- package/dist/install/DevCheckoutDetector.js +33 -16
- package/dist/install/DevCheckoutDetector.js.map +1 -1
- package/hooks/agent-git-guard.mjs +118 -0
- package/hooks/agent-git-guard.ts +166 -0
- package/hooks/hooks.json +30 -15
- package/hooks/playwright-output-guard.mjs +136 -0
- package/hooks/playwright-output-guard.ts +130 -0
- package/hooks/pre-agent-dispatch.mjs +346 -0
- package/hooks/pre-agent-dispatch.ts +287 -0
- package/hooks/session-start.mjs +8 -36
- package/hooks/session-start.ts +15 -72
- package/hooks/subagent-stop.mjs +0 -18
- package/hooks/subagent-stop.ts +29 -13
- package/hooks/verification-before-completion.mjs +105 -0
- package/hooks/verification-before-completion.ts +97 -0
- package/hooks-settings-template.json +19 -4
- package/manifest.json +54 -6
- package/package.json +1 -1
- package/personas/INDEX.md +46 -0
- package/skills/ciclo-lane-dispatch/SKILL.md +158 -0
- package/skills/orchestrator/SKILL.md +3 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-git-guard.ts — PreToolUse[Bash] hook (TD-169 enforcement)
|
|
3
|
+
*
|
|
4
|
+
* BLOCKS `git push`, `git merge`, and `gh pr merge` when the command is run by a
|
|
5
|
+
* SUB-AGENT, while ALLOWING the main orchestrator (main conversation) to run them.
|
|
6
|
+
*
|
|
7
|
+
* Mechanism: Claude Code injects `agent_id` into PreToolUse stdin ONLY when the
|
|
8
|
+
* hook fires inside a sub-agent call (see hook-reference: "Present only when the
|
|
9
|
+
* hook fires inside a subagent call"). workflow-guard.ts uses the same signal for
|
|
10
|
+
* Write/Edit (the sub-agent branch of decideWorkflowGuardAction). This hook extends
|
|
11
|
+
* it to the git escape-to-remote path.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists: S288 governance breach — an `implementer` sub-agent autonomously
|
|
14
|
+
* committed + pushed + `gh pr merge`'d an unauthorized FK-drop to main. Prose
|
|
15
|
+
* commit-policy in dispatch prompts is unenforced (agents hold Bash + gh). This hook
|
|
16
|
+
* is the enforcement layer (cf. workflow-guard model; S288-handoff §TD-169).
|
|
17
|
+
*
|
|
18
|
+
* Scope: push / merge ONLY — NOT commit. A local commit by a sub-agent is recoverable
|
|
19
|
+
* and is sometimes legitimate (orchestrator Commit Policy option b). Push/merge to a
|
|
20
|
+
* remote is the unrecoverable breach vector, so that is what we deny.
|
|
21
|
+
*
|
|
22
|
+
* RFC-040 TD-204 §⑤-A (S366) — Classe-A asset-safety classification:
|
|
23
|
+
* this gate is classified Classe-A (same family as workflow-guard.ts's
|
|
24
|
+
* `.orchestrator`-registration and workflow-active checks — asset-safety
|
|
25
|
+
* gates that must remain intact everywhere, never silently no-op). UNLIKE
|
|
26
|
+
* workflow-guard.ts, this gate's decision (evaluateGitGuard) is a pure
|
|
27
|
+
* function of (command, agentId, agentType) ONLY — it never inspects any
|
|
28
|
+
* file_path or cwd. A sub-agent attempting `git push`/`git merge`/`gh pr
|
|
29
|
+
* merge` is denied identically no matter which project's working tree the
|
|
30
|
+
* Bash command runs against. This makes it **globally cwd-safe already**:
|
|
31
|
+
* no TD-204 ⑤-A remediation (project-root resolution / `.unmanaged`
|
|
32
|
+
* sentinel / unregistered-path branching) is needed here — there is no
|
|
33
|
+
* path-dependent branch to make cwd-scoped-safe in the first place. See
|
|
34
|
+
* tests/unit/hooks/agent-git-guard-path-invariance.test.ts for the
|
|
35
|
+
* property-style proof.
|
|
36
|
+
*
|
|
37
|
+
* Decision:
|
|
38
|
+
* - agent_id absent (main orchestrator) → silent pass-through (allow)
|
|
39
|
+
* - agent_id present + push/merge/gh-pr-merge → DENY
|
|
40
|
+
* - agent_id present + commit/read-only/other → silent pass-through (allow)
|
|
41
|
+
* - SKIP_AGENT_GIT_GUARD=true → allow (escape hatch, logged)
|
|
42
|
+
* - parse error / empty stdin → fail-open (allow)
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { log, readStdin, getField, outputPreToolUse } from './lib/api-client.js';
|
|
46
|
+
|
|
47
|
+
const HOOK = 'AGENT-GIT-GUARD';
|
|
48
|
+
|
|
49
|
+
// ─────────────────────────────────────────────────────────────
|
|
50
|
+
// Dependency injection interface (enables testing without IO)
|
|
51
|
+
// ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface HookDeps {
|
|
54
|
+
readStdin: () => Promise<Record<string, unknown>>;
|
|
55
|
+
output: (opts: { decision: 'allow' | 'deny'; reason?: string; context?: string }) => void;
|
|
56
|
+
log: (prefix: string, msg: string) => void;
|
|
57
|
+
getEnv: (key: string) => string | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultDeps: HookDeps = {
|
|
61
|
+
readStdin,
|
|
62
|
+
output: outputPreToolUse,
|
|
63
|
+
log,
|
|
64
|
+
getEnv: (key) => process.env[key],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────────────────────────────────────────
|
|
68
|
+
// Pure helpers (exported for testing)
|
|
69
|
+
// ─────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
// `(\s|$)` after the verb avoids matching plumbing like `git merge-base`.
|
|
72
|
+
const PUSH = /\bgit\s+push(\s|$)/;
|
|
73
|
+
const MERGE = /\bgit\s+merge(\s|$)/;
|
|
74
|
+
const GH_PR_MERGE = /\bgh\s+pr\s+merge(\s|$)/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* True when the bash command performs a push or merge (the escape-to-remote
|
|
78
|
+
* operations owned exclusively by the main orchestrator). Excludes `git merge-base`
|
|
79
|
+
* (read-only plumbing), `git commit`, and read-only git/gh commands.
|
|
80
|
+
*/
|
|
81
|
+
export function commandIsPushOrMerge(command: string): boolean {
|
|
82
|
+
return PUSH.test(command) || MERGE.test(command) || GH_PR_MERGE.test(command);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface GuardResult {
|
|
86
|
+
decision: 'allow' | 'deny';
|
|
87
|
+
reason?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pure decision: deny only when a sub-agent (agentId present) attempts a
|
|
92
|
+
* push/merge. Empty agentId == main orchestrator == always allowed.
|
|
93
|
+
*
|
|
94
|
+
* Path-independent by construction (RFC-040 TD-204 §⑤-A, Classe-A): the
|
|
95
|
+
* signature takes no file_path/cwd input, so the decision is provably the
|
|
96
|
+
* same for every project's working tree — globally cwd-safe without any
|
|
97
|
+
* project-root resolution.
|
|
98
|
+
*/
|
|
99
|
+
export function evaluateGitGuard(
|
|
100
|
+
command: string,
|
|
101
|
+
agentId: string,
|
|
102
|
+
agentType?: string
|
|
103
|
+
): GuardResult {
|
|
104
|
+
if (!agentId) return { decision: 'allow' }; // main orchestrator owns git
|
|
105
|
+
if (!commandIsPushOrMerge(command)) return { decision: 'allow' }; // commit/read-only fine
|
|
106
|
+
|
|
107
|
+
const who = agentType && agentType.length > 0 ? `Sub-agent '${agentType}'` : 'Sub-agent';
|
|
108
|
+
return {
|
|
109
|
+
decision: 'deny',
|
|
110
|
+
reason:
|
|
111
|
+
`[AGENT-GIT-GUARD] ${who} is not permitted to run git push / git merge / gh pr merge. ` +
|
|
112
|
+
`Per RFC-022 + TD-169, push and merge are owned EXCLUSIVELY by the main orchestrator ` +
|
|
113
|
+
`(the main conversation). Make your changes locally and return them in your report — ` +
|
|
114
|
+
`the orchestrator reviews, then pushes/merges. (Prevents the S288 unauthorized merge-to-main breach.)`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────────────────────
|
|
119
|
+
// Core logic (exported for testing)
|
|
120
|
+
// ─────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export async function mainWithDeps(deps: HookDeps): Promise<void> {
|
|
123
|
+
const { readStdin, output, log, getEnv } = deps;
|
|
124
|
+
|
|
125
|
+
const stdin = await readStdin();
|
|
126
|
+
|
|
127
|
+
const command =
|
|
128
|
+
(getField(stdin, 'tool_input.command') as string | undefined) ||
|
|
129
|
+
(getField(stdin, 'command') as string | undefined) ||
|
|
130
|
+
'';
|
|
131
|
+
|
|
132
|
+
const agentId = getField(stdin, 'agent_id');
|
|
133
|
+
const agentType = getField(stdin, 'agent_type');
|
|
134
|
+
|
|
135
|
+
// Escape hatch (parity with workflow-guard SKIP_*). Process-env only — a
|
|
136
|
+
// sub-agent's transient shell env does not persist into the hook process.
|
|
137
|
+
if (getEnv('SKIP_AGENT_GIT_GUARD') === 'true') {
|
|
138
|
+
log(HOOK, `BYPASS (SKIP_AGENT_GIT_GUARD): ${command.slice(0, 60)}`);
|
|
139
|
+
return; // silent pass-through
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result = evaluateGitGuard(command, agentId, agentType);
|
|
143
|
+
|
|
144
|
+
if (result.decision === 'deny') {
|
|
145
|
+
log(HOOK, `DENY (agent=${agentType || 'unknown'}): ${command.slice(0, 80)}`);
|
|
146
|
+
output({ decision: 'deny', reason: result.reason });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Allow path is a silent pass-through so we never clobber the context that the
|
|
151
|
+
// sibling Bash hook (verification-before-completion) injects.
|
|
152
|
+
log(
|
|
153
|
+
HOOK,
|
|
154
|
+
`ALLOW (agent=${agentId ? agentType || 'sub-agent' : 'main'}): ${command.slice(0, 60)}`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─────────────────────────────────────────────────────────────
|
|
159
|
+
// Entry point (production use)
|
|
160
|
+
// ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async function main(): Promise<void> {
|
|
163
|
+
await mainWithDeps(defaultDeps);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
main().catch(() => process.exit(0));
|
package/hooks/hooks.json
CHANGED
|
@@ -4,59 +4,74 @@
|
|
|
4
4
|
{
|
|
5
5
|
"matcher": "startup|resume",
|
|
6
6
|
"hooks": [
|
|
7
|
-
{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs", "timeout": 10000 },
|
|
8
|
-
{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/sprint-registry.mjs", "timeout": 5000 }
|
|
7
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"", "timeout": 10000 },
|
|
8
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sprint-registry.mjs\"", "timeout": 5000 }
|
|
9
9
|
]
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"UserPromptSubmit": [
|
|
13
13
|
{
|
|
14
14
|
"matcher": "",
|
|
15
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt.mjs", "timeout": 10000 }]
|
|
15
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt.mjs\"", "timeout": 10000 }]
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
18
|
"PreToolUse": [
|
|
19
19
|
{
|
|
20
|
-
"matcher": "
|
|
21
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/gate-guardian.mjs", "timeout": 15000 }]
|
|
20
|
+
"matcher": "mcp__(plugin_orchestrator_)?orchestrator-tools__advancePhase",
|
|
21
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-guardian.mjs\"", "timeout": 15000 }]
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"matcher": "Write|Edit",
|
|
25
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/workflow-guard.mjs", "timeout": 10000 }]
|
|
25
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/workflow-guard.mjs\"", "timeout": 10000 }]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"matcher": "Bash",
|
|
29
|
+
"hooks": [
|
|
30
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/verification-before-completion.mjs\"", "timeout": 5000 },
|
|
31
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-git-guard.mjs\"", "timeout": 5000 }
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"matcher": "Agent",
|
|
36
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-agent-dispatch.mjs\"", "timeout": 5000 }]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"matcher": "mcp__plugin_playwright_playwright__browser_take_screenshot|mcp__plugin_playwright_playwright__browser_pdf_save",
|
|
40
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/playwright-output-guard.mjs\"", "timeout": 5000 }]
|
|
26
41
|
}
|
|
27
42
|
],
|
|
28
43
|
"SubagentStart": [
|
|
29
44
|
{
|
|
30
|
-
"matcher": "implementer|specifier|planner|task-generator|reviewer|researcher",
|
|
31
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-start.mjs", "timeout": 10000 }]
|
|
45
|
+
"matcher": "(orchestrator:)?(implementer|specifier|planner|task-generator|reviewer|researcher)",
|
|
46
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-start.mjs\"", "timeout": 10000 }]
|
|
32
47
|
}
|
|
33
48
|
],
|
|
34
49
|
"SubagentStop": [
|
|
35
50
|
{
|
|
36
|
-
"matcher": "implementer|specifier|planner|task-generator|reviewer|researcher",
|
|
37
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop.mjs", "timeout": 30000 }]
|
|
51
|
+
"matcher": "(orchestrator:)?(implementer|specifier|planner|task-generator|reviewer|researcher)",
|
|
52
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop.mjs\"", "timeout": 30000 }]
|
|
38
53
|
}
|
|
39
54
|
],
|
|
40
55
|
"Stop": [
|
|
41
56
|
{
|
|
42
57
|
"matcher": "",
|
|
43
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dangling-guard.mjs", "timeout": 15000 }]
|
|
58
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/dangling-guard.mjs\"", "timeout": 15000 }]
|
|
44
59
|
}
|
|
45
60
|
],
|
|
46
61
|
"PostToolUse": [
|
|
47
62
|
{
|
|
48
63
|
"matcher": "",
|
|
49
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/mailbox-listener.mjs", "timeout": 5000 }]
|
|
64
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/mailbox-listener.mjs\"", "timeout": 5000 }]
|
|
50
65
|
},
|
|
51
66
|
{
|
|
52
|
-
"matcher": "
|
|
53
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/post-implement.mjs", "timeout": 15000 }]
|
|
67
|
+
"matcher": "mcp__(plugin_orchestrator_)?orchestrator-tools__advancePhase",
|
|
68
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/post-implement.mjs\"", "timeout": 15000 }]
|
|
54
69
|
}
|
|
55
70
|
],
|
|
56
71
|
"PostCompact": [
|
|
57
72
|
{
|
|
58
73
|
"matcher": "",
|
|
59
|
-
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/post-compact.mjs", "timeout": 5000 }]
|
|
74
|
+
"hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/post-compact.mjs\"", "timeout": 5000 }]
|
|
60
75
|
}
|
|
61
76
|
]
|
|
62
77
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createRequire as __createRequireShim } from 'node:module'; const require = __createRequireShim(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// hooks/playwright-output-guard.ts
|
|
4
|
+
import { dirname as dirname3, isAbsolute, normalize, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
// hooks/lib/api-client.ts
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, readdirSync } from "node:fs";
|
|
8
|
+
import { join as join2, dirname as dirname2 } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
// ../project-root-resolver/dist/index.js
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
function resolveProjectRoot(cwd, sentinel = ".orchestrator") {
|
|
15
|
+
let currentDir = cwd;
|
|
16
|
+
for (; ; ) {
|
|
17
|
+
if (existsSync(join(currentDir, sentinel))) {
|
|
18
|
+
return currentDir;
|
|
19
|
+
}
|
|
20
|
+
const parentDir = dirname(currentDir);
|
|
21
|
+
if (parentDir === currentDir) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
currentDir = parentDir;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// hooks/lib/api-client.ts
|
|
29
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
var __dirname = dirname2(__filename);
|
|
31
|
+
var PROJECT_ROOT = join2(__dirname, "..", "..", "..");
|
|
32
|
+
var STATE_DIR = join2(PROJECT_ROOT, ".orchestrator", ".state");
|
|
33
|
+
var JWT_CACHE = join2(STATE_DIR, "jwt-token");
|
|
34
|
+
var DEBUG_LOG = join2(STATE_DIR, "hook-debug.log");
|
|
35
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
36
|
+
var API_URL = process.env.ORCHESTRATOR_API_URL || "http://localhost:3001";
|
|
37
|
+
var AUTH_EMAIL = process.env.ORCHESTRATOR_ADMIN_EMAIL || process.env.ORCHESTRATOR_AUTH_EMAIL || "system@orchestrator.local";
|
|
38
|
+
var AUTH_PASSWORD = process.env.ORCHESTRATOR_ADMIN_PASSWORD || process.env.ORCHESTRATOR_AUTH_PASSWORD || "system123";
|
|
39
|
+
var PROJECT_ID = process.env.ORCHESTRATOR_PROJECT_ID || "";
|
|
40
|
+
function resolveHookProjectRoot(cwd) {
|
|
41
|
+
return resolveProjectRoot(cwd);
|
|
42
|
+
}
|
|
43
|
+
function log(prefix, msg) {
|
|
44
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d+Z$/, "Z");
|
|
45
|
+
try {
|
|
46
|
+
const line = `[${ts}] ${prefix}: ${msg}
|
|
47
|
+
`;
|
|
48
|
+
writeFileSync(DEBUG_LOG, line, { flag: "a" });
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
var LAST_COMPLETED_FILE = join2(STATE_DIR, "last-completed-workflow.json");
|
|
53
|
+
var LAST_COMPLETED_TTL_MS = 60 * 60 * 1e3;
|
|
54
|
+
var INVOCATIONS_DIR = join2(STATE_DIR, "invocations");
|
|
55
|
+
var INVOCATION_STATE_TTL_MS = 4 * 60 * 60 * 1e3;
|
|
56
|
+
var DISPATCH_INTENT_DIR = join2(STATE_DIR, "dispatch-intent");
|
|
57
|
+
async function readStdin() {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
for await (const chunk of process.stdin) {
|
|
60
|
+
chunks.push(chunk);
|
|
61
|
+
}
|
|
62
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
63
|
+
if (!raw) return {};
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function getField(obj, path) {
|
|
71
|
+
const parts = path.split(".");
|
|
72
|
+
let current = obj;
|
|
73
|
+
for (const part of parts) {
|
|
74
|
+
if (current == null || typeof current !== "object") return "";
|
|
75
|
+
current = current[part];
|
|
76
|
+
}
|
|
77
|
+
return current != null ? String(current) : "";
|
|
78
|
+
}
|
|
79
|
+
function outputPreToolUse(opts) {
|
|
80
|
+
const output = {
|
|
81
|
+
hookSpecificOutput: {
|
|
82
|
+
hookEventName: "PreToolUse",
|
|
83
|
+
permissionDecision: opts.decision,
|
|
84
|
+
...opts.reason && { permissionDecisionReason: opts.reason },
|
|
85
|
+
...opts.context && { additionalContext: opts.context }
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
console.log(JSON.stringify(output));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// hooks/playwright-output-guard.ts
|
|
92
|
+
var HOOK = "PLAYWRIGHT-OUTPUT-GUARD";
|
|
93
|
+
var PROJECT_ROOT2 = process.env.CLAUDE_PROJECT_DIR ?? resolveHookProjectRoot(process.cwd()) ?? process.cwd();
|
|
94
|
+
var GUIDANCE = 'Playwright Output Guard: screenshots n\xE3o podem cair na raiz do repo (auto-checkpoint os varre \u2014 recorr\xEAncia S266-S268). Use `filename: ".playwright-mcp/<nome>.png"` (descart\xE1vel, gitignored), `filename: ".cobaia-evidence/<sess\xE3o>/<nome>.png"` (evid\xEAncia a versionar), ou omita `filename` (default j\xE1 vai para .playwright-mcp/).';
|
|
95
|
+
function evaluateFilename(filename) {
|
|
96
|
+
if (!filename) return { decision: "allow" };
|
|
97
|
+
const resolved = isAbsolute(filename) ? normalize(filename) : resolve(PROJECT_ROOT2, filename);
|
|
98
|
+
const dir = dirname3(resolved);
|
|
99
|
+
if (dir === PROJECT_ROOT2) {
|
|
100
|
+
return { decision: "deny", reason: GUIDANCE };
|
|
101
|
+
}
|
|
102
|
+
if (!resolved.startsWith(PROJECT_ROOT2 + "/") && !isAbsolute(filename)) {
|
|
103
|
+
return { decision: "deny", reason: GUIDANCE };
|
|
104
|
+
}
|
|
105
|
+
return { decision: "allow" };
|
|
106
|
+
}
|
|
107
|
+
var defaultDeps = {
|
|
108
|
+
readStdin,
|
|
109
|
+
output: outputPreToolUse,
|
|
110
|
+
log
|
|
111
|
+
};
|
|
112
|
+
async function mainWithDeps(deps) {
|
|
113
|
+
const stdin = await deps.readStdin();
|
|
114
|
+
const filename = getField(stdin, "tool_input.filename");
|
|
115
|
+
const result = evaluateFilename(filename || void 0);
|
|
116
|
+
deps.log(HOOK, `filename=${filename || "(none)"} decision=${result.decision}`);
|
|
117
|
+
if (result.decision === "deny") {
|
|
118
|
+
deps.output({ decision: "deny", reason: result.reason });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
deps.output({
|
|
122
|
+
decision: "allow",
|
|
123
|
+
...filename ? {} : {
|
|
124
|
+
context: "[PLAYWRIGHT-OUTPUT-GUARD] sem filename: output em .playwright-mcp/ (gitignored)."
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function main() {
|
|
129
|
+
await mainWithDeps(defaultDeps);
|
|
130
|
+
}
|
|
131
|
+
main().catch(() => process.exit(0));
|
|
132
|
+
export {
|
|
133
|
+
PROJECT_ROOT2 as PROJECT_ROOT,
|
|
134
|
+
evaluateFilename,
|
|
135
|
+
mainWithDeps
|
|
136
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* playwright-output-guard.ts — PreToolUse[browser_take_screenshot] hook (S269 item g)
|
|
3
|
+
*
|
|
4
|
+
* Problema: @playwright/mcp resolve `filename` contra o WORKSPACE ROOT
|
|
5
|
+
* (workspaceFile em packages/playwright-core/src/tools/backend/context.ts),
|
|
6
|
+
* não contra o outputDir. Filenames sem diretório caem na raiz do repo e o
|
|
7
|
+
* auto-checkpoint (`git add -A`) varre os PNGs para dentro de commits —
|
|
8
|
+
* recorrência observada em S266/S267/S268.
|
|
9
|
+
*
|
|
10
|
+
* Fix em camadas:
|
|
11
|
+
* 1. Este hook NEGA filenames que resolveriam para a raiz do projeto,
|
|
12
|
+
* instruindo o agente a usar `.playwright-mcp/` (gitignored) ou
|
|
13
|
+
* `.cobaia-evidence/<sessão>/` (evidência versionável).
|
|
14
|
+
* 2. `.gitignore` com `/*.png` etc. como cinto-e-suspensório.
|
|
15
|
+
*
|
|
16
|
+
* Política:
|
|
17
|
+
* - sem filename → ALLOW (default = <cwd>/.playwright-mcp/, gitignored)
|
|
18
|
+
* - filename em subdiretório → ALLOW
|
|
19
|
+
* - filename bare / ./x.png / absoluto-na-raiz / traversal → DENY
|
|
20
|
+
* - stdin não-parseável → ALLOW (fail-open: nunca bloquear por infra)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { dirname, isAbsolute, normalize, resolve } from 'node:path';
|
|
24
|
+
import {
|
|
25
|
+
log,
|
|
26
|
+
readStdin,
|
|
27
|
+
getField,
|
|
28
|
+
outputPreToolUse,
|
|
29
|
+
resolveHookProjectRoot,
|
|
30
|
+
} from './lib/api-client.js';
|
|
31
|
+
|
|
32
|
+
const HOOK = 'PLAYWRIGHT-OUTPUT-GUARD';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Raiz do projeto contra a qual `filename` é resolvido.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ NÃO derive de `__filename` (era o bug, S396/RFC-040 §18.12): sob o modelo
|
|
38
|
+
* de distribuição por plugin (§18) este arquivo roda a partir de
|
|
39
|
+
* `~/.claude/plugins/cache/<marketplace>/orchestrator/<versão>/hooks/`, então
|
|
40
|
+
* `dirname(__filename)/../..` resolveria para o CACHE DO PLUGIN em vez do repo
|
|
41
|
+
* do usuário. O guard não quebraria (fail-open), mas `dir === PROJECT_ROOT`
|
|
42
|
+
* nunca casaria — viraria um NO-OP SILENCIOSO, a pior falha possível para um
|
|
43
|
+
* gate. Cadeia de resolução, do mais autoritativo ao último recurso:
|
|
44
|
+
* 1. `CLAUDE_PROJECT_DIR` — injetado pelo harness, correto por construção;
|
|
45
|
+
* 2. walk-up pelo sentinel `.orchestrator` (primitivo RFC-040 C1);
|
|
46
|
+
* 3. `process.cwd()` — hooks rodam com cwd = diretório do projeto.
|
|
47
|
+
*/
|
|
48
|
+
export const PROJECT_ROOT =
|
|
49
|
+
process.env.CLAUDE_PROJECT_DIR ?? resolveHookProjectRoot(process.cwd()) ?? process.cwd();
|
|
50
|
+
|
|
51
|
+
export interface GuardDecision {
|
|
52
|
+
decision: 'allow' | 'deny';
|
|
53
|
+
reason?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const GUIDANCE =
|
|
57
|
+
'Playwright Output Guard: screenshots não podem cair na raiz do repo (auto-checkpoint os varre — recorrência S266-S268). ' +
|
|
58
|
+
'Use `filename: ".playwright-mcp/<nome>.png"` (descartável, gitignored), ' +
|
|
59
|
+
'`filename: ".cobaia-evidence/<sessão>/<nome>.png"` (evidência a versionar), ' +
|
|
60
|
+
'ou omita `filename` (default já vai para .playwright-mcp/).';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Avalia o filename pedido pelo agente.
|
|
64
|
+
* DENY quando o arquivo resolveria para a raiz do projeto (ou acima dela).
|
|
65
|
+
*/
|
|
66
|
+
export function evaluateFilename(filename: string | undefined): GuardDecision {
|
|
67
|
+
if (!filename) return { decision: 'allow' };
|
|
68
|
+
|
|
69
|
+
const resolved = isAbsolute(filename) ? normalize(filename) : resolve(PROJECT_ROOT, filename);
|
|
70
|
+
|
|
71
|
+
const dir = dirname(resolved);
|
|
72
|
+
|
|
73
|
+
// Cairia exatamente na raiz do projeto → DENY
|
|
74
|
+
if (dir === PROJECT_ROOT) {
|
|
75
|
+
return { decision: 'deny', reason: GUIDANCE };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Traversal para fora do projeto → DENY (o próprio MCP também negaria,
|
|
79
|
+
// mas aqui damos guidance acionável em vez de erro críptico)
|
|
80
|
+
if (!resolved.startsWith(PROJECT_ROOT + '/') && !isAbsolute(filename)) {
|
|
81
|
+
return { decision: 'deny', reason: GUIDANCE };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { decision: 'allow' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────
|
|
88
|
+
// Dependency injection (testável sem IO)
|
|
89
|
+
// ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export interface HookDeps {
|
|
92
|
+
readStdin: () => Promise<Record<string, unknown>>;
|
|
93
|
+
output: (opts: { decision: 'allow' | 'deny'; reason?: string; context?: string }) => void;
|
|
94
|
+
log: (prefix: string, msg: string) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const defaultDeps: HookDeps = {
|
|
98
|
+
readStdin,
|
|
99
|
+
output: outputPreToolUse,
|
|
100
|
+
log,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export async function mainWithDeps(deps: HookDeps): Promise<void> {
|
|
104
|
+
const stdin = await deps.readStdin();
|
|
105
|
+
const filename = getField(stdin, 'tool_input.filename');
|
|
106
|
+
|
|
107
|
+
const result = evaluateFilename(filename || undefined);
|
|
108
|
+
deps.log(HOOK, `filename=${filename || '(none)'} decision=${result.decision}`);
|
|
109
|
+
|
|
110
|
+
if (result.decision === 'deny') {
|
|
111
|
+
deps.output({ decision: 'deny', reason: result.reason });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
deps.output({
|
|
116
|
+
decision: 'allow',
|
|
117
|
+
...(filename
|
|
118
|
+
? {}
|
|
119
|
+
: {
|
|
120
|
+
context:
|
|
121
|
+
'[PLAYWRIGHT-OUTPUT-GUARD] sem filename: output em .playwright-mcp/ (gitignored).',
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function main(): Promise<void> {
|
|
127
|
+
await mainWithDeps(defaultDeps);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main().catch(() => process.exit(0));
|