@orchestrator-claude/definitions 3.37.0 → 3.37.2
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/adversarial-reviewer.md +6 -1
- package/agents/api-extractor.md +6 -0
- package/agents/azure-devops-engineer.md +1 -2
- package/agents/business-rule-miner.md +6 -0
- package/agents/cicd-platform-engineer.md +1 -2
- package/agents/ciclo-engineer.md +50 -21
- package/agents/code-archaeologist.md +6 -0
- package/agents/debug-sidecar.md +6 -0
- package/agents/doc-sidecar.md +6 -0
- package/agents/docs-guardian.md +9 -3
- package/agents/frontend-engineer.md +1 -2
- package/agents/{ai-architect.md → harness-architect.md} +8 -3
- package/agents/implementer.md +6 -1
- package/agents/legacy-discoverer.md +6 -0
- package/agents/legacy-synthesizer.md +6 -0
- package/agents/methodology-researcher.md +6 -1
- package/agents/operational-artifact-builder.md +6 -1
- package/agents/orchestrator.md +27 -87
- package/agents/planner.md +6 -1
- package/agents/presentation-engineer.md +29 -15
- package/agents/python-data-engineer.md +0 -1
- package/agents/researcher.md +6 -0
- package/agents/reviewer.md +6 -1
- package/agents/schema-extractor.md +6 -0
- package/agents/software-architect.md +10 -5
- package/agents/specifier.md +6 -1
- package/agents/task-generator.md +6 -1
- package/agents/test-sidecar.md +6 -0
- package/dist/scripts/generate-hooks-matcher.d.ts +106 -0
- package/dist/scripts/generate-hooks-matcher.d.ts.map +1 -0
- package/dist/scripts/generate-hooks-matcher.js +153 -0
- package/dist/scripts/generate-hooks-matcher.js.map +1 -0
- package/dist/scripts/generate-manifest.d.ts +22 -0
- package/dist/scripts/generate-manifest.d.ts.map +1 -1
- package/dist/scripts/generate-manifest.js +67 -2
- package/dist/scripts/generate-manifest.js.map +1 -1
- package/hooks/agent-git-guard.mjs +63 -11
- package/hooks/dangling-guard.mjs +42 -12
- package/hooks/gate-guardian.mjs +38 -8
- package/hooks/hooks.json +3 -9
- package/hooks/lib/api-client.ts +128 -9
- package/hooks/lib/capability-scanner.ts +462 -55
- package/hooks/lib/dispatch-marker.ts +214 -0
- package/hooks/lib/git-checkpoint.ts +34 -37
- package/hooks/lib/observations-helper.ts +14 -1
- package/hooks/lib/security-gate-core.ts +271 -0
- package/hooks/mailbox-listener.mjs +69 -17
- package/hooks/playwright-output-guard.mjs +40 -10
- package/hooks/post-implement.mjs +38 -8
- package/hooks/pre-agent-dispatch.mjs +137 -16
- package/hooks/pre-agent-dispatch.ts +82 -15
- package/hooks/security-gate-honest.mjs +302 -0
- package/hooks/security-gate-honest.ts +148 -0
- package/hooks/session-start.mjs +304 -106
- package/hooks/session-start.ts +317 -71
- package/hooks/subagent-start.mjs +7673 -22
- package/hooks/subagent-start.ts +367 -16
- package/hooks/subagent-stop.mjs +102 -62
- package/hooks/subagent-stop.ts +124 -28
- package/hooks/user-prompt.mjs +43 -13
- package/hooks/verification-before-completion.mjs +63 -11
- package/hooks/workflow-guard.mjs +45 -11
- package/hooks-settings-template.json +1 -7
- package/manifest.json +77 -61
- package/package.json +4 -2
- package/skills/checkpoint-protocol/SKILL.md +19 -4
- package/skills/ciclo-lane-dispatch/SKILL.md +117 -8
- package/skills/fase-1b-projecao/SKILL.md +150 -0
- package/skills/lane-1-entrevista/SKILL.md +173 -0
- package/skills/orchestrator/SKILL.md +11 -3
- package/skills/workflow-coordination/SKILL.md +3 -3
- package/hooks/post-compact.mjs +0 -7931
- package/hooks/post-compact.ts +0 -170
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pre-agent-dispatch.ts — PreToolUse hook for the Agent tool
|
|
3
3
|
*
|
|
4
|
-
* NON-BLOCKING (always exits 0; only injects advisory context).
|
|
4
|
+
* NON-BLOCKING (always exits 0; only injects advisory context). Four responsibilities
|
|
5
|
+
* (the count said "Two" while listing three, from S445 back to whenever the third was
|
|
6
|
+
* added — corrected here rather than left to drift a fourth time):
|
|
5
7
|
*
|
|
6
8
|
* 1. (RFC-027 Sprint 4) Warn when a known agent is dispatched WITHOUT subagent_type,
|
|
7
9
|
* so frontmatter (model/tools) loads correctly.
|
|
@@ -29,6 +31,15 @@
|
|
|
29
31
|
* Best-effort / fail-open in every dimension (missing deps, no active workflow,
|
|
30
32
|
* resolution timeout, filesystem error) — never blocks the dispatch.
|
|
31
33
|
*
|
|
34
|
+
* 4. (S445, RFC-042) Boundary marker per ref. RFC-042 settled that a checkpoint is an
|
|
35
|
+
* AUDIT MARK (D1) and is EXPLICIT (D2) — neither of which yields a coordinate for
|
|
36
|
+
* "the state just before this agent started". This hook fires at the exact instant
|
|
37
|
+
* of dispatch and already talks to git, so it records that coordinate: a ref under
|
|
38
|
+
* `refs/orchestrator/` at HEAD's full 40-hex sha, for COMMITTING agents only.
|
|
39
|
+
* It creates no commit, writes no file, and touches no database — a ref is git
|
|
40
|
+
* metadata, so `git status` is unchanged. Rollback is deliberately NOT implemented:
|
|
41
|
+
* it is plain `git reset --hard <ref>`. See lib/dispatch-marker.ts.
|
|
42
|
+
*
|
|
32
43
|
* Design note (TD-171): the committing-agent set mirrors the `createCheckpoint` tool
|
|
33
44
|
* holders in `.claude/agents/*.md`. Keep it in sync with that ground truth and with the
|
|
34
45
|
* orchestrator SKILL §2c.1 Commit-Policy scope.
|
|
@@ -42,7 +53,13 @@ import {
|
|
|
42
53
|
outputContext,
|
|
43
54
|
getActiveWorkflow,
|
|
44
55
|
writeDispatchIntent,
|
|
56
|
+
normalizeAgentName,
|
|
45
57
|
} from './lib/api-client.js';
|
|
58
|
+
import {
|
|
59
|
+
writeDispatchBoundaryMarker,
|
|
60
|
+
runGitDefault,
|
|
61
|
+
type DispatchMarkerOutcome,
|
|
62
|
+
} from './lib/dispatch-marker.js';
|
|
46
63
|
|
|
47
64
|
const HOOK = 'PRE-AGENT-DISPATCH';
|
|
48
65
|
|
|
@@ -80,20 +97,6 @@ const PROTECTED_BRANCHES: readonly string[] = ['main', 'master'];
|
|
|
80
97
|
// Pure helpers (exported for testing)
|
|
81
98
|
// ─────────────────────────────────────────────────────────────
|
|
82
99
|
|
|
83
|
-
/**
|
|
84
|
-
* Strip the plugin namespace from a dispatch name.
|
|
85
|
-
*
|
|
86
|
-
* Under plugin scope the harness namespaces every agent (`orchestrator:implementer`
|
|
87
|
-
* — measured in the S398 sandbox, RFC-040 §18.14); in project scope the same agent
|
|
88
|
-
* arrives bare (`implementer`). A guard that compares against the bare name only
|
|
89
|
-
* answers `false` for the namespaced form — and since hooks fail open, it disables
|
|
90
|
-
* itself in silence. Both forms MUST resolve to the same decision.
|
|
91
|
-
*/
|
|
92
|
-
export function normalizeAgentName(subagentType: string): string {
|
|
93
|
-
const i = subagentType.lastIndexOf(':');
|
|
94
|
-
return i === -1 ? subagentType : subagentType.slice(i + 1);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
100
|
export function isCommittingAgent(subagentType: string): boolean {
|
|
98
101
|
if (!subagentType) return false;
|
|
99
102
|
const t = normalizeAgentName(subagentType).toLowerCase();
|
|
@@ -176,6 +179,13 @@ export interface HookDeps {
|
|
|
176
179
|
getActiveWorkflow?: () => Promise<{ id: string } | null>;
|
|
177
180
|
/** TD-205: stamps the dispatch-intent marker for workflowId. See getActiveWorkflow doc. */
|
|
178
181
|
writeDispatchIntent?: (workflowId: string) => boolean;
|
|
182
|
+
/**
|
|
183
|
+
* S445 (RFC-042): records the git BOUNDARY MARKER for a committing agent —
|
|
184
|
+
* a ref at HEAD's full sha, so there is a coordinate to `git reset --hard`
|
|
185
|
+
* back to. Optional for the same reason as the TD-205 deps above: pre-S445
|
|
186
|
+
* test doubles must keep working, and an absent dep simply skips the marker.
|
|
187
|
+
*/
|
|
188
|
+
writeDispatchMarker?: (agentName: string, branch: string) => DispatchMarkerOutcome;
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
function readBranchFromGit(): string {
|
|
@@ -197,6 +207,8 @@ const defaultDeps: HookDeps = {
|
|
|
197
207
|
readBranch: readBranchFromGit,
|
|
198
208
|
getActiveWorkflow,
|
|
199
209
|
writeDispatchIntent,
|
|
210
|
+
writeDispatchMarker: (agentName, branch) =>
|
|
211
|
+
writeDispatchBoundaryMarker(agentName, branch, { runGit: runGitDefault }),
|
|
200
212
|
};
|
|
201
213
|
|
|
202
214
|
/**
|
|
@@ -240,6 +252,57 @@ export async function stampDispatchIntent(
|
|
|
240
252
|
}
|
|
241
253
|
}
|
|
242
254
|
|
|
255
|
+
/**
|
|
256
|
+
* S445 (RFC-042): writes the git boundary marker for a COMMITTING agent, and
|
|
257
|
+
* returns an advisory line if it failed (empty array otherwise).
|
|
258
|
+
*
|
|
259
|
+
* ONLY committing agents get a marker. An agent that cannot commit cannot move
|
|
260
|
+
* HEAD, so a marker for it records a boundary nothing can cross — pure noise
|
|
261
|
+
* consuming the retention cap. This condition is the load-bearing one and is
|
|
262
|
+
* covered by an explicit negative control in the test suite.
|
|
263
|
+
*
|
|
264
|
+
* FAIL-OPEN BUT NOT SILENT. A marker failure must never block or delay the
|
|
265
|
+
* dispatch, so everything here is caught. But "fail-open" is not "invisible":
|
|
266
|
+
* the failure mode this feature must avoid is a GREEN VACUUM — the ref silently
|
|
267
|
+
* never being written (e.g. an invalid ref name) while everything looks fine. So
|
|
268
|
+
* a failure is reported on BOTH existing channels: the debug log AND the in-band
|
|
269
|
+
* advisory context the TD-171 branch warning already uses, which is the one a
|
|
270
|
+
* human or orchestrator actually reads.
|
|
271
|
+
*/
|
|
272
|
+
export function markDispatchBoundary(
|
|
273
|
+
subagentType: string,
|
|
274
|
+
branch: string,
|
|
275
|
+
deps: Pick<HookDeps, 'writeDispatchMarker' | 'log'>
|
|
276
|
+
): string[] {
|
|
277
|
+
if (!deps.writeDispatchMarker) return [];
|
|
278
|
+
if (!isCommittingAgent(subagentType)) return [];
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const outcome = deps.writeDispatchMarker(subagentType, branch);
|
|
282
|
+
|
|
283
|
+
if (outcome.status === 'written') {
|
|
284
|
+
deps.log(HOOK, `[S445] boundary marker ${outcome.ref} -> ${outcome.sha}`);
|
|
285
|
+
if (outcome.pruned.length > 0) {
|
|
286
|
+
deps.log(HOOK, `[S445] pruned ${outcome.pruned.length} marker(s) over retention cap`);
|
|
287
|
+
}
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
deps.log(HOOK, `[S445] boundary marker FAILED: ${outcome.reason}`);
|
|
292
|
+
return [
|
|
293
|
+
`[BOUNDARY-MARKER][S445] Could not record the pre-dispatch git coordinate for ` +
|
|
294
|
+
`'${subagentType}' on '${branch}': ${outcome.reason}. The dispatch proceeds (this never ` +
|
|
295
|
+
`blocks), but there will be NO ref to 'git reset --hard' back to for this agent's work.`,
|
|
296
|
+
];
|
|
297
|
+
} catch (err) {
|
|
298
|
+
deps.log(HOOK, `[S445] boundary marker threw (fail-open): ${String(err)}`);
|
|
299
|
+
return [
|
|
300
|
+
`[BOUNDARY-MARKER][S445] Boundary-marker step threw for '${subagentType}': ${String(err)}. ` +
|
|
301
|
+
`Dispatch proceeds; no rollback coordinate was recorded.`,
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
243
306
|
// ─────────────────────────────────────────────────────────────
|
|
244
307
|
// Core logic (exported for testing)
|
|
245
308
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -259,6 +322,10 @@ export async function mainWithDeps(deps: HookDeps): Promise<void> {
|
|
|
259
322
|
|
|
260
323
|
const warnings = evaluateDispatch({ subagentType, prompt, branch });
|
|
261
324
|
|
|
325
|
+
// S445: record the pre-dispatch git coordinate BEFORE the sub-agent spawns.
|
|
326
|
+
// Committing agents only; never blocks; a failure is surfaced, not swallowed.
|
|
327
|
+
warnings.push(...markDispatchBoundary(subagentType, branch, deps));
|
|
328
|
+
|
|
262
329
|
// TD-205: stamp the dispatch-intent marker regardless of whether a warning
|
|
263
330
|
// fires — a dispatch is happening either way, and the marker exists purely
|
|
264
331
|
// to cover the bg-dispatch race for dangling-guard's Stop hook.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createRequire as __createRequireShim } from 'node:module'; const require = __createRequireShim(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// hooks/lib/api-client.ts
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join as join2 } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
// ../project-root-resolver/dist/index.js
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
function hasProjectRootMarker(candidate, sentinel) {
|
|
13
|
+
return existsSync(join(candidate, ".git")) || existsSync(join(candidate, "package.json")) || existsSync(join(candidate, sentinel, ".root"));
|
|
14
|
+
}
|
|
15
|
+
function resolveProjectRoot(cwd, sentinel = ".orchestrator") {
|
|
16
|
+
const candidates = [];
|
|
17
|
+
let currentDir = cwd;
|
|
18
|
+
for (; ; ) {
|
|
19
|
+
if (existsSync(join(currentDir, sentinel))) {
|
|
20
|
+
candidates.push(currentDir);
|
|
21
|
+
}
|
|
22
|
+
const parentDir = dirname(currentDir);
|
|
23
|
+
if (parentDir === currentDir) {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
currentDir = parentDir;
|
|
27
|
+
}
|
|
28
|
+
const [innermost] = candidates;
|
|
29
|
+
if (innermost === void 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
if (hasProjectRootMarker(candidate, sentinel)) {
|
|
34
|
+
return candidate;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return innermost;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// hooks/lib/api-client.ts
|
|
41
|
+
function resolveFallbackStateDir(cwd) {
|
|
42
|
+
const cwdHash = createHash("sha256").update(cwd).digest("hex").slice(0, 16);
|
|
43
|
+
return join2(tmpdir(), "orchestrator-hook-fallback", cwdHash);
|
|
44
|
+
}
|
|
45
|
+
var PROJECT_ROOT = (() => {
|
|
46
|
+
const resolved = resolveHookProjectRoot(process.cwd());
|
|
47
|
+
if (resolved !== null && hasProjectRootMarker(resolved, ".orchestrator")) {
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
})();
|
|
52
|
+
var STATE_DIR_OVERRIDE = process.env.ORCHESTRATOR_STATE_DIR;
|
|
53
|
+
var STATE_DIR = STATE_DIR_OVERRIDE !== void 0 && STATE_DIR_OVERRIDE !== "" ? STATE_DIR_OVERRIDE : PROJECT_ROOT !== null ? join2(PROJECT_ROOT, ".orchestrator", ".state") : resolveFallbackStateDir(process.cwd());
|
|
54
|
+
var JWT_CACHE = join2(STATE_DIR, "jwt-token");
|
|
55
|
+
var DEBUG_LOG = join2(STATE_DIR, "hook-debug.log");
|
|
56
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
57
|
+
if ((STATE_DIR_OVERRIDE === void 0 || STATE_DIR_OVERRIDE === "") && PROJECT_ROOT === null) {
|
|
58
|
+
log(
|
|
59
|
+
"API-CLIENT",
|
|
60
|
+
`PROJECT_ROOT: no marker-verified ".orchestrator" project root found above ${process.cwd()} \u2014 falling back to isolated state dir ${STATE_DIR} (RF-003)`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
var API_URL = process.env.ORCHESTRATOR_API_URL || "http://localhost:3001";
|
|
64
|
+
var AUTH_EMAIL = process.env.ORCHESTRATOR_ADMIN_EMAIL || process.env.ORCHESTRATOR_AUTH_EMAIL || "system@orchestrator.local";
|
|
65
|
+
var AUTH_PASSWORD = process.env.ORCHESTRATOR_ADMIN_PASSWORD || process.env.ORCHESTRATOR_AUTH_PASSWORD || "system123";
|
|
66
|
+
var PROJECT_ID = process.env.ORCHESTRATOR_PROJECT_ID || "";
|
|
67
|
+
function resolveHookProjectRoot(cwd) {
|
|
68
|
+
return resolveProjectRoot(cwd);
|
|
69
|
+
}
|
|
70
|
+
function log(prefix, msg) {
|
|
71
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d+Z$/, "Z");
|
|
72
|
+
try {
|
|
73
|
+
const line = `[${ts}] ${prefix}: ${msg}
|
|
74
|
+
`;
|
|
75
|
+
writeFileSync(DEBUG_LOG, line, { flag: "a" });
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
var LAST_COMPLETED_FILE = join2(STATE_DIR, "last-completed-workflow.json");
|
|
80
|
+
var LAST_COMPLETED_TTL_MS = 60 * 60 * 1e3;
|
|
81
|
+
var INVOCATIONS_DIR = join2(STATE_DIR, "invocations");
|
|
82
|
+
var INVOCATION_STATE_TTL_MS = 4 * 60 * 60 * 1e3;
|
|
83
|
+
var DISPATCH_INTENT_DIR = join2(STATE_DIR, "dispatch-intent");
|
|
84
|
+
async function readStdin() {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
for await (const chunk of process.stdin) {
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
}
|
|
89
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
90
|
+
if (!raw) return {};
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function getField(obj, path) {
|
|
98
|
+
const parts = path.split(".");
|
|
99
|
+
let current = obj;
|
|
100
|
+
for (const part of parts) {
|
|
101
|
+
if (current == null || typeof current !== "object") return "";
|
|
102
|
+
current = current[part];
|
|
103
|
+
}
|
|
104
|
+
return current != null ? String(current) : "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// hooks/security-gate-honest.ts
|
|
108
|
+
import { execFileSync } from "node:child_process";
|
|
109
|
+
|
|
110
|
+
// hooks/lib/security-gate-core.ts
|
|
111
|
+
var L2_LATENCY_FLOOR_MS = 400;
|
|
112
|
+
var L1_COVERAGE = "literais de credencial no diff (chaves de provedor, tokens, blocos PEM, URLs com senha)";
|
|
113
|
+
var SECRET_RULES = [
|
|
114
|
+
{ rule: "aws-access-key-id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
115
|
+
{ rule: "anthropic-api-key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
116
|
+
{ rule: "openai-api-key", re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
|
|
117
|
+
{ rule: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/ },
|
|
118
|
+
{ rule: "slack-token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/ },
|
|
119
|
+
{ rule: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
120
|
+
{ rule: "npm-token", re: /\bnpm_[A-Za-z0-9]{36}\b/ },
|
|
121
|
+
{ rule: "private-key-block", re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
|
|
122
|
+
{ rule: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
|
|
123
|
+
{ rule: "url-with-password", re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s:@/]{6,}@/i }
|
|
124
|
+
];
|
|
125
|
+
var PLACEHOLDER = /(?:x{6,}|\.{3,}|<[^>]{2,}>|\$\{|EXAMPLE|PLACEHOLDER|YOUR_|CHANGEME|REDACTED|FAKE|DUMMY|\bTODO\b)/i;
|
|
126
|
+
function maskEvidence(s) {
|
|
127
|
+
const t = s.trim().slice(0, 80);
|
|
128
|
+
return t.length <= 12 ? "***" : `${t.slice(0, 6)}\u2026${t.slice(-4)} (${String(t.length)} ch)`;
|
|
129
|
+
}
|
|
130
|
+
function scanDiff(diff) {
|
|
131
|
+
const out = [];
|
|
132
|
+
let file = "(desconhecido)";
|
|
133
|
+
let lineNo = 0;
|
|
134
|
+
for (const raw of diff.split("\n")) {
|
|
135
|
+
if (raw.startsWith("+++ b/")) {
|
|
136
|
+
file = raw.slice(6);
|
|
137
|
+
lineNo = 0;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (raw.startsWith("@@")) {
|
|
141
|
+
const m = /\+(\d+)/.exec(raw);
|
|
142
|
+
lineNo = m ? parseInt(m[1], 10) - 1 : 0;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!raw.startsWith("+") || raw.startsWith("+++")) continue;
|
|
146
|
+
lineNo += 1;
|
|
147
|
+
const content = raw.slice(1);
|
|
148
|
+
if (PLACEHOLDER.test(content)) continue;
|
|
149
|
+
for (const { rule, re } of SECRET_RULES) {
|
|
150
|
+
const hit = re.exec(content);
|
|
151
|
+
if (hit) out.push({ rule, file, line: lineNo, evidence: maskEvidence(hit[0]) });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function isCommitCommand(command) {
|
|
157
|
+
return /git\s+(?:-\S+(?:\s+\S+)?\s+)*commit\b/.test(command) && !/--dry-run\b/.test(command);
|
|
158
|
+
}
|
|
159
|
+
function evaluate(deps) {
|
|
160
|
+
const verdicts = [];
|
|
161
|
+
let src;
|
|
162
|
+
try {
|
|
163
|
+
src = deps.getDiff();
|
|
164
|
+
} catch (e) {
|
|
165
|
+
const reason = `n\xE3o consegui ler o diff do commit (${e.message.slice(0, 80)})`;
|
|
166
|
+
return [
|
|
167
|
+
{ state: "not-analyzed", layer: "L1", reason },
|
|
168
|
+
{ state: "not-analyzed", layer: "L2", reason }
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
const diff = src.text;
|
|
172
|
+
if (!diff.trim()) {
|
|
173
|
+
const reason = `o diff veio vazio via ${src.how} \u2014 nada para analisar. Isto N\xC3O \xE9 o mesmo que limpo`;
|
|
174
|
+
return [
|
|
175
|
+
{ state: "not-analyzed", layer: "L1", reason },
|
|
176
|
+
{ state: "not-analyzed", layer: "L2", reason }
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
const hits = scanDiff(diff);
|
|
180
|
+
const cover = `${L1_COVERAGE} \xB7 via ${src.how}`;
|
|
181
|
+
verdicts.push(
|
|
182
|
+
hits.length ? { state: "findings", layer: "L1", detail: cover, hits } : { state: "clean", layer: "L1", detail: cover }
|
|
183
|
+
);
|
|
184
|
+
const sem = deps.runSemantic(diff);
|
|
185
|
+
if (sem === null) {
|
|
186
|
+
verdicts.push({ state: "not-analyzed", layer: "L2", reason: deps.l2UnavailableReason() });
|
|
187
|
+
} else if (sem.elapsedMs < L2_LATENCY_FLOOR_MS) {
|
|
188
|
+
verdicts.push({
|
|
189
|
+
state: "not-analyzed",
|
|
190
|
+
layer: "L2",
|
|
191
|
+
reason: `devolveu veredito em ${String(sem.elapsedMs)} ms, abaixo do piso de ${String(L2_LATENCY_FLOOR_MS)} ms \u2014 tratado como fallback, n\xE3o como an\xE1lise`
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
verdicts.push(
|
|
195
|
+
sem.hits.length ? { state: "findings", layer: "L2", detail: "an\xE1lise sem\xE2ntica", hits: sem.hits } : { state: "clean", layer: "L2", detail: "an\xE1lise sem\xE2ntica" }
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return verdicts;
|
|
199
|
+
}
|
|
200
|
+
function renderShort(verdicts) {
|
|
201
|
+
const findings = verdicts.filter((v) => v.state === "findings");
|
|
202
|
+
if (findings.length) {
|
|
203
|
+
const n = findings.reduce((a, v) => a + (v.state === "findings" ? v.hits.length : 0), 0);
|
|
204
|
+
const layers = findings.map((v) => v.layer).join("+");
|
|
205
|
+
return `SECURITY-GATE: ${String(n)} achado(s) em ${layers} \u2014 confira antes de publicar`;
|
|
206
|
+
}
|
|
207
|
+
const analyzed = verdicts.filter((v) => v.state !== "not-analyzed").map((v) => v.layer);
|
|
208
|
+
const skipped = verdicts.filter((v) => v.state === "not-analyzed").map((v) => v.layer);
|
|
209
|
+
if (!analyzed.length) {
|
|
210
|
+
return 'SECURITY-GATE: NENHUMA camada analisou o commit \u2014 isto N\xC3O \xE9 "limpo", \xE9 aus\xEAncia de an\xE1lise';
|
|
211
|
+
}
|
|
212
|
+
if (skipped.length) {
|
|
213
|
+
return `SECURITY-GATE: cobertura PARCIAL \u2014 ${analyzed.join("+")} analisou, ${skipped.join("+")} n\xE3o`;
|
|
214
|
+
}
|
|
215
|
+
return `SECURITY-GATE: ${analyzed.join("+")} analisou, sem achados`;
|
|
216
|
+
}
|
|
217
|
+
function render(verdicts) {
|
|
218
|
+
const lines = ["[SECURITY-GATE] Revis\xE3o do commit \u2014 veredito por camada:"];
|
|
219
|
+
for (const v of verdicts) {
|
|
220
|
+
if (v.state === "clean") {
|
|
221
|
+
lines.push(` ${v.layer} ANALISADO \u2014 LIMPO \xB7 cobertura: ${v.detail}`);
|
|
222
|
+
} else if (v.state === "findings") {
|
|
223
|
+
lines.push(
|
|
224
|
+
` ${v.layer} ANALISADO \u2014 ${String(v.hits.length)} ACHADO(S) \xB7 cobertura: ${v.detail}`
|
|
225
|
+
);
|
|
226
|
+
for (const h of v.hits.slice(0, 10)) {
|
|
227
|
+
lines.push(` ${h.rule} \xB7 ${h.file}:${String(h.line)} \xB7 ${h.evidence}`);
|
|
228
|
+
}
|
|
229
|
+
if (v.hits.length > 10) lines.push(` \u2026 e mais ${String(v.hits.length - 10)}`);
|
|
230
|
+
} else {
|
|
231
|
+
lines.push(` ${v.layer} N\xC3O ANALISEI \u2014 ${v.reason}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const analyzed = verdicts.filter((v) => v.state !== "not-analyzed").map((v) => v.layer);
|
|
235
|
+
const skipped = verdicts.filter((v) => v.state === "not-analyzed").map((v) => v.layer);
|
|
236
|
+
if (!analyzed.length) {
|
|
237
|
+
lines.push(
|
|
238
|
+
' \u26A0 NENHUMA camada analisou este commit. Isto N\xC3O \xE9 "limpo" \u2014 \xE9 aus\xEAncia de informa\xE7\xE3o.'
|
|
239
|
+
);
|
|
240
|
+
} else if (skipped.length) {
|
|
241
|
+
lines.push(
|
|
242
|
+
` \u26A0 Cobertura PARCIAL: ${analyzed.join("+")} rodou, ${skipped.join("+")} n\xE3o. O que n\xE3o foi analisado n\xE3o est\xE1 limpo \u2014 est\xE1 por analisar.`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (verdicts.some((v) => v.state === "findings")) {
|
|
246
|
+
lines.push(" \u2192 Confira antes de publicar. O commit local j\xE1 existe; nada foi bloqueado.");
|
|
247
|
+
}
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// hooks/security-gate-honest.ts
|
|
252
|
+
var HOOK = "SECURITY-GATE";
|
|
253
|
+
var defaultDeps = {
|
|
254
|
+
getDiff: () => {
|
|
255
|
+
const git = (args2) => execFileSync("git", args2, { encoding: "utf8", maxBuffer: 8 * 1024 * 1024, timeout: 1e4 });
|
|
256
|
+
const parents = git(["rev-list", "--parents", "-n", "1", "HEAD"]).trim().split(/\s+/).length - 1;
|
|
257
|
+
const args = parents > 1 ? ["show", "--format=", "--unified=0", "-m", "--first-parent", "HEAD"] : ["show", "--format=", "--unified=0", "HEAD"];
|
|
258
|
+
const how = parents > 1 ? `git show -m --first-parent HEAD (merge de ${parents} pais)` : "git show HEAD";
|
|
259
|
+
return { text: git(args), how };
|
|
260
|
+
},
|
|
261
|
+
// Ficha 3 item (d): o gate é honesto ANTES de voltar a funcionar. Enquanto a
|
|
262
|
+
// credencial/SDK não forem decididos pelo PM, L2 declara ausência — nunca
|
|
263
|
+
// finge limpeza.
|
|
264
|
+
runSemantic: () => null,
|
|
265
|
+
l2UnavailableReason: () => "camada sem\xE2ntica sem credencial decidida (ficha 3 da S505, item d) \u2014 nenhuma chamada foi tentada, nada foi analisado nesta camada"
|
|
266
|
+
};
|
|
267
|
+
async function mainWithDeps(deps, stdinFn = readStdin) {
|
|
268
|
+
const stdin = await stdinFn();
|
|
269
|
+
const command = getField(stdin, "tool_input.command") || getField(stdin, "command") || "";
|
|
270
|
+
if (!isCommitCommand(command)) return;
|
|
271
|
+
const verdicts = evaluate(deps);
|
|
272
|
+
const text = render(verdicts);
|
|
273
|
+
log(HOOK, text.replace(/\n/g, " | "));
|
|
274
|
+
console.log(
|
|
275
|
+
JSON.stringify({
|
|
276
|
+
systemMessage: renderShort(verdicts),
|
|
277
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text }
|
|
278
|
+
})
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
async function main() {
|
|
282
|
+
await mainWithDeps(defaultDeps);
|
|
283
|
+
}
|
|
284
|
+
main().catch((e) => {
|
|
285
|
+
try {
|
|
286
|
+
log(HOOK, `N\xC3O ANALISEI \u2014 o pr\xF3prio gate falhou: ${e.message.slice(0, 120)}`);
|
|
287
|
+
} catch {
|
|
288
|
+
}
|
|
289
|
+
process.exit(0);
|
|
290
|
+
});
|
|
291
|
+
export {
|
|
292
|
+
L1_COVERAGE,
|
|
293
|
+
L2_LATENCY_FLOOR_MS,
|
|
294
|
+
SECRET_RULES,
|
|
295
|
+
evaluate,
|
|
296
|
+
isCommitCommand,
|
|
297
|
+
mainWithDeps,
|
|
298
|
+
maskEvidence,
|
|
299
|
+
render,
|
|
300
|
+
renderShort,
|
|
301
|
+
scanDiff
|
|
302
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security-gate-honest.ts — PostToolUse[Bash] hook
|
|
3
|
+
*
|
|
4
|
+
* Substituto honesto do `security-guidance@claude-plugins-official` para a
|
|
5
|
+
* revisão de commits (ficha 3 da S505; decisão do PM: trocar por algo que
|
|
6
|
+
* FALHE ALTO).
|
|
7
|
+
*
|
|
8
|
+
* ─── O DEFEITO QUE ESTE HOOK EXISTE PARA NÃO COMETER ──────────────────────
|
|
9
|
+
* Medido na S505, `security_reminder_hook.py:1418`: quando a chamada de API
|
|
10
|
+
* falha, o Commit review emite `no security issues found` e sai com 0 — três
|
|
11
|
+
* MILISSEGUNDOS depois do erro. A informação de que nada foi analisado está
|
|
12
|
+
* disponível ali (`llm._last_call_claude_http_error`) e é enviada para a
|
|
13
|
+
* telemetria como `api_error`, mas NÃO muda o veredito mostrado.
|
|
14
|
+
*
|
|
15
|
+
* O mesmo arquivo faz a coisa certa 667 linhas abaixo: o Stop hook (:2085)
|
|
16
|
+
* testa `_last_call_claude_http_error` ANTES do veredito e, quando há erro,
|
|
17
|
+
* chama `restore_unreviewed_stop_state` em vez de declarar limpo. Duas
|
|
18
|
+
* rotinas, a mesma variável, duas condutas — e só uma é honesta.
|
|
19
|
+
*
|
|
20
|
+
* ─── A REGRA, E É A ÚNICA QUE IMPORTA ─────────────────────────────────────
|
|
21
|
+
* AUSÊNCIA DE ANÁLISE NUNCA VIRA VEREDITO POSITIVO. Um gate que degrada para
|
|
22
|
+
* "limpo" é pior que gate nenhum: gate nenhum é uma ausência que se percebe;
|
|
23
|
+
* "no security issues found" é uma afirmação que se cita depois.
|
|
24
|
+
*
|
|
25
|
+
* Três estados, sempre explícitos, nunca implícitos:
|
|
26
|
+
* ANALISADO — LIMPO ......... a camada rodou e não achou nada
|
|
27
|
+
* ANALISADO — ACHADOS ....... a camada rodou e achou
|
|
28
|
+
* NÃO ANALISEI — <motivo> ... a camada não rodou, e o motivo vai junto
|
|
29
|
+
*
|
|
30
|
+
* E a cobertura é declarada em toda saída: dizer "limpo" sem dizer "limpo
|
|
31
|
+
* DE QUÊ" é a mesma família de defeito, um degrau mais sutil.
|
|
32
|
+
*
|
|
33
|
+
* ─── CAMADAS ──────────────────────────────────────────────────────────────
|
|
34
|
+
* L1 · varredura local de segredos (regex sobre o diff) — SEM API, sempre
|
|
35
|
+
* disponível, cobertura estreita e declarada.
|
|
36
|
+
* L2 · análise semântica por LLM — hoje NÃO DISPONÍVEL (sem credencial,
|
|
37
|
+
* ver §credencial na ficha). O gancho e o piso de latência ficam
|
|
38
|
+
* escritos para quando a credencial for decidida; enquanto isso L2
|
|
39
|
+
* reporta NÃO ANALISEI, que é o comportamento correto.
|
|
40
|
+
*
|
|
41
|
+
* ─── PISO DE LATÊNCIA (segunda guarda, achado da S505) ────────────────────
|
|
42
|
+
* Análise semântica de um diff não fecha em milissegundos. Quando L2 existir
|
|
43
|
+
* e devolver veredito em menos de L2_LATENCY_FLOOR_MS, o resultado é tratado
|
|
44
|
+
* como fallback, não como análise — vira NÃO ANALISEI. É guarda secundária:
|
|
45
|
+
* a primária é testar o erro explicitamente, como o Stop hook do plugin faz.
|
|
46
|
+
*
|
|
47
|
+
* NUNCA BLOQUEIA. Emite contexto. O objetivo é informar com honestidade, não
|
|
48
|
+
* impedir o commit — bloquear seria decisão de outra ficha.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { log, readStdin, getField } from './lib/api-client.js';
|
|
52
|
+
import { execFileSync } from 'node:child_process';
|
|
53
|
+
import {
|
|
54
|
+
evaluate,
|
|
55
|
+
render,
|
|
56
|
+
renderShort,
|
|
57
|
+
isCommitCommand,
|
|
58
|
+
type GateDeps,
|
|
59
|
+
} from './lib/security-gate-core.js';
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
L1_COVERAGE,
|
|
63
|
+
L2_LATENCY_FLOOR_MS,
|
|
64
|
+
SECRET_RULES,
|
|
65
|
+
maskEvidence,
|
|
66
|
+
scanDiff,
|
|
67
|
+
isCommitCommand,
|
|
68
|
+
evaluate,
|
|
69
|
+
render,
|
|
70
|
+
renderShort,
|
|
71
|
+
type Finding,
|
|
72
|
+
type Verdict,
|
|
73
|
+
type GateDeps,
|
|
74
|
+
} from './lib/security-gate-core.js';
|
|
75
|
+
|
|
76
|
+
const HOOK = 'SECURITY-GATE';
|
|
77
|
+
|
|
78
|
+
// ─────────────────────────────────────────────────────────────
|
|
79
|
+
// Produção
|
|
80
|
+
// ─────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const defaultDeps: GateDeps = {
|
|
83
|
+
getDiff: () => {
|
|
84
|
+
const git = (args: string[]) =>
|
|
85
|
+
execFileSync('git', args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 10_000 });
|
|
86
|
+
// Um merge commit devolve diff VAZIO em `git show` sem `-m` (medido S505: o
|
|
87
|
+
// HEAD de merge dava 0 linhas e um commit normal, 159). Sem esta distinção o
|
|
88
|
+
// gate diria "não analisei" para todo merge, misturando-o com falha real.
|
|
89
|
+
const parents =
|
|
90
|
+
git(['rev-list', '--parents', '-n', '1', 'HEAD']).trim().split(/\s+/).length - 1;
|
|
91
|
+
const args =
|
|
92
|
+
parents > 1
|
|
93
|
+
? ['show', '--format=', '--unified=0', '-m', '--first-parent', 'HEAD']
|
|
94
|
+
: ['show', '--format=', '--unified=0', 'HEAD'];
|
|
95
|
+
const how =
|
|
96
|
+
parents > 1 ? `git show -m --first-parent HEAD (merge de ${parents} pais)` : 'git show HEAD';
|
|
97
|
+
return { text: git(args), how };
|
|
98
|
+
},
|
|
99
|
+
// Ficha 3 item (d): o gate é honesto ANTES de voltar a funcionar. Enquanto a
|
|
100
|
+
// credencial/SDK não forem decididos pelo PM, L2 declara ausência — nunca
|
|
101
|
+
// finge limpeza.
|
|
102
|
+
runSemantic: () => null,
|
|
103
|
+
l2UnavailableReason: () =>
|
|
104
|
+
'camada semântica sem credencial decidida (ficha 3 da S505, item d) — ' +
|
|
105
|
+
'nenhuma chamada foi tentada, nada foi analisado nesta camada',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export async function mainWithDeps(deps: GateDeps, stdinFn = readStdin): Promise<void> {
|
|
109
|
+
const stdin = await stdinFn();
|
|
110
|
+
const command =
|
|
111
|
+
(getField(stdin, 'tool_input.command') as string | undefined) ||
|
|
112
|
+
(getField(stdin, 'command') as string | undefined) ||
|
|
113
|
+
'';
|
|
114
|
+
|
|
115
|
+
if (!isCommitCommand(command)) return;
|
|
116
|
+
|
|
117
|
+
const verdicts = evaluate(deps);
|
|
118
|
+
const text = render(verdicts);
|
|
119
|
+
log(HOOK, text.replace(/\n/g, ' | '));
|
|
120
|
+
|
|
121
|
+
// DOIS canais, e a distinção é a ficha inteira (achado do Manager, S505):
|
|
122
|
+
// additionalContext → só o MODELO lê;
|
|
123
|
+
// systemMessage → a PESSOA vê, sem bloquear.
|
|
124
|
+
// Um gate que "falha alto" apenas para o modelo não falha alto para ninguém —
|
|
125
|
+
// é o mesmo efeito prático do plugin que este substitui. `outputContext` da
|
|
126
|
+
// casa não emite systemMessage, então o JSON sai montado aqui.
|
|
127
|
+
console.log(
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
systemMessage: renderShort(verdicts),
|
|
130
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text },
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function main(): Promise<void> {
|
|
136
|
+
await mainWithDeps(defaultDeps);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Falha do próprio gate também não pode virar silêncio-que-parece-limpo:
|
|
140
|
+
// o motivo vai para o log, e a saída 0 apenas não bloqueia o fluxo.
|
|
141
|
+
main().catch((e: unknown) => {
|
|
142
|
+
try {
|
|
143
|
+
log(HOOK, `NÃO ANALISEI — o próprio gate falhou: ${(e as Error).message.slice(0, 120)}`);
|
|
144
|
+
} catch {
|
|
145
|
+
/* nada a fazer; nunca derrubar a sessão por causa do gate */
|
|
146
|
+
}
|
|
147
|
+
process.exit(0);
|
|
148
|
+
});
|