@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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch-marker.ts — boundary marker per git ref (S445, RFC-042).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS. At the instant a committing agent is dispatched, record WHERE
|
|
5
|
+
* HEAD WAS, as a git ref pointing at the full 40-hex sha. That is the whole
|
|
6
|
+
* feature: a COORDINATE for "the state before this agent started".
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS IS NOT, deliberately. It does not restore anything. RFC-042 settled
|
|
9
|
+
* that a checkpoint is an AUDIT MARK (D1) and is EXPLICIT (D2); asked whether a
|
|
10
|
+
* restore path was wanted, the answer was that rollback has been needed only a
|
|
11
|
+
* few times and plain git handled it. So rollback stays plain git:
|
|
12
|
+
*
|
|
13
|
+
* git reset --hard <ref>
|
|
14
|
+
*
|
|
15
|
+
* No restore code lives here. An unused restore path is precisely how the
|
|
16
|
+
* existing checkpoint subsystem accumulated four independent breaks (see the
|
|
17
|
+
* checkpoint-protocol skill) — a coordinate that git can consume cannot rot the
|
|
18
|
+
* same way, because git is the thing that consumes it.
|
|
19
|
+
*
|
|
20
|
+
* IT DOES NOT CREATE A COMMIT AND DOES NOT TOUCH THE WORKING TREE. A ref is git
|
|
21
|
+
* metadata: writing one leaves `git status` byte-identical. That is what lets
|
|
22
|
+
* this coexist with the S445 rule that ordinary machine operation must never
|
|
23
|
+
* write a git-tracked file.
|
|
24
|
+
*
|
|
25
|
+
* THE COLON TRAP (the reason sanitization is a requirement, not a nicety).
|
|
26
|
+
* Under plugin scope agents are named `orchestrator:implementer`. A git ref name
|
|
27
|
+
* may not contain a colon (nor space, tilde, caret, question mark, asterisk,
|
|
28
|
+
* open bracket, backslash, or a double dot). Passing a raw agent name through
|
|
29
|
+
* would make `update-ref` fail — and since the calling hook is fail-open, it
|
|
30
|
+
* would fail SILENTLY, leaving the whole feature a green no-op for exactly the
|
|
31
|
+
* agents that can commit. Hence sanitizeRefComponent, and hence a test that
|
|
32
|
+
* validates the output with git's own `check-ref-format`.
|
|
33
|
+
*
|
|
34
|
+
* RETENTION. Refs are GC roots, so markers would accumulate forever and we would
|
|
35
|
+
* have traded collectable orphan objects for uncollectable refs. Hence a cap.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { execFileSync } from 'node:child_process';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Namespace for dispatch boundary markers. Verified free before adoption:
|
|
42
|
+
* `git for-each-ref refs/orchestrator/` returned 0 refs. The 6 pre-existing
|
|
43
|
+
* `orchestrator/*` TAGS live under `refs/tags/orchestrator/`, which is a
|
|
44
|
+
* DIFFERENT namespace — these markers do not collide with them, and are not
|
|
45
|
+
* tags (a tag is a published, human-meaningful mark; this is machine bookkeeping
|
|
46
|
+
* with a retention cap).
|
|
47
|
+
*/
|
|
48
|
+
export const DISPATCH_MARKER_REF_PREFIX = 'refs/orchestrator/dispatch';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Maximum markers retained PER KEY (per branch — see writeDispatchBoundaryMarker
|
|
52
|
+
* for why branch and not workflow). Oldest are pruned once the cap is exceeded.
|
|
53
|
+
*
|
|
54
|
+
* WHY A CAP EXISTS AT ALL: a git ref is a GC ROOT. Every marker pins the commit
|
|
55
|
+
* it points at, so without pruning these would accumulate without bound and
|
|
56
|
+
* would keep otherwise-unreachable objects alive forever — trading collectable
|
|
57
|
+
* orphans for uncollectable refs, which is a worse deal than the problem being
|
|
58
|
+
* solved. 20 is deliberately small: markers are a rollback coordinate for RECENT
|
|
59
|
+
* work, and a coordinate 20 dispatches old has been superseded by ordinary
|
|
60
|
+
* commits and branches. Raise it only against a measured need.
|
|
61
|
+
*/
|
|
62
|
+
export const MAX_DISPATCH_MARKERS_PER_KEY = 20;
|
|
63
|
+
|
|
64
|
+
/** Fallback when an input sanitizes down to nothing. */
|
|
65
|
+
const EMPTY_COMPONENT_PLACEHOLDER = 'unknown';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reduces arbitrary text to a single ref-name component that git accepts.
|
|
69
|
+
*
|
|
70
|
+
* The allowlist is deliberately narrower than git's own rules rather than a
|
|
71
|
+
* blocklist of forbidden characters. Permitting only [A-Za-z0-9_-] makes whole
|
|
72
|
+
* FAMILIES of git's restrictions unreachable instead of individually handled:
|
|
73
|
+
* dropping '.' rules out a leading dot, a trailing dot, a '..' sequence and a
|
|
74
|
+
* '.lock' suffix at once; dropping '@' and '{' rules out '@{' and the lone '@'.
|
|
75
|
+
* A blocklist would have to enumerate each rule correctly and stay correct as
|
|
76
|
+
* git evolves; this cannot fail open by omission.
|
|
77
|
+
*/
|
|
78
|
+
export function sanitizeRefComponent(raw: string): string {
|
|
79
|
+
const cleaned = (raw || '')
|
|
80
|
+
.replace(/[^A-Za-z0-9_-]/g, '-')
|
|
81
|
+
.replace(/-+/g, '-')
|
|
82
|
+
.replace(/^-+|-+$/g, '');
|
|
83
|
+
|
|
84
|
+
return cleaned || EMPTY_COMPONENT_PLACEHOLDER;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Compact UTC stamp, e.g. `20260804T142439123Z`. Colons are stripped because a
|
|
89
|
+
* plain ISO-8601 string (`2026-08-04T14:24:39.123Z`) is an INVALID ref name for
|
|
90
|
+
* the very same reason the agent name is. Fixed width and zero-padded, so
|
|
91
|
+
* lexicographic ref order equals chronological order — which is what makes
|
|
92
|
+
* "prune the oldest" a plain sort.
|
|
93
|
+
*/
|
|
94
|
+
export function formatMarkerStamp(d: Date): string {
|
|
95
|
+
return d.toISOString().replace(/[-:.]/g, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `refs/orchestrator/dispatch/<branch>/<stamp>-<agent>`. */
|
|
99
|
+
export function buildMarkerRefName(branch: string, agent: string, stamp: string): string {
|
|
100
|
+
const branchPart = sanitizeRefComponent(branch);
|
|
101
|
+
const agentPart = sanitizeRefComponent(agent);
|
|
102
|
+
const stampPart = sanitizeRefComponent(stamp);
|
|
103
|
+
|
|
104
|
+
return `${DISPATCH_MARKER_REF_PREFIX}/${branchPart}/${stampPart}-${agentPart}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type DispatchMarkerOutcome =
|
|
108
|
+
| { status: 'written'; ref: string; sha: string; pruned: string[] }
|
|
109
|
+
| { status: 'failed'; reason: string };
|
|
110
|
+
|
|
111
|
+
export interface DispatchMarkerDeps {
|
|
112
|
+
/** Runs git and returns stdout. MUST throw on non-zero exit. */
|
|
113
|
+
runGit: (args: string[]) => string;
|
|
114
|
+
/** Injectable clock (tests need deterministic, ordered stamps). */
|
|
115
|
+
now?: () => Date;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Production git runner: fixed argv, no shell, so no interpolation surface. */
|
|
119
|
+
export function runGitDefault(args: string[]): string {
|
|
120
|
+
return execFileSync('git', args, {
|
|
121
|
+
encoding: 'utf-8',
|
|
122
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Removes the oldest markers under `prefix` until at most `cap` remain.
|
|
128
|
+
* Lexicographic ref order is chronological (see formatMarkerStamp).
|
|
129
|
+
*/
|
|
130
|
+
function pruneOldestMarkers(prefix: string, cap: number, deps: DispatchMarkerDeps): string[] {
|
|
131
|
+
const listed = deps
|
|
132
|
+
.runGit(['for-each-ref', '--format=%(refname)', '--sort=refname', `${prefix}/`])
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((l) => l.trim())
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
|
|
137
|
+
const excess = listed.length - cap;
|
|
138
|
+
if (excess <= 0) return [];
|
|
139
|
+
|
|
140
|
+
const doomed = listed.slice(0, excess);
|
|
141
|
+
for (const ref of doomed) {
|
|
142
|
+
deps.runGit(['update-ref', '-d', ref]);
|
|
143
|
+
}
|
|
144
|
+
return doomed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Writes the boundary marker for `agentName` on `branch`.
|
|
149
|
+
*
|
|
150
|
+
* RETENTION KEY IS THE BRANCH, NOT THE WORKFLOW — a deliberate substitution,
|
|
151
|
+
* reported as such. The workflow id is only obtainable here through an async API
|
|
152
|
+
* call, which would make the marker ABSENT precisely when the system is degraded
|
|
153
|
+
* (API down) — the opposite of what a rollback coordinate is for. The branch is
|
|
154
|
+
* already in hand locally and needs no network. It is also the better key on the
|
|
155
|
+
* merits: `git reset --hard` is a branch-scoped operation, so grouping markers by
|
|
156
|
+
* branch matches the unit they would be consumed at.
|
|
157
|
+
*
|
|
158
|
+
* FAIL-OPEN IN EVERY DIMENSION: git missing, no commits yet, a ref-name that
|
|
159
|
+
* somehow still fails validation, a broken prune — all return a `failed` outcome.
|
|
160
|
+
* This function NEVER throws, so it can never block or delay a dispatch. It is
|
|
161
|
+
* fail-open but NOT silent: it returns the reason, and the caller surfaces it.
|
|
162
|
+
*/
|
|
163
|
+
export function writeDispatchBoundaryMarker(
|
|
164
|
+
agentName: string,
|
|
165
|
+
branch: string,
|
|
166
|
+
deps: DispatchMarkerDeps
|
|
167
|
+
): DispatchMarkerOutcome {
|
|
168
|
+
let ref = '';
|
|
169
|
+
try {
|
|
170
|
+
// An agent name that sanitizes to nothing yields a marker nobody can
|
|
171
|
+
// attribute — litter that consumes the retention cap rather than a usable
|
|
172
|
+
// coordinate. NOT a hypothetical guard: a `...-unknown` ref was written into
|
|
173
|
+
// the real repository during S445's negative-control run, because this hook's
|
|
174
|
+
// entry module executes on import (top-level `main().catch()`) and therefore
|
|
175
|
+
// ran with production deps and an empty stdin. The caller gates on
|
|
176
|
+
// isCommittingAgent, so this never fires in production — it is the backstop
|
|
177
|
+
// for the paths that bypass that gate.
|
|
178
|
+
if (sanitizeRefComponent(agentName) === EMPTY_COMPONENT_PLACEHOLDER) {
|
|
179
|
+
return {
|
|
180
|
+
status: 'failed',
|
|
181
|
+
reason: `refusing to write an unattributable marker (agent name "${agentName}")`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const sha = deps.runGit(['rev-parse', 'HEAD']).trim();
|
|
186
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) {
|
|
187
|
+
return { status: 'failed', reason: `HEAD did not resolve to a 40-hex sha (got "${sha}")` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const stamp = formatMarkerStamp((deps.now ?? (() => new Date()))());
|
|
191
|
+
ref = buildMarkerRefName(branch, agentName, stamp);
|
|
192
|
+
|
|
193
|
+
deps.runGit(['update-ref', ref, sha]);
|
|
194
|
+
|
|
195
|
+
// The marker now exists. Pruning is best-effort bookkeeping ON TOP of a
|
|
196
|
+
// successful write — a prune failure must never downgrade the outcome, or a
|
|
197
|
+
// full namespace would start reporting failure for markers that were in fact
|
|
198
|
+
// written.
|
|
199
|
+
let pruned: string[] = [];
|
|
200
|
+
try {
|
|
201
|
+
pruned = pruneOldestMarkers(
|
|
202
|
+
`${DISPATCH_MARKER_REF_PREFIX}/${sanitizeRefComponent(branch)}`,
|
|
203
|
+
MAX_DISPATCH_MARKERS_PER_KEY,
|
|
204
|
+
deps
|
|
205
|
+
);
|
|
206
|
+
} catch {
|
|
207
|
+
pruned = [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { status: 'written', ref, sha, pruned };
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return { status: 'failed', reason: `${ref ? `ref="${ref}" ` : ''}${String(err)}` };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Replaces post-phase-checkpoint.sh git logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { execSync } from 'node:child_process';
|
|
7
6
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
8
7
|
import { join, dirname } from 'node:path';
|
|
9
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -24,22 +23,33 @@ function getCompletedPhase(currentPhase: string): string | null {
|
|
|
24
23
|
return idx > 0 ? (PHASE_ORDER[idx - 1] ?? null) : null;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
function exec(cmd: string): string {
|
|
28
|
-
return execSync(cmd, { cwd: PROJECT_ROOT, encoding: 'utf-8' }).trim();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
26
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
* Register a checkpoint (PostgreSQL side only) if the phase changed since the
|
|
28
|
+
* last checkpointed phase.
|
|
29
|
+
*
|
|
30
|
+
* TD-257 (S444): this function used to also stage and commit the working
|
|
31
|
+
* tree (`git add -A` + `git commit --no-verify`, unattended, on every phase
|
|
32
|
+
* transition). That was DISARMED after measurement — the decision was made
|
|
33
|
+
* by the user from data, not opinion: 212 such commits by subject over 7
|
|
34
|
+
* months, 145/212 (68%) pure machine-noise, 42/212 (20%) MIXED (real work
|
|
35
|
+
* buried alongside noise), one of which (`01c28adf`) deleted 1627 lines
|
|
36
|
+
* including `EvaluatePhaseGateUseCase.ts` and 3 suites under the message
|
|
37
|
+
* "Auto-checkpoint after tasks phase" — with zero restores in that window,
|
|
38
|
+
* and no production caller for `CheckpointManager.restore()` at all. The
|
|
39
|
+
* separate, deliberate `createCheckpoint` MCP tool (`CheckpointManager.ts`,
|
|
40
|
+
* `GitOperations.ts`) is UNAFFECTED by this change — it is named, tagged,
|
|
41
|
+
* and stays. Returns `true` when a checkpoint was registered for a newly
|
|
42
|
+
* completed phase, `false` otherwise (no completed phase, already
|
|
43
|
+
* checkpointed, or the registration call failed).
|
|
34
44
|
*/
|
|
35
45
|
export async function createCheckpointIfNeeded(
|
|
36
46
|
workflowId: string,
|
|
37
47
|
currentPhase: string
|
|
38
|
-
): Promise<
|
|
48
|
+
): Promise<boolean> {
|
|
39
49
|
const completedPhase = getCompletedPhase(currentPhase);
|
|
40
50
|
if (!completedPhase) {
|
|
41
51
|
log('CHECKPOINT', `No completed phase from: ${currentPhase}`);
|
|
42
|
-
return
|
|
52
|
+
return false;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
// Check if already checkpointed
|
|
@@ -51,41 +61,28 @@ export async function createCheckpointIfNeeded(
|
|
|
51
61
|
}
|
|
52
62
|
if (lastPhase === completedPhase) {
|
|
53
63
|
log('CHECKPOINT', `Already checkpointed: ${completedPhase}`);
|
|
54
|
-
return
|
|
64
|
+
return false;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
try {
|
|
58
|
-
|
|
59
|
-
exec('git add -A');
|
|
60
|
-
|
|
61
|
-
// Check if there are staged changes
|
|
62
|
-
try {
|
|
63
|
-
exec('git diff --cached --quiet');
|
|
64
|
-
// No changes
|
|
65
|
-
log('CHECKPOINT', `No changes for phase: ${completedPhase}`);
|
|
66
|
-
writeFileSync(LAST_PHASE_FILE, completedPhase);
|
|
67
|
-
return null;
|
|
68
|
-
} catch {
|
|
69
|
-
// Has changes — good, proceed to commit
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const commitMsg = `[orchestrator] ${completedPhase}: Auto-checkpoint after ${completedPhase} phase`;
|
|
73
|
-
exec(`git commit -m "${commitMsg}" --no-verify`);
|
|
74
|
-
const commitHash = exec('git rev-parse HEAD');
|
|
75
|
-
|
|
76
|
-
log('CHECKPOINT', `Created: phase=${completedPhase} commit=${commitHash}`);
|
|
68
|
+
log('CHECKPOINT', `Registering checkpoint for phase: ${completedPhase}`);
|
|
77
69
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
70
|
+
// TD-257: no commit is made anymore, so there is no real hash to report.
|
|
71
|
+
// registerCheckpoint's `commitHash` parameter is still required by its
|
|
72
|
+
// signature (out of this task's scope to change — see implementation
|
|
73
|
+
// report) — '' is the honest value ("no commit exists"), not an invented
|
|
74
|
+
// placeholder: it mirrors how the separate createCheckpoint MCP tool path
|
|
75
|
+
// already reports "no commit" as `gitCommit: undefined`
|
|
76
|
+
// (CreateCheckpointHandler.ts) when there is nothing to point to, and the
|
|
77
|
+
// field is unused server-side regardless — CreateCheckpointBodySchema
|
|
78
|
+
// (checkpoints.schemas.ts) has no commitHash field at all, and the route
|
|
79
|
+
// handler (checkpoints.routes.ts) never reads it off the request body.
|
|
80
|
+
await registerCheckpoint(workflowId, `Auto-checkpoint after ${completedPhase} phase`, '');
|
|
84
81
|
|
|
85
82
|
writeFileSync(LAST_PHASE_FILE, completedPhase);
|
|
86
|
-
return
|
|
83
|
+
return true;
|
|
87
84
|
} catch (err) {
|
|
88
85
|
log('CHECKPOINT', `Error: ${err}`);
|
|
89
|
-
return
|
|
86
|
+
return false;
|
|
90
87
|
}
|
|
91
88
|
}
|
|
@@ -4,11 +4,22 @@
|
|
|
4
4
|
* Provides types and helper to write observations.yaml files.
|
|
5
5
|
* Used by subagent-stop.ts to record what each sub-agent observed.
|
|
6
6
|
* All functions are fail-open: errors logged to stderr, never thrown.
|
|
7
|
+
*
|
|
8
|
+
* TD-214/TD-239 TASK-003 (RF-004): `agent` is normalized via
|
|
9
|
+
* `normalizeAgentName` BEFORE it is used to build `agentDir`. Under plugin
|
|
10
|
+
* scope the harness namespaces dispatch names (`orchestrator:reviewer`); in
|
|
11
|
+
* project scope the same agent arrives bare (`reviewer`). Without this,
|
|
12
|
+
* the two scopes silently fork into two different observations.yaml
|
|
13
|
+
* directories for the SAME agent. `data.agent` (the field written INSIDE the
|
|
14
|
+
* YAML) intentionally keeps the raw, un-normalized input — only the
|
|
15
|
+
* directory-path use is normalized — so the file still records exactly what
|
|
16
|
+
* the harness sent, for audit purposes.
|
|
7
17
|
*/
|
|
8
18
|
|
|
9
19
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
10
20
|
import { join } from 'node:path';
|
|
11
21
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
22
|
+
import { normalizeAgentName } from './api-client.js';
|
|
12
23
|
|
|
13
24
|
// ─── Types ───────────────────────────────────────────────
|
|
14
25
|
|
|
@@ -47,7 +58,9 @@ export function appendObservation(
|
|
|
47
58
|
entry: Omit<ObservationEntry, 'promoted'>
|
|
48
59
|
): void {
|
|
49
60
|
try {
|
|
50
|
-
|
|
61
|
+
// TD-214/TD-239 TASK-003: normalize BEFORE building the directory path —
|
|
62
|
+
// see module doc for why (plugin-scope vs project-scope namespacing).
|
|
63
|
+
const agentDir = join(memoryDir, normalizeAgentName(agent));
|
|
51
64
|
const filePath = join(agentDir, 'observations.yaml');
|
|
52
65
|
|
|
53
66
|
// Ensure directory exists
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security-gate-core.ts — lógica PURA do gate honesto de commit (S505, ficha 3).
|
|
3
|
+
*
|
|
4
|
+
* Separado de `security-gate-honest.ts` de propósito: aquele arquivo tem um
|
|
5
|
+
* `main()` de topo que aguarda stdin, e importá-lo num teste trava o runner.
|
|
6
|
+
* A convenção anterior desta casa contornava isso RE-IMPLEMENTANDO a lógica
|
|
7
|
+
* dentro do teste (ver `tests/unit/hooks/post-implement.test.ts`), o que
|
|
8
|
+
* duplica a regra e deixa as duas cópias divergirem em silêncio — a família
|
|
9
|
+
* TD-232 que este repo persegue. Extrair o núcleo resolve os dois problemas:
|
|
10
|
+
* o teste importa o que RODA, e não uma cópia dele.
|
|
11
|
+
*
|
|
12
|
+
* Nada aqui faz IO. O `git show` e a saída do hook ficam no arquivo do hook.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Abaixo disto, um veredito de L2 é fallback disfarçado, não análise. */
|
|
16
|
+
export const L2_LATENCY_FLOOR_MS = 400;
|
|
17
|
+
|
|
18
|
+
/** O que a L1 cobre — declarado, porque cobertura não-dita vira cobertura presumida. */
|
|
19
|
+
export const L1_COVERAGE =
|
|
20
|
+
'literais de credencial no diff (chaves de provedor, tokens, blocos PEM, URLs com senha)';
|
|
21
|
+
|
|
22
|
+
export type Verdict =
|
|
23
|
+
| { state: 'clean'; layer: string; detail: string }
|
|
24
|
+
| { state: 'findings'; layer: string; detail: string; hits: Finding[] }
|
|
25
|
+
| { state: 'not-analyzed'; layer: string; reason: string };
|
|
26
|
+
|
|
27
|
+
export interface Finding {
|
|
28
|
+
rule: string;
|
|
29
|
+
file: string;
|
|
30
|
+
line: number;
|
|
31
|
+
evidence: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─────────────────────────────────────────────────────────────
|
|
35
|
+
// L1 — padrões. Estreitos de propósito: cada regra que dispara
|
|
36
|
+
// falso positivo em massa treina quem lê a ignorar, e um gate
|
|
37
|
+
// ignorado é um gate desligado com aparência de ligado.
|
|
38
|
+
// ─────────────────────────────────────────────────────────────
|
|
39
|
+
export const SECRET_RULES: { rule: string; re: RegExp }[] = [
|
|
40
|
+
{ rule: 'aws-access-key-id', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
41
|
+
{ rule: 'anthropic-api-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
42
|
+
{ rule: 'openai-api-key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
|
|
43
|
+
{ rule: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/ },
|
|
44
|
+
{ rule: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/ },
|
|
45
|
+
{ rule: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
46
|
+
{ rule: 'npm-token', re: /\bnpm_[A-Za-z0-9]{36}\b/ },
|
|
47
|
+
{ rule: 'private-key-block', re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
|
|
48
|
+
{ rule: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
|
|
49
|
+
{ rule: 'url-with-password', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s:@/]{6,}@/i },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Marcas que indicam placeholder e não segredo real. Sem isto o gate grita em
|
|
54
|
+
* cima do `.env.example` e ensina o leitor a ignorá-lo.
|
|
55
|
+
*/
|
|
56
|
+
const PLACEHOLDER =
|
|
57
|
+
/(?:x{6,}|\.{3,}|<[^>]{2,}>|\$\{|EXAMPLE|PLACEHOLDER|YOUR_|CHANGEME|REDACTED|FAKE|DUMMY|\bTODO\b)/i;
|
|
58
|
+
|
|
59
|
+
/** Mascara a evidência: o gate não pode ser o veículo que vaza o segredo que achou. */
|
|
60
|
+
export function maskEvidence(s: string): string {
|
|
61
|
+
const t = s.trim().slice(0, 80);
|
|
62
|
+
return t.length <= 12 ? '***' : `${t.slice(0, 6)}…${t.slice(-4)} (${String(t.length)} ch)`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Varre um diff unificado. Só linhas ADICIONADAS (`+`) — uma linha removida
|
|
67
|
+
* já está no histórico e culpar o commit que a REMOVE inverte o sinal.
|
|
68
|
+
*/
|
|
69
|
+
export function scanDiff(diff: string): Finding[] {
|
|
70
|
+
const out: Finding[] = [];
|
|
71
|
+
let file = '(desconhecido)';
|
|
72
|
+
let lineNo = 0;
|
|
73
|
+
|
|
74
|
+
for (const raw of diff.split('\n')) {
|
|
75
|
+
if (raw.startsWith('+++ b/')) {
|
|
76
|
+
file = raw.slice(6);
|
|
77
|
+
lineNo = 0;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (raw.startsWith('@@')) {
|
|
81
|
+
const m = /\+(\d+)/.exec(raw);
|
|
82
|
+
lineNo = m ? parseInt(m[1], 10) - 1 : 0;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!raw.startsWith('+') || raw.startsWith('+++')) continue;
|
|
86
|
+
lineNo += 1;
|
|
87
|
+
|
|
88
|
+
const content = raw.slice(1);
|
|
89
|
+
if (PLACEHOLDER.test(content)) continue;
|
|
90
|
+
|
|
91
|
+
for (const { rule, re } of SECRET_RULES) {
|
|
92
|
+
const hit = re.exec(content);
|
|
93
|
+
if (hit) out.push({ rule, file, line: lineNo, evidence: maskEvidence(hit[0]) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Detecta `git commit` — e NÃO `git commit --dry-run`, que não cria commit.
|
|
101
|
+
*
|
|
102
|
+
* Aceita opções globais com valor separado (`git -c user.name=x commit`), que a
|
|
103
|
+
* primeira versão desta função deixava passar: o `-c` consome o traço, mas o
|
|
104
|
+
* `user.name=x` seguinte não, e o `commit` deixava de casar. Pego por teste.
|
|
105
|
+
*
|
|
106
|
+
* LIMITAÇÃO CONHECIDA, declarada em vez de escondida: isto é casamento de
|
|
107
|
+
* texto, não parsing de shell. Um comando que apenas CONTENHA a frase
|
|
108
|
+
* (`ls "git commit"`) dispara o gate, que então descreve o HEAD corrente — um
|
|
109
|
+
* commit que não é daquele comando. É ruído, não risco: toda saída nomeia o que
|
|
110
|
+
* analisou, então o leitor vê sobre o que o veredito fala. Parsear shell de
|
|
111
|
+
* verdade custa mais do que o defeito que evita.
|
|
112
|
+
*/
|
|
113
|
+
export function isCommitCommand(command: string): boolean {
|
|
114
|
+
return /git\s+(?:-\S+(?:\s+\S+)?\s+)*commit\b/.test(command) && !/--dry-run\b/.test(command);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─────────────────────────────────────────────────────────────
|
|
118
|
+
// Núcleo
|
|
119
|
+
// ─────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** O diff mais a declaração de COMO foi obtido — cobertura não-dita vira cobertura presumida. */
|
|
122
|
+
export interface DiffSource {
|
|
123
|
+
text: string;
|
|
124
|
+
/** Ex.: "git show HEAD" · "git show -m --first-parent HEAD (merge de 2 pais)". */
|
|
125
|
+
how: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface GateDeps {
|
|
129
|
+
getDiff: () => DiffSource;
|
|
130
|
+
/** L2 devolve null enquanto não houver credencial decidida (ficha 3, item d). */
|
|
131
|
+
runSemantic: (diff: string) => { hits: Finding[]; elapsedMs: number } | null;
|
|
132
|
+
l2UnavailableReason: () => string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function evaluate(deps: GateDeps): Verdict[] {
|
|
136
|
+
const verdicts: Verdict[] = [];
|
|
137
|
+
|
|
138
|
+
let src: DiffSource;
|
|
139
|
+
try {
|
|
140
|
+
src = deps.getDiff();
|
|
141
|
+
} catch (e) {
|
|
142
|
+
// A falha de leitura do diff é NÃO ANALISEI para TODAS as camadas.
|
|
143
|
+
// Este é o ponto exato em que o plugin medido diria "limpo".
|
|
144
|
+
const reason = `não consegui ler o diff do commit (${(e as Error).message.slice(0, 80)})`;
|
|
145
|
+
return [
|
|
146
|
+
{ state: 'not-analyzed', layer: 'L1', reason },
|
|
147
|
+
{ state: 'not-analyzed', layer: 'L2', reason },
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const diff = src.text;
|
|
152
|
+
if (!diff.trim()) {
|
|
153
|
+
// Distinguir POR QUE veio vazio: um merge sem conflito e um `git show` que
|
|
154
|
+
// falhou pedem leituras diferentes, e medida que não separa estados que
|
|
155
|
+
// pedem ações diferentes é medida errada (regra de desenho da S480).
|
|
156
|
+
const reason =
|
|
157
|
+
`o diff veio vazio via ${src.how} — nada para analisar. ` + 'Isto NÃO é o mesmo que limpo';
|
|
158
|
+
return [
|
|
159
|
+
{ state: 'not-analyzed', layer: 'L1', reason },
|
|
160
|
+
{ state: 'not-analyzed', layer: 'L2', reason },
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── L1 ──
|
|
165
|
+
const hits = scanDiff(diff);
|
|
166
|
+
const cover = `${L1_COVERAGE} · via ${src.how}`;
|
|
167
|
+
verdicts.push(
|
|
168
|
+
hits.length
|
|
169
|
+
? { state: 'findings', layer: 'L1', detail: cover, hits }
|
|
170
|
+
: { state: 'clean', layer: 'L1', detail: cover }
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// ── L2 ──
|
|
174
|
+
const sem = deps.runSemantic(diff);
|
|
175
|
+
if (sem === null) {
|
|
176
|
+
verdicts.push({ state: 'not-analyzed', layer: 'L2', reason: deps.l2UnavailableReason() });
|
|
177
|
+
} else if (sem.elapsedMs < L2_LATENCY_FLOOR_MS) {
|
|
178
|
+
// A guarda de latência. Não confia no veredito: análise semântica de um
|
|
179
|
+
// diff não fecha nesse tempo, então isto é fallback, não resultado.
|
|
180
|
+
verdicts.push({
|
|
181
|
+
state: 'not-analyzed',
|
|
182
|
+
layer: 'L2',
|
|
183
|
+
reason:
|
|
184
|
+
`devolveu veredito em ${String(sem.elapsedMs)} ms, abaixo do piso de ${String(L2_LATENCY_FLOOR_MS)} ms — ` +
|
|
185
|
+
'tratado como fallback, não como análise',
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
verdicts.push(
|
|
189
|
+
sem.hits.length
|
|
190
|
+
? { state: 'findings', layer: 'L2', detail: 'análise semântica', hits: sem.hits }
|
|
191
|
+
: { state: 'clean', layer: 'L2', detail: 'análise semântica' }
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return verdicts;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A linha ÚNICA que o humano vê (`systemMessage`), contra o texto por camada que
|
|
200
|
+
* só chega ao modelo (`additionalContext`).
|
|
201
|
+
*
|
|
202
|
+
* Achado do Manager na S505, e é o coração da ficha: **falhar alto significa a
|
|
203
|
+
* PESSOA ver.** Um gate cujo aviso só alcança o modelo tem o mesmo efeito
|
|
204
|
+
* prático do plugin que ele substitui — ninguém é avisado. Por isso a saída sai
|
|
205
|
+
* pelos dois canais.
|
|
206
|
+
*
|
|
207
|
+
* Reporta o PIOR estado, porque é o único que muda o que a pessoa faz agora:
|
|
208
|
+
* achados > nada analisado > cobertura parcial > tudo analisado e limpo.
|
|
209
|
+
*/
|
|
210
|
+
export function renderShort(verdicts: Verdict[]): string {
|
|
211
|
+
const findings = verdicts.filter((v) => v.state === 'findings');
|
|
212
|
+
if (findings.length) {
|
|
213
|
+
const n = findings.reduce((a, v) => a + (v.state === 'findings' ? v.hits.length : 0), 0);
|
|
214
|
+
const layers = findings.map((v) => v.layer).join('+');
|
|
215
|
+
return `SECURITY-GATE: ${String(n)} achado(s) em ${layers} — confira antes de publicar`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const analyzed = verdicts.filter((v) => v.state !== 'not-analyzed').map((v) => v.layer);
|
|
219
|
+
const skipped = verdicts.filter((v) => v.state === 'not-analyzed').map((v) => v.layer);
|
|
220
|
+
|
|
221
|
+
if (!analyzed.length) {
|
|
222
|
+
return 'SECURITY-GATE: NENHUMA camada analisou o commit — isto NÃO é "limpo", é ausência de análise';
|
|
223
|
+
}
|
|
224
|
+
if (skipped.length) {
|
|
225
|
+
return `SECURITY-GATE: cobertura PARCIAL — ${analyzed.join('+')} analisou, ${skipped.join('+')} não`;
|
|
226
|
+
}
|
|
227
|
+
return `SECURITY-GATE: ${analyzed.join('+')} analisou, sem achados`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Renderiza. Nunca produz a frase "no security issues found" nem equivalente
|
|
232
|
+
* global: o veredito é sempre por camada, sempre com a cobertura ao lado.
|
|
233
|
+
*/
|
|
234
|
+
export function render(verdicts: Verdict[]): string {
|
|
235
|
+
const lines = ['[SECURITY-GATE] Revisão do commit — veredito por camada:'];
|
|
236
|
+
|
|
237
|
+
for (const v of verdicts) {
|
|
238
|
+
if (v.state === 'clean') {
|
|
239
|
+
lines.push(` ${v.layer} ANALISADO — LIMPO · cobertura: ${v.detail}`);
|
|
240
|
+
} else if (v.state === 'findings') {
|
|
241
|
+
lines.push(
|
|
242
|
+
` ${v.layer} ANALISADO — ${String(v.hits.length)} ACHADO(S) · cobertura: ${v.detail}`
|
|
243
|
+
);
|
|
244
|
+
for (const h of v.hits.slice(0, 10)) {
|
|
245
|
+
lines.push(` ${h.rule} · ${h.file}:${String(h.line)} · ${h.evidence}`);
|
|
246
|
+
}
|
|
247
|
+
if (v.hits.length > 10) lines.push(` … e mais ${String(v.hits.length - 10)}`);
|
|
248
|
+
} else {
|
|
249
|
+
lines.push(` ${v.layer} NÃO ANALISEI — ${v.reason}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const analyzed = verdicts.filter((v) => v.state !== 'not-analyzed').map((v) => v.layer);
|
|
254
|
+
const skipped = verdicts.filter((v) => v.state === 'not-analyzed').map((v) => v.layer);
|
|
255
|
+
|
|
256
|
+
if (!analyzed.length) {
|
|
257
|
+
lines.push(
|
|
258
|
+
' ⚠ NENHUMA camada analisou este commit. Isto NÃO é "limpo" — é ausência de informação.'
|
|
259
|
+
);
|
|
260
|
+
} else if (skipped.length) {
|
|
261
|
+
lines.push(
|
|
262
|
+
` ⚠ Cobertura PARCIAL: ${analyzed.join('+')} rodou, ${skipped.join('+')} não. ` +
|
|
263
|
+
'O que não foi analisado não está limpo — está por analisar.'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (verdicts.some((v) => v.state === 'findings')) {
|
|
268
|
+
lines.push(' → Confira antes de publicar. O commit local já existe; nada foi bloqueado.');
|
|
269
|
+
}
|
|
270
|
+
return lines.join('\n');
|
|
271
|
+
}
|