@intutic/cli 1.10.0 → 2.0.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/README.md +1 -0
- package/dist/cli.js +868 -30
- package/dist/cli.js.map +1 -1
- package/dist/commands/attenuate.d.ts +39 -0
- package/dist/commands/attenuate.d.ts.map +1 -0
- package/dist/commands/attenuate.js +98 -0
- package/dist/commands/attenuate.js.map +1 -0
- package/dist/commands/budget.d.ts +80 -0
- package/dist/commands/budget.d.ts.map +1 -1
- package/dist/commands/budget.js +119 -3
- package/dist/commands/budget.js.map +1 -1
- package/dist/commands/connect.d.ts +10 -0
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +358 -48
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/credentials.d.ts +31 -0
- package/dist/commands/credentials.d.ts.map +1 -0
- package/dist/commands/credentials.js +134 -0
- package/dist/commands/credentials.js.map +1 -0
- package/dist/commands/decision.d.ts +37 -0
- package/dist/commands/decision.d.ts.map +1 -0
- package/dist/commands/decision.js +56 -0
- package/dist/commands/decision.js.map +1 -0
- package/dist/commands/doctor.d.ts +38 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +228 -18
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/enforce.d.ts +33 -0
- package/dist/commands/enforce.d.ts.map +1 -0
- package/dist/commands/enforce.js +150 -0
- package/dist/commands/enforce.js.map +1 -0
- package/dist/commands/enterprise.d.ts +29 -0
- package/dist/commands/enterprise.d.ts.map +1 -0
- package/dist/commands/enterprise.js +141 -0
- package/dist/commands/enterprise.js.map +1 -0
- package/dist/commands/env.d.ts +7 -0
- package/dist/commands/env.d.ts.map +1 -0
- package/dist/commands/env.js +55 -0
- package/dist/commands/env.js.map +1 -0
- package/dist/commands/exec.d.ts +38 -2
- package/dist/commands/exec.d.ts.map +1 -1
- package/dist/commands/exec.js +247 -10
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/findings.d.ts +84 -0
- package/dist/commands/findings.d.ts.map +1 -0
- package/dist/commands/findings.js +329 -0
- package/dist/commands/findings.js.map +1 -0
- package/dist/commands/gateway.d.ts +62 -0
- package/dist/commands/gateway.d.ts.map +1 -0
- package/dist/commands/gateway.js +298 -0
- package/dist/commands/gateway.js.map +1 -0
- package/dist/commands/guardrails.d.ts +81 -0
- package/dist/commands/guardrails.d.ts.map +1 -0
- package/dist/commands/guardrails.js +735 -0
- package/dist/commands/guardrails.js.map +1 -0
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +14 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/install-daemon.d.ts +70 -3
- package/dist/commands/install-daemon.d.ts.map +1 -1
- package/dist/commands/install-daemon.js +482 -47
- package/dist/commands/install-daemon.js.map +1 -1
- package/dist/commands/integrity.d.ts +205 -0
- package/dist/commands/integrity.d.ts.map +1 -0
- package/dist/commands/integrity.js +620 -0
- package/dist/commands/integrity.js.map +1 -0
- package/dist/commands/judge.d.ts +31 -0
- package/dist/commands/judge.d.ts.map +1 -0
- package/dist/commands/judge.js +118 -0
- package/dist/commands/judge.js.map +1 -0
- package/dist/commands/org.d.ts +31 -0
- package/dist/commands/org.d.ts.map +1 -0
- package/dist/commands/org.js +159 -0
- package/dist/commands/org.js.map +1 -0
- package/dist/commands/policy.d.ts +138 -4
- package/dist/commands/policy.d.ts.map +1 -1
- package/dist/commands/policy.js +538 -97
- package/dist/commands/policy.js.map +1 -1
- package/dist/commands/predict-cost.d.ts +45 -0
- package/dist/commands/predict-cost.d.ts.map +1 -0
- package/dist/commands/predict-cost.js +123 -0
- package/dist/commands/predict-cost.js.map +1 -0
- package/dist/commands/rollback.d.ts +5 -0
- package/dist/commands/rollback.d.ts.map +1 -0
- package/dist/commands/rollback.js +142 -0
- package/dist/commands/rollback.js.map +1 -0
- package/dist/commands/routing.d.ts +62 -0
- package/dist/commands/routing.d.ts.map +1 -0
- package/dist/commands/routing.js +121 -0
- package/dist/commands/routing.js.map +1 -0
- package/dist/commands/setup.d.ts +62 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +238 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/skill.d.ts +268 -1
- package/dist/commands/skill.d.ts.map +1 -1
- package/dist/commands/skill.js +744 -39
- package/dist/commands/skill.js.map +1 -1
- package/dist/commands/sops.d.ts +95 -0
- package/dist/commands/sops.d.ts.map +1 -1
- package/dist/commands/sops.js +334 -34
- package/dist/commands/sops.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +1 -18
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +44 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/team.d.ts +38 -0
- package/dist/commands/team.d.ts.map +1 -0
- package/dist/commands/team.js +140 -0
- package/dist/commands/team.js.map +1 -0
- package/dist/commands/traces.d.ts +8 -1
- package/dist/commands/traces.d.ts.map +1 -1
- package/dist/commands/traces.js +114 -15
- package/dist/commands/traces.js.map +1 -1
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +2 -1
- package/dist/commands/whoami.js.map +1 -1
- package/dist/config/keychain.d.ts +10 -2
- package/dist/config/keychain.d.ts.map +1 -1
- package/dist/config/keychain.js +83 -14
- package/dist/config/keychain.js.map +1 -1
- package/dist/config/paths.d.ts +27 -0
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +37 -6
- package/dist/config/paths.js.map +1 -1
- package/dist/config/store.d.ts.map +1 -1
- package/dist/config/store.js +19 -3
- package/dist/config/store.js.map +1 -1
- package/dist/harness/ag2.d.ts +22 -0
- package/dist/harness/ag2.d.ts.map +1 -0
- package/dist/harness/ag2.js +34 -0
- package/dist/harness/ag2.js.map +1 -0
- package/dist/harness/agentcore.d.ts +55 -0
- package/dist/harness/agentcore.d.ts.map +1 -0
- package/dist/harness/agentcore.js +119 -0
- package/dist/harness/agentcore.js.map +1 -0
- package/dist/harness/agenticOrchestrator.d.ts +52 -0
- package/dist/harness/agenticOrchestrator.d.ts.map +1 -0
- package/dist/harness/agenticOrchestrator.js +115 -0
- package/dist/harness/agenticOrchestrator.js.map +1 -0
- package/dist/harness/aiSdkHarness.d.ts +30 -0
- package/dist/harness/aiSdkHarness.d.ts.map +1 -0
- package/dist/harness/aiSdkHarness.js +51 -0
- package/dist/harness/aiSdkHarness.js.map +1 -0
- package/dist/harness/aiSdkWorkflow.d.ts +26 -0
- package/dist/harness/aiSdkWorkflow.d.ts.map +1 -0
- package/dist/harness/aiSdkWorkflow.js +39 -0
- package/dist/harness/aiSdkWorkflow.js.map +1 -0
- package/dist/harness/autogen.d.ts +24 -0
- package/dist/harness/autogen.d.ts.map +1 -0
- package/dist/harness/autogen.js +36 -0
- package/dist/harness/autogen.js.map +1 -0
- package/dist/harness/base.js.map +1 -1
- package/dist/harness/continue.d.ts.map +1 -1
- package/dist/harness/continue.js +2 -1
- package/dist/harness/continue.js.map +1 -1
- package/dist/harness/crewai.d.ts +15 -0
- package/dist/harness/crewai.d.ts.map +1 -0
- package/dist/harness/crewai.js +26 -0
- package/dist/harness/crewai.js.map +1 -0
- package/dist/harness/detector.d.ts +1 -1
- package/dist/harness/detector.d.ts.map +1 -1
- package/dist/harness/detector.js +47 -1
- package/dist/harness/detector.js.map +1 -1
- package/dist/harness/dsh.d.ts +19 -0
- package/dist/harness/dsh.d.ts.map +1 -0
- package/dist/harness/dsh.js +93 -0
- package/dist/harness/dsh.js.map +1 -0
- package/dist/harness/eve.d.ts +40 -0
- package/dist/harness/eve.d.ts.map +1 -0
- package/dist/harness/eve.js +67 -0
- package/dist/harness/eve.js.map +1 -0
- package/dist/harness/googleAdk.d.ts +18 -0
- package/dist/harness/googleAdk.d.ts.map +1 -0
- package/dist/harness/googleAdk.js +29 -0
- package/dist/harness/googleAdk.js.map +1 -0
- package/dist/harness/goose.d.ts.map +1 -1
- package/dist/harness/goose.js +5 -1
- package/dist/harness/goose.js.map +1 -1
- package/dist/harness/grok.d.ts +16 -0
- package/dist/harness/grok.d.ts.map +1 -0
- package/dist/harness/grok.js +78 -0
- package/dist/harness/grok.js.map +1 -0
- package/dist/harness/jsSdkGatedAdapter.d.ts +102 -0
- package/dist/harness/jsSdkGatedAdapter.d.ts.map +1 -0
- package/dist/harness/jsSdkGatedAdapter.js +139 -0
- package/dist/harness/jsSdkGatedAdapter.js.map +1 -0
- package/dist/harness/langchain.d.ts +22 -0
- package/dist/harness/langchain.d.ts.map +1 -0
- package/dist/harness/langchain.js +33 -0
- package/dist/harness/langchain.js.map +1 -0
- package/dist/harness/langgraph.d.ts +22 -0
- package/dist/harness/langgraph.d.ts.map +1 -0
- package/dist/harness/langgraph.js +80 -0
- package/dist/harness/langgraph.js.map +1 -0
- package/dist/harness/mastra.d.ts +21 -0
- package/dist/harness/mastra.d.ts.map +1 -0
- package/dist/harness/mastra.js +33 -0
- package/dist/harness/mastra.js.map +1 -0
- package/dist/harness/muse.d.ts +22 -0
- package/dist/harness/muse.d.ts.map +1 -0
- package/dist/harness/muse.js +92 -0
- package/dist/harness/muse.js.map +1 -0
- package/dist/harness/n8n.d.ts.map +1 -1
- package/dist/harness/n8n.js +36 -11
- package/dist/harness/n8n.js.map +1 -1
- package/dist/harness/openaiAgents.d.ts +40 -0
- package/dist/harness/openaiAgents.d.ts.map +1 -0
- package/dist/harness/openaiAgents.js +107 -0
- package/dist/harness/openaiAgents.js.map +1 -0
- package/dist/harness/opencode.d.ts +24 -0
- package/dist/harness/opencode.d.ts.map +1 -0
- package/dist/harness/opencode.js +97 -0
- package/dist/harness/opencode.js.map +1 -0
- package/dist/harness/pydanticAi.d.ts +19 -0
- package/dist/harness/pydanticAi.d.ts.map +1 -0
- package/dist/harness/pydanticAi.js +30 -0
- package/dist/harness/pydanticAi.js.map +1 -0
- package/dist/harness/sdkGatedAdapter.d.ts +55 -0
- package/dist/harness/sdkGatedAdapter.d.ts.map +1 -0
- package/dist/harness/sdkGatedAdapter.js +85 -0
- package/dist/harness/sdkGatedAdapter.js.map +1 -0
- package/dist/harness/smolagents.d.ts +22 -0
- package/dist/harness/smolagents.d.ts.map +1 -0
- package/dist/harness/smolagents.js +33 -0
- package/dist/harness/smolagents.js.map +1 -0
- package/dist/harness/strands.d.ts +22 -0
- package/dist/harness/strands.d.ts.map +1 -0
- package/dist/harness/strands.js +35 -0
- package/dist/harness/strands.js.map +1 -0
- package/dist/harness/trueforge.d.ts +25 -0
- package/dist/harness/trueforge.d.ts.map +1 -0
- package/dist/harness/trueforge.js +40 -0
- package/dist/harness/trueforge.js.map +1 -0
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js +70 -0
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/vercelAiSdk.d.ts +31 -0
- package/dist/harness/vercelAiSdk.d.ts.map +1 -0
- package/dist/harness/vercelAiSdk.js +44 -0
- package/dist/harness/vercelAiSdk.js.map +1 -0
- package/dist/harness/xirp.d.ts +43 -0
- package/dist/harness/xirp.d.ts.map +1 -0
- package/dist/harness/xirp.js +95 -0
- package/dist/harness/xirp.js.map +1 -0
- package/dist/lib/api.d.ts +68 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +108 -67
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/authMessages.d.ts +19 -0
- package/dist/lib/authMessages.d.ts.map +1 -0
- package/dist/lib/authMessages.js +20 -0
- package/dist/lib/authMessages.js.map +1 -0
- package/dist/lib/binaryChecksum.d.ts +12 -0
- package/dist/lib/binaryChecksum.d.ts.map +1 -0
- package/dist/lib/binaryChecksum.js +45 -0
- package/dist/lib/binaryChecksum.js.map +1 -0
- package/dist/lib/binaryOnPath.d.ts +24 -0
- package/dist/lib/binaryOnPath.d.ts.map +1 -0
- package/dist/lib/binaryOnPath.js +31 -0
- package/dist/lib/binaryOnPath.js.map +1 -0
- package/dist/lib/caTrust.d.ts +69 -0
- package/dist/lib/caTrust.d.ts.map +1 -0
- package/dist/lib/caTrust.js +169 -0
- package/dist/lib/caTrust.js.map +1 -0
- package/dist/lib/ciscoScanner.d.ts +178 -0
- package/dist/lib/ciscoScanner.d.ts.map +1 -0
- package/dist/lib/ciscoScanner.js +292 -0
- package/dist/lib/ciscoScanner.js.map +1 -0
- package/dist/lib/decisionsLogRefresh.d.ts +28 -0
- package/dist/lib/decisionsLogRefresh.d.ts.map +1 -0
- package/dist/lib/decisionsLogRefresh.js +54 -0
- package/dist/lib/decisionsLogRefresh.js.map +1 -0
- package/dist/lib/deviceReport.d.ts +26 -0
- package/dist/lib/deviceReport.d.ts.map +1 -0
- package/dist/lib/deviceReport.js +55 -0
- package/dist/lib/deviceReport.js.map +1 -0
- package/dist/lib/elevation.d.ts +58 -0
- package/dist/lib/elevation.d.ts.map +1 -0
- package/dist/lib/elevation.js +83 -0
- package/dist/lib/elevation.js.map +1 -0
- package/dist/lib/enforcementState.d.ts +69 -0
- package/dist/lib/enforcementState.d.ts.map +1 -0
- package/dist/lib/enforcementState.js +113 -0
- package/dist/lib/enforcementState.js.map +1 -0
- package/dist/lib/ensureValkey.d.ts.map +1 -1
- package/dist/lib/ensureValkey.js +2 -2
- package/dist/lib/ensureValkey.js.map +1 -1
- package/dist/lib/envInjector.d.ts.map +1 -1
- package/dist/lib/envInjector.js +22 -4
- package/dist/lib/envInjector.js.map +1 -1
- package/dist/lib/gitHooks.d.ts +7 -2
- package/dist/lib/gitHooks.d.ts.map +1 -1
- package/dist/lib/gitHooks.js +180 -13
- package/dist/lib/gitHooks.js.map +1 -1
- package/dist/lib/gracefulShutdown.d.ts +37 -0
- package/dist/lib/gracefulShutdown.d.ts.map +1 -0
- package/dist/lib/gracefulShutdown.js +57 -0
- package/dist/lib/gracefulShutdown.js.map +1 -0
- package/dist/lib/graphIdentity.d.ts +80 -0
- package/dist/lib/graphIdentity.d.ts.map +1 -0
- package/dist/lib/graphIdentity.js +120 -0
- package/dist/lib/graphIdentity.js.map +1 -0
- package/dist/lib/localTraces.d.ts +61 -0
- package/dist/lib/localTraces.d.ts.map +1 -0
- package/dist/lib/localTraces.js +173 -0
- package/dist/lib/localTraces.js.map +1 -0
- package/dist/lib/mdmManifest.d.ts +73 -0
- package/dist/lib/mdmManifest.d.ts.map +1 -0
- package/dist/lib/mdmManifest.js +190 -0
- package/dist/lib/mdmManifest.js.map +1 -0
- package/dist/lib/onboarding.d.ts.map +1 -1
- package/dist/lib/onboarding.js +60 -1
- package/dist/lib/onboarding.js.map +1 -1
- package/dist/lib/otelPropagation.d.ts +26 -0
- package/dist/lib/otelPropagation.d.ts.map +1 -0
- package/dist/lib/otelPropagation.js +40 -0
- package/dist/lib/otelPropagation.js.map +1 -0
- package/dist/lib/policySnapshot.d.ts +92 -0
- package/dist/lib/policySnapshot.d.ts.map +1 -0
- package/dist/lib/policySnapshot.js +173 -0
- package/dist/lib/policySnapshot.js.map +1 -0
- package/dist/lib/process.d.ts.map +1 -1
- package/dist/lib/process.js +60 -0
- package/dist/lib/process.js.map +1 -1
- package/dist/lib/providerProbe.d.ts +28 -0
- package/dist/lib/providerProbe.d.ts.map +1 -0
- package/dist/lib/providerProbe.js +57 -0
- package/dist/lib/providerProbe.js.map +1 -0
- package/dist/lib/proxyBinary.d.ts +19 -0
- package/dist/lib/proxyBinary.d.ts.map +1 -0
- package/dist/lib/proxyBinary.js +30 -0
- package/dist/lib/proxyBinary.js.map +1 -0
- package/dist/lib/sandbox/firecracker.d.ts +89 -0
- package/dist/lib/sandbox/firecracker.d.ts.map +1 -0
- package/dist/lib/sandbox/firecracker.js +135 -0
- package/dist/lib/sandbox/firecracker.js.map +1 -0
- package/dist/lib/sandbox/index.d.ts +34 -0
- package/dist/lib/sandbox/index.d.ts.map +1 -0
- package/dist/lib/sandbox/index.js +54 -0
- package/dist/lib/sandbox/index.js.map +1 -0
- package/dist/lib/sandbox/oci.d.ts +38 -0
- package/dist/lib/sandbox/oci.d.ts.map +1 -0
- package/dist/lib/sandbox/oci.js +108 -0
- package/dist/lib/sandbox/oci.js.map +1 -0
- package/dist/lib/sandbox/types.d.ts +48 -0
- package/dist/lib/sandbox/types.d.ts.map +1 -0
- package/dist/lib/sandbox/types.js +12 -0
- package/dist/lib/sandbox/types.js.map +1 -0
- package/dist/lib/sopFrontMatter.d.ts +50 -0
- package/dist/lib/sopFrontMatter.d.ts.map +1 -0
- package/dist/lib/sopFrontMatter.js +129 -0
- package/dist/lib/sopFrontMatter.js.map +1 -0
- package/package.json +11 -6
package/dist/commands/skill.js
CHANGED
|
@@ -5,13 +5,263 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import * as fs from 'node:fs/promises';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
10
11
|
import { log } from '../lib/logger.js';
|
|
12
|
+
import { NOT_AUTHENTICATED } from '../lib/authMessages.js';
|
|
11
13
|
import { loadCredentials, loadConfig } from '../config/store.js';
|
|
12
|
-
import { resolveControlPlaneUrl } from '../config/paths.js';
|
|
14
|
+
import { getIntuticDir, resolveControlPlaneUrl } from '../config/paths.js';
|
|
13
15
|
import { createApiClient } from '../lib/api.js';
|
|
16
|
+
import { scanSkillContent, SKILL_SCAN_PATTERNS, scanScriptContent, SCRIPT_SCAN_PATTERNS, detectScriptLanguage, MAX_SKILL_DIR_DEPTH, MAX_FILES_PER_SKILL, MAX_SCRIPT_SCAN_BYTES, } from '@intutic/shared-types';
|
|
17
|
+
import { ciscoScannerOnPath, runCiscoScan, combineCiscoSarifRuns, } from '../lib/ciscoScanner.js';
|
|
14
18
|
import pc from 'picocolors';
|
|
19
|
+
/**
|
|
20
|
+
* Cap a skill's content to the same bound the control plane's
|
|
21
|
+
* `SkillFileReportSchema` enforces (`z.string().max(65536)`) before it ever
|
|
22
|
+
* leaves this machine — truncating client-side rather than letting an
|
|
23
|
+
* oversized file 400 the whole report. `content` is judged, then stripped
|
|
24
|
+
* before persistence server-side either way (see that schema's own doc
|
|
25
|
+
* comment); a truncated judge input is a weaker signal, never a stored
|
|
26
|
+
* artifact, so silent truncation here is the same tradeoff
|
|
27
|
+
* `mirrorAdoptionService.ts`'s own `capBytes` makes for judge prompt fields.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_SKILL_CONTENT_TRANSPORT_CHARS = 65536;
|
|
30
|
+
export function capContentForTransport(content) {
|
|
31
|
+
return content.length > MAX_SKILL_CONTENT_TRANSPORT_CHARS
|
|
32
|
+
? content.slice(0, MAX_SKILL_CONTENT_TRANSPORT_CHARS)
|
|
33
|
+
: content;
|
|
34
|
+
}
|
|
35
|
+
/** Where bundled skill directories live, mirroring the conventions
|
|
36
|
+
* `services/sync-daemon/src/agentReporter.ts`'s `collectSkills` already
|
|
37
|
+
* uses for `.agents/skills`, plus Claude Code's own `.claude/skills`. Each
|
|
38
|
+
* entry directly under the root is a skill; `SKILL.md` is its content. */
|
|
39
|
+
const SKILL_DIRECTORY_ROOTS = [
|
|
40
|
+
{ dir: '.agents/skills', source: '.agents/skills' },
|
|
41
|
+
{ dir: '.claude/skills', source: '.claude/skills' },
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* Read, content-scan, and (if `prune` is set and the scan is not clean)
|
|
45
|
+
* auto-prune one real skill file. Extracted from `runSkillAudit` so the
|
|
46
|
+
* discovery/read/scan/report path is testable without the network and
|
|
47
|
+
* credentials setup the full command does.
|
|
48
|
+
*
|
|
49
|
+
* Refusal-not-pass: a read failure returns `scanned: false` with
|
|
50
|
+
* `issuesDetected: 0` — the zero is not a clean bill of health, it is "we
|
|
51
|
+
* never got to look." Callers must treat `scanned: false` as its own status,
|
|
52
|
+
* never as `clean`.
|
|
53
|
+
*/
|
|
54
|
+
export async function auditSkillFile(filePath, fullPath, prune,
|
|
55
|
+
/** Suppresses the per-finding `log.warn` narrative. Used by `--sarif`
|
|
56
|
+
* (`runSkillAudit`), whose stdout contract is a single JSON document — a
|
|
57
|
+
* CI tool piping that into a SARIF parser must not see decorated text
|
|
58
|
+
* interleaved with it. Findings are still returned; only the console
|
|
59
|
+
* narration is skipped. Defaults to `false` so every existing caller
|
|
60
|
+
* (including the tests that assert on `log.warn` output indirectly via
|
|
61
|
+
* behaviour) is unaffected. */
|
|
62
|
+
quiet = false,
|
|
63
|
+
/**
|
|
64
|
+
* Phase S5 (TD-357). Attach the file's full content to the returned entry
|
|
65
|
+
* — only ever set by a caller that already confirmed
|
|
66
|
+
* `semanticSkillAnalysisEnabled` for this workspace (`runSkillAudit`
|
|
67
|
+
* below reads it off `client.fetchConfig`'s resolved settings, the same
|
|
68
|
+
* way it already reads `enableLocalSkillAuditDelete`). Defaults to
|
|
69
|
+
* `false` so every existing caller/test is unaffected. */
|
|
70
|
+
attachContent = false) {
|
|
71
|
+
let content;
|
|
72
|
+
try {
|
|
73
|
+
content = await fs.readFile(fullPath, 'utf8');
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (!quiet) {
|
|
77
|
+
log.warn(`[${filePath}] Could not be read (${err instanceof Error ? err.message : String(err)}) — reported as unscanned, not clean.`);
|
|
78
|
+
}
|
|
79
|
+
return { filePath, linesCount: 0, issuesDetected: 0, scanned: false, kind: 'skill_md' };
|
|
80
|
+
}
|
|
81
|
+
const contentLines = content.split('\n');
|
|
82
|
+
const result = scanSkillContent(content);
|
|
83
|
+
const sha256 = createHash('sha256').update(content, 'utf8').digest('hex');
|
|
84
|
+
if (!result.clean && !quiet) {
|
|
85
|
+
for (const finding of result.findings) {
|
|
86
|
+
log.warn(`[${filePath}] ${finding.category} finding (${finding.patternId})${finding.excerpt ? `: ${finding.excerpt}` : ''}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Auto-prune worthy findings, same opt-in as the legacy rule-file audit:
|
|
90
|
+
// drop any LINE that itself trips a pattern, line-by-line — mirroring the
|
|
91
|
+
// existing per-line re-scan below rather than deleting the whole file,
|
|
92
|
+
// since a skill missing a few flagged lines still functions.
|
|
93
|
+
if (!result.clean && prune) {
|
|
94
|
+
let fileUpdated = false;
|
|
95
|
+
const filteredLines = contentLines.filter((line) => {
|
|
96
|
+
if (!scanSkillContent(line).clean) {
|
|
97
|
+
fileUpdated = true;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
if (fileUpdated) {
|
|
103
|
+
await fs.writeFile(fullPath, filteredLines.join('\n'), 'utf8');
|
|
104
|
+
if (!quiet)
|
|
105
|
+
log.success(`[${filePath}] Auto-pruned lines flagged by the skill-content scan.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
filePath,
|
|
110
|
+
linesCount: contentLines.length,
|
|
111
|
+
issuesDetected: result.findings.length,
|
|
112
|
+
findings: result.findings,
|
|
113
|
+
sha256,
|
|
114
|
+
scanned: true,
|
|
115
|
+
kind: 'skill_md',
|
|
116
|
+
...(attachContent ? { content: capContentForTransport(content) } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Enumerate real `SKILL.md` files under both skill directory roots. A
|
|
120
|
+
* missing root is not an error — most workspaces have neither. Exported so
|
|
121
|
+
* tests can walk a fixture directory tree without going through the full
|
|
122
|
+
* `skill audit`/`skill list` command (credentials, network, etc). */
|
|
123
|
+
export async function discoverSkillFiles(workspaceRoot) {
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const root of SKILL_DIRECTORY_ROOTS) {
|
|
126
|
+
const dir = join(workspaceRoot, root.dir);
|
|
127
|
+
let entries;
|
|
128
|
+
try {
|
|
129
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
if (!entry.isDirectory())
|
|
136
|
+
continue;
|
|
137
|
+
const filePath = join(root.dir, entry.name, 'SKILL.md');
|
|
138
|
+
const fullPath = join(workspaceRoot, filePath);
|
|
139
|
+
if (existsSync(fullPath)) {
|
|
140
|
+
out.push({ filePath, fullPath, source: root.source });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Bounded, symlink-skipping recursive walk of a skill directory's sibling
|
|
148
|
+
* files — everything alongside `SKILL.md`, the surface TD-356
|
|
149
|
+
* (`docs/TECH_DEBT.md`) named as unenumerated. `SKILL.md` itself is excluded
|
|
150
|
+
* (it is already covered by `discoverSkillFiles`/`auditSkillFile` above);
|
|
151
|
+
* everything else found, at any depth up to the cap, is a candidate for
|
|
152
|
+
* `auditScriptFile`.
|
|
153
|
+
*
|
|
154
|
+
* Bounded by `MAX_SKILL_DIR_DEPTH` and `MAX_FILES_PER_SKILL`
|
|
155
|
+
* (`@intutic/shared-types`) — a skill directory is developer-authored
|
|
156
|
+
* content, not something this command should ever walk unboundedly. Depth 0
|
|
157
|
+
* is the skill directory itself; nested subdirectories increment from
|
|
158
|
+
* there, and the walk stops recursing once depth exceeds the cap.
|
|
159
|
+
*
|
|
160
|
+
* Symlinks are never followed: `Dirent.isSymbolicLink()` is checked during
|
|
161
|
+
* `readdir` and skipped outright, without ever stat-ing or resolving where
|
|
162
|
+
* the link points. A symlink inside a skill directory could point outside
|
|
163
|
+
* that directory — or outside the workspace entirely — which would turn a
|
|
164
|
+
* bounded, skill-scoped walk into an effectively unbounded one, and would
|
|
165
|
+
* let a skill directory "bundle" a script that does not actually live on
|
|
166
|
+
* disk inside it.
|
|
167
|
+
*/
|
|
168
|
+
export async function discoverSkillBundledFiles(workspaceRoot,
|
|
169
|
+
/** Path to the skill directory (not `SKILL.md` itself), relative to
|
|
170
|
+
* `workspaceRoot`, e.g. `.agents/skills/my-skill`. */
|
|
171
|
+
skillDirPath) {
|
|
172
|
+
const out = [];
|
|
173
|
+
async function walk(relDir, depth) {
|
|
174
|
+
if (depth > MAX_SKILL_DIR_DEPTH || out.length >= MAX_FILES_PER_SKILL)
|
|
175
|
+
return;
|
|
176
|
+
let entries;
|
|
177
|
+
try {
|
|
178
|
+
entries = await fs.readdir(join(workspaceRoot, relDir), { withFileTypes: true });
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
if (out.length >= MAX_FILES_PER_SKILL)
|
|
185
|
+
return;
|
|
186
|
+
if (entry.isSymbolicLink())
|
|
187
|
+
continue; // never follow — see doc comment above
|
|
188
|
+
const relPath = join(relDir, entry.name);
|
|
189
|
+
if (entry.isDirectory()) {
|
|
190
|
+
await walk(relPath, depth + 1);
|
|
191
|
+
}
|
|
192
|
+
else if (entry.isFile()) {
|
|
193
|
+
if (depth === 0 && entry.name === 'SKILL.md')
|
|
194
|
+
continue;
|
|
195
|
+
out.push({ filePath: relPath, fullPath: join(workspaceRoot, relPath) });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
await walk(skillDirPath, 0);
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Read, hash, and — when recognized and within the byte cap — content-scan
|
|
204
|
+
* one bundled script file discovered by `discoverSkillBundledFiles`.
|
|
205
|
+
*
|
|
206
|
+
* The sha256 hash is ALWAYS computed, even for a file this scanner cannot
|
|
207
|
+
* itself interpret (a binary, an unrecognized extension with no useful
|
|
208
|
+
* shebang) — that is deliberate, not a leftover from a simpler version of
|
|
209
|
+
* this function. A later, separate, opt-in phase (Cisco `skill-scanner` /
|
|
210
|
+
* VirusTotal-style hash lookup, Phase S3) needs the hash of every bundled
|
|
211
|
+
* file regardless of whether THIS scanner's regex patterns can say anything
|
|
212
|
+
* about its content.
|
|
213
|
+
*
|
|
214
|
+
* Refusal-not-pass, same contract as `auditSkillFile`: a read failure, a
|
|
215
|
+
* file over `MAX_SCRIPT_SCAN_BYTES`, or an unrecognized language all return
|
|
216
|
+
* `scanned: false` — never folded into `issuesDetected: 0` implying safety.
|
|
217
|
+
* A file over the byte cap is a REFUSAL to look, reported as such, not a
|
|
218
|
+
* silent skip and not a false "clean".
|
|
219
|
+
*/
|
|
220
|
+
export async function auditScriptFile(filePath, fullPath, quiet = false) {
|
|
221
|
+
let buffer;
|
|
222
|
+
try {
|
|
223
|
+
buffer = await fs.readFile(fullPath);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
if (!quiet) {
|
|
227
|
+
log.warn(`[${filePath}] Could not be read (${err instanceof Error ? err.message : String(err)}) — reported as unscanned, not clean.`);
|
|
228
|
+
}
|
|
229
|
+
return { filePath, linesCount: 0, issuesDetected: 0, scanned: false, kind: 'script' };
|
|
230
|
+
}
|
|
231
|
+
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
|
232
|
+
const firstLine = buffer.toString('utf8', 0, Math.min(buffer.length, 200)).split('\n')[0];
|
|
233
|
+
const language = detectScriptLanguage(filePath, firstLine);
|
|
234
|
+
if (buffer.length > MAX_SCRIPT_SCAN_BYTES) {
|
|
235
|
+
if (!quiet) {
|
|
236
|
+
log.warn(`[${filePath}] Exceeds the ${MAX_SCRIPT_SCAN_BYTES}-byte script scan cap — refusing to scan (hash recorded).`);
|
|
237
|
+
}
|
|
238
|
+
return { filePath, linesCount: 0, issuesDetected: 0, sha256, scanned: false, kind: 'script', language };
|
|
239
|
+
}
|
|
240
|
+
if (language === 'unknown') {
|
|
241
|
+
// Not a script language this scanner understands (binary, data file,
|
|
242
|
+
// unrecognized extension with no useful shebang) — hash-only, same
|
|
243
|
+
// refusal-not-pass status as an over-cap file.
|
|
244
|
+
return { filePath, linesCount: 0, issuesDetected: 0, sha256, scanned: false, kind: 'script', language };
|
|
245
|
+
}
|
|
246
|
+
const content = buffer.toString('utf8');
|
|
247
|
+
const contentLines = content.split('\n');
|
|
248
|
+
const result = scanScriptContent(content, language);
|
|
249
|
+
if (!result.clean && !quiet) {
|
|
250
|
+
for (const finding of result.findings) {
|
|
251
|
+
log.warn(`[${filePath}] ${finding.category} finding (${finding.patternId})${finding.excerpt ? `: ${finding.excerpt}` : ''}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
filePath,
|
|
256
|
+
linesCount: contentLines.length,
|
|
257
|
+
issuesDetected: result.findings.length,
|
|
258
|
+
findings: result.findings,
|
|
259
|
+
sha256,
|
|
260
|
+
scanned: true,
|
|
261
|
+
kind: 'script',
|
|
262
|
+
language,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
15
265
|
// ─── Skill Commands ──────────────────────────────────────────────────
|
|
16
266
|
export async function runSkillList() {
|
|
17
267
|
log.header('Intutic — Skill Discovery');
|
|
@@ -40,6 +290,23 @@ export async function runSkillList() {
|
|
|
40
290
|
});
|
|
41
291
|
}
|
|
42
292
|
}
|
|
293
|
+
// Real skill directories (`.agents/skills`, `.claude/skills`) — discovery
|
|
294
|
+
// only here, matching the existing "list only counts" contract for this
|
|
295
|
+
// command; content scanning happens in `skill audit` below.
|
|
296
|
+
const skillFiles = await discoverSkillFiles(workspaceRoot);
|
|
297
|
+
for (const { filePath } of skillFiles) {
|
|
298
|
+
found++;
|
|
299
|
+
let lines = 0;
|
|
300
|
+
try {
|
|
301
|
+
lines = (await fs.readFile(join(workspaceRoot, filePath), 'utf8')).split('\n').length;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Discovery already found the directory entry; a read failure here
|
|
305
|
+
// just means we can't report a line count.
|
|
306
|
+
}
|
|
307
|
+
console.log(` ${pc.green('✔')} ${pc.bold(filePath)} (${lines} lines)`);
|
|
308
|
+
skillsReport.push({ filePath, linesCount: lines, issuesDetected: 0 });
|
|
309
|
+
}
|
|
43
310
|
if (found === 0) {
|
|
44
311
|
log.info('No active skill or rule files found in workspace root.');
|
|
45
312
|
}
|
|
@@ -54,28 +321,252 @@ export async function runSkillList() {
|
|
|
54
321
|
await client.post(`/api/v1/workspaces/${creds.workspaceId}/skills/report`, { skills: skillsReport });
|
|
55
322
|
log.dim('Reported local skills to Intutic control plane.');
|
|
56
323
|
}
|
|
57
|
-
catch
|
|
58
|
-
// Non-blocking
|
|
324
|
+
catch {
|
|
325
|
+
// Non-blocking: discovery already printed everything the user asked for,
|
|
326
|
+
// and the report is a convenience for the dashboard.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* A minimal, valid SARIF 2.1.0 document for CI/code-scanning interop.
|
|
332
|
+
*
|
|
333
|
+
* Built for GitHub Code Scanning and any other consumer of the format — not
|
|
334
|
+
* for Cisco's open-source `skill-scanner` project specifically, but so that
|
|
335
|
+
* tool's findings and ours can land in the same code-scanning pane on a repo
|
|
336
|
+
* that runs both. We do not shell out to or depend on that project; this is
|
|
337
|
+
* only a compatible OUTPUT shape.
|
|
338
|
+
*
|
|
339
|
+
* `rules` lists every pattern `scanSkillContent` knows how to report —
|
|
340
|
+
* SARIF's convention is the tool's full rule catalog, not only the ids that
|
|
341
|
+
* fired in this run, so a code-scanning UI can show a rule as "0 findings"
|
|
342
|
+
* rather than "unknown rule". `results` is built only from real skill files
|
|
343
|
+
* (`SkillReportEntry.findings`, populated by `auditSkillFile`); the legacy
|
|
344
|
+
* `.cursorrules`/`CLAUDE.md` regex checks above have no pattern ids and stay
|
|
345
|
+
* out of SARIF for that reason — see the entries' own `findings` being
|
|
346
|
+
* absent, not an omission here.
|
|
347
|
+
*
|
|
348
|
+
* `locations` deliberately carries no `region`: `SkillScanFinding` bounds an
|
|
349
|
+
* excerpt (see `skillScan.ts`'s `excerptFor`) but not a line/column, and
|
|
350
|
+
* SARIF's `region` is optional — fabricating `startLine: 1` would claim a
|
|
351
|
+
* precision this scanner does not have.
|
|
352
|
+
*
|
|
353
|
+
* `additionalRuns` (Phase S3): zero or more extra SARIF `runs[]`-shaped
|
|
354
|
+
* objects appended AFTER this codebase's own run — the Cisco skill-scanner
|
|
355
|
+
* integration's combined run, when that engine ran. SARIF is explicitly
|
|
356
|
+
* designed to carry multiple runs from different tools in one document, so
|
|
357
|
+
* these are appended AS-IS, never translated or merged into this function's
|
|
358
|
+
* own `results`/`rules` — that translation only happens for the internal
|
|
359
|
+
* `SkillScanFinding` representation (`mergeCiscoFindings`), never for this
|
|
360
|
+
* output mode.
|
|
361
|
+
*/
|
|
362
|
+
export function buildSarifLog(entries, additionalRuns = []) {
|
|
363
|
+
const results = entries.flatMap((entry) => (entry.findings ?? []).map((finding) => ({
|
|
364
|
+
ruleId: finding.patternId,
|
|
365
|
+
level: 'warning',
|
|
366
|
+
message: {
|
|
367
|
+
text: finding.excerpt
|
|
368
|
+
? `${finding.category} finding: ${finding.excerpt}`
|
|
369
|
+
: `${finding.category} finding (${finding.patternId})`,
|
|
370
|
+
},
|
|
371
|
+
locations: [
|
|
372
|
+
{
|
|
373
|
+
physicalLocation: {
|
|
374
|
+
artifactLocation: { uri: entry.filePath },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
})));
|
|
379
|
+
return {
|
|
380
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
381
|
+
version: '2.1.0',
|
|
382
|
+
runs: [
|
|
383
|
+
{
|
|
384
|
+
tool: {
|
|
385
|
+
driver: {
|
|
386
|
+
name: 'intutic-skill-scan',
|
|
387
|
+
informationUri: 'https://intutic.ai',
|
|
388
|
+
// The rule catalog is the union of both pattern tables — SARIF's
|
|
389
|
+
// convention is one driver's full rule set, not just the ids
|
|
390
|
+
// that fired this run, so a code-scanning UI can show a rule as
|
|
391
|
+
// "0 findings" rather than "unknown rule". Bundled-script
|
|
392
|
+
// findings (Phase S2, `SCRIPT_SCAN_PATTERNS`) land in the same
|
|
393
|
+
// `intutic-skill-scan` driver as `SKILL.md` content findings —
|
|
394
|
+
// one driver name, not a second tool, since both are the same
|
|
395
|
+
// "scan agent-loaded content for these threat shapes" capability.
|
|
396
|
+
rules: [...SKILL_SCAN_PATTERNS, ...SCRIPT_SCAN_PATTERNS].map((pattern) => ({
|
|
397
|
+
id: pattern.id,
|
|
398
|
+
shortDescription: { text: pattern.description },
|
|
399
|
+
defaultConfiguration: { level: 'warning' },
|
|
400
|
+
properties: { category: pattern.category },
|
|
401
|
+
})),
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
// Present and empty, never omitted, when nothing was flagged — a
|
|
405
|
+
// missing `results` key reads to some SARIF consumers as "the tool
|
|
406
|
+
// did not run" rather than "the tool ran and found nothing".
|
|
407
|
+
results,
|
|
408
|
+
},
|
|
409
|
+
...additionalRuns,
|
|
410
|
+
],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Longest excerpt a Cisco finding's `message` is trimmed to before becoming
|
|
415
|
+
* a `SkillScanFinding.excerpt`. Matches the control plane's
|
|
416
|
+
* `SkillScanFindingSchema.excerpt` cap (`harnessConfig.ts`) — this codebase's
|
|
417
|
+
* own findings bound their excerpt to ~40 chars of surrounding context
|
|
418
|
+
* (`EXCERPT_RADIUS`, `skillScan.ts`), but a Cisco `message.text` is a full
|
|
419
|
+
* LLM-authored sentence, not a regex match window, so this cap is generous
|
|
420
|
+
* rather than matching that radius — it exists to reject an attempt to
|
|
421
|
+
* smuggle something much larger through this field, not to enforce a tight
|
|
422
|
+
* budget on a legitimate finding description.
|
|
423
|
+
*/
|
|
424
|
+
const CISCO_EXCERPT_CAP = 2000;
|
|
425
|
+
/**
|
|
426
|
+
* Maps the real `skill-scanner` category taxonomy — the `ThreatCategory`
|
|
427
|
+
* enum in the installed 2.0.13 package's `skill_scanner/core/models.py`,
|
|
428
|
+
* carried on each SARIF result's `properties.category` (see
|
|
429
|
+
* `lib/ciscoScanner.ts`'s module doc comment for how it's extracted) — onto
|
|
430
|
+
* this codebase's own three-category taxonomy. `prompt_injection` covers
|
|
431
|
+
* deception/manipulation vectors (including ones aimed at a human or the
|
|
432
|
+
* discovery/trust mechanism itself, not just the model); `data_exfiltration`
|
|
433
|
+
* covers anything about data or credentials leaving; everything else
|
|
434
|
+
* (execution, abuse, supply-chain, or genuinely uncategorizable) falls back
|
|
435
|
+
* to `'malicious_code'`, the broadest of the three buckets.
|
|
436
|
+
*/
|
|
437
|
+
const CISCO_CATEGORY_MAP = {
|
|
438
|
+
prompt_injection: 'prompt_injection',
|
|
439
|
+
social_engineering: 'prompt_injection',
|
|
440
|
+
harmful_content: 'prompt_injection',
|
|
441
|
+
skill_discovery_abuse: 'prompt_injection',
|
|
442
|
+
transitive_trust_abuse: 'prompt_injection',
|
|
443
|
+
unicode_steganography: 'prompt_injection',
|
|
444
|
+
data_exfiltration: 'data_exfiltration',
|
|
445
|
+
hardcoded_secrets: 'data_exfiltration',
|
|
446
|
+
command_injection: 'malicious_code',
|
|
447
|
+
unauthorized_tool_use: 'malicious_code',
|
|
448
|
+
obfuscation: 'malicious_code',
|
|
449
|
+
resource_abuse: 'malicious_code',
|
|
450
|
+
policy_violation: 'malicious_code',
|
|
451
|
+
malware: 'malicious_code',
|
|
452
|
+
autonomy_abuse: 'malicious_code',
|
|
453
|
+
tool_chaining_abuse: 'malicious_code',
|
|
454
|
+
supply_chain_attack: 'malicious_code',
|
|
455
|
+
};
|
|
456
|
+
/** See {@link CISCO_CATEGORY_MAP}. Exported for tests. */
|
|
457
|
+
export function mapCiscoCategory(category) {
|
|
458
|
+
if (!category)
|
|
459
|
+
return 'malicious_code';
|
|
460
|
+
return CISCO_CATEGORY_MAP[category] ?? 'malicious_code';
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Merges one skill directory's Cisco findings into that skill's existing
|
|
464
|
+
* report entries — the `SKILL.md` row, or a bundled-script row when a
|
|
465
|
+
* finding's own location matches one of `candidateEntries` exactly. Falls
|
|
466
|
+
* back to `fallbackEntry` (always the skill's `SKILL.md` row) when no
|
|
467
|
+
* location match is found — this codebase does not assume `skill-scanner`
|
|
468
|
+
* always attributes a finding to a specific bundled file, so an
|
|
469
|
+
* unmatched/absent location is treated as "belongs to the skill as a
|
|
470
|
+
* whole," not an error.
|
|
471
|
+
*
|
|
472
|
+
* Each Cisco finding becomes a `SkillScanFinding` with `patternId: 'cisco.'
|
|
473
|
+
* + ruleId`, a category from {@link mapCiscoCategory} (mapped from the
|
|
474
|
+
* finding's own `category`, not its `ruleId`), an excerpt capped at
|
|
475
|
+
* {@link CISCO_EXCERPT_CAP}, and `engine: 'cisco-skill-scanner'` — this
|
|
476
|
+
* codebase's own findings stamp `engine: 'native'` at construction
|
|
477
|
+
* (`skillScan.ts`/`scriptScan.ts`), so every finding in a report entry now
|
|
478
|
+
* carries its provenance.
|
|
479
|
+
*
|
|
480
|
+
* Returns the number of findings merged, for the caller's running `issues`
|
|
481
|
+
* count.
|
|
482
|
+
*/
|
|
483
|
+
export function mergeCiscoFindings(candidateEntries, fallbackEntry, ciscoResult, workspaceRoot) {
|
|
484
|
+
if (!ciscoResult.ok)
|
|
485
|
+
return 0;
|
|
486
|
+
for (const finding of ciscoResult.findings) {
|
|
487
|
+
const mapped = {
|
|
488
|
+
patternId: `cisco.${finding.ruleId}`,
|
|
489
|
+
category: mapCiscoCategory(finding.category),
|
|
490
|
+
excerpt: finding.message.slice(0, CISCO_EXCERPT_CAP) || undefined,
|
|
491
|
+
engine: 'cisco-skill-scanner',
|
|
492
|
+
};
|
|
493
|
+
let target;
|
|
494
|
+
if (finding.filePath) {
|
|
495
|
+
const relPath = finding.filePath.startsWith(workspaceRoot)
|
|
496
|
+
? finding.filePath.slice(workspaceRoot.length).replace(/^[/\\]/, '')
|
|
497
|
+
: finding.filePath;
|
|
498
|
+
target = candidateEntries.find((e) => e.filePath === relPath);
|
|
59
499
|
}
|
|
500
|
+
target ??= fallbackEntry;
|
|
501
|
+
target.findings = [...(target.findings ?? []), mapped];
|
|
502
|
+
target.issuesDetected += 1;
|
|
60
503
|
}
|
|
504
|
+
return ciscoResult.findings.length;
|
|
61
505
|
}
|
|
62
|
-
export async function runSkillAudit() {
|
|
63
|
-
|
|
506
|
+
export async function runSkillAudit(opts = {}) {
|
|
507
|
+
const sarif = opts.sarif === true;
|
|
508
|
+
// SARIF's contract is a single JSON document on stdout — a CI tool piping
|
|
509
|
+
// this into a code-scanning upload must not see decorated progress text
|
|
510
|
+
// interleaved with it. Every narrative call below is gated on `!sarif`;
|
|
511
|
+
// findings are still collected and reported identically either way.
|
|
512
|
+
if (!sarif)
|
|
513
|
+
log.header('Intutic — Skill Security Audit');
|
|
64
514
|
const config = loadConfig();
|
|
65
515
|
const workspaceRoot = config?.workspaceRoot ?? process.cwd();
|
|
66
516
|
const creds = await loadCredentials().catch(() => null);
|
|
67
517
|
let enableLocalSkillAuditDelete = false;
|
|
68
|
-
|
|
518
|
+
let ciscoSkillScannerEnabled = false;
|
|
519
|
+
// Phase S5 (TD-357): gates whether `auditSkillFile` below attaches full
|
|
520
|
+
// `SKILL.md` content to its report entry. Off unless the workspace opted
|
|
521
|
+
// in — see `WorkspaceSettings.semanticSkillAnalysisEnabled`'s own doc
|
|
522
|
+
// comment for what turning it on transmits.
|
|
523
|
+
let semanticSkillAnalysisEnabled = false;
|
|
524
|
+
// Fetch workspace settings to see if auto-delete / the Cisco auto-run /
|
|
525
|
+
// semantic analysis are enabled
|
|
69
526
|
if (creds) {
|
|
70
527
|
try {
|
|
71
528
|
const client = await getClient(config?.devMode);
|
|
72
529
|
const syncConfig = await client.fetchConfig(creds.workspaceId);
|
|
73
530
|
enableLocalSkillAuditDelete = syncConfig.settings?.enableLocalSkillAuditDelete ?? false;
|
|
531
|
+
ciscoSkillScannerEnabled = syncConfig.settings?.ciscoSkillScannerEnabled ?? false;
|
|
532
|
+
semanticSkillAnalysisEnabled = syncConfig.settings?.semanticSkillAnalysisEnabled ?? false;
|
|
74
533
|
}
|
|
75
534
|
catch {
|
|
76
535
|
// fallback to false
|
|
77
536
|
}
|
|
78
537
|
}
|
|
538
|
+
// ── Cisco skill-scanner engine resolution (Phase S3) ──────────────────
|
|
539
|
+
//
|
|
540
|
+
// Native scanning always runs — it is this command's baseline and always
|
|
541
|
+
// available. The Cisco engine additionally runs when EITHER:
|
|
542
|
+
// - `--engine cisco` was explicitly passed: a LOUD failure (non-zero
|
|
543
|
+
// exit) if the binary is absent, since the user explicitly asked for
|
|
544
|
+
// this engine and a silent skip would look like a clean scan.
|
|
545
|
+
// - the `ciscoSkillScannerEnabled` workspace setting is on: a graceful,
|
|
546
|
+
// info-level skip if the binary is absent, since this is an opt-in
|
|
547
|
+
// AUTO-run path, not an explicit request.
|
|
548
|
+
if (opts.engine !== undefined && opts.engine !== 'native' && opts.engine !== 'cisco') {
|
|
549
|
+
log.error(`Unknown --engine value '${opts.engine}'. Expected 'native' or 'cisco'.`);
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
const requestedCisco = opts.engine === 'cisco';
|
|
553
|
+
let runCisco = false;
|
|
554
|
+
if (requestedCisco || ciscoSkillScannerEnabled) {
|
|
555
|
+
const available = await ciscoScannerOnPath();
|
|
556
|
+
if (available) {
|
|
557
|
+
runCisco = true;
|
|
558
|
+
}
|
|
559
|
+
else if (requestedCisco) {
|
|
560
|
+
log.error("Cisco skill-scanner engine requested (--engine cisco) but the 'skill-scanner' binary " +
|
|
561
|
+
'is not on PATH. Install it with `pipx install cisco-ai-skill-scanner`.');
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
else if (!sarif) {
|
|
565
|
+
log.info("ciscoSkillScannerEnabled is on, but the 'skill-scanner' binary is not on PATH — " +
|
|
566
|
+
'skipping the Cisco engine for this run (native scanning still ran).');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const ciscoRawSarifRuns = [];
|
|
79
570
|
const filesToScan = [
|
|
80
571
|
'.cursorrules',
|
|
81
572
|
'CLAUDE.md',
|
|
@@ -89,31 +580,35 @@ export async function runSkillAudit() {
|
|
|
89
580
|
if (existsSync(fullPath)) {
|
|
90
581
|
const content = await fs.readFile(fullPath, 'utf8');
|
|
91
582
|
let fileIssues = 0;
|
|
92
|
-
|
|
583
|
+
const contentLines = content.split('\n');
|
|
93
584
|
let fileUpdated = false;
|
|
94
585
|
// 1. Audit for secrets (AWS, Intutic, generic keys)
|
|
95
586
|
if (content.match(/vk_[a-zA-Z0-9]{30,}/)) {
|
|
96
|
-
|
|
587
|
+
if (!sarif)
|
|
588
|
+
log.error(`[${file}] Hardcoded Intutic virtual key prefix detected.`);
|
|
97
589
|
fileIssues++;
|
|
98
590
|
}
|
|
99
591
|
if (content.match(/sk-live-[a-zA-Z0-9]{30,}/)) {
|
|
100
|
-
|
|
592
|
+
if (!sarif)
|
|
593
|
+
log.error(`[${file}] Hardcoded API secrets detected.`);
|
|
101
594
|
fileIssues++;
|
|
102
595
|
}
|
|
103
596
|
// 2. Audit for unsafe wildcard commands (e.g. rm -rf *, sh *)
|
|
104
|
-
if (content.match(/rm\s+-rf\s+[
|
|
105
|
-
|
|
597
|
+
if (content.match(/rm\s+-rf\s+[*/]/)) {
|
|
598
|
+
if (!sarif)
|
|
599
|
+
log.warn(`[${file}] Unsafe recursive delete wildcard patterns (rm -rf *) found.`);
|
|
106
600
|
fileIssues++;
|
|
107
601
|
}
|
|
108
602
|
if (content.match(/curl\s+|wget\s+/)) {
|
|
109
|
-
|
|
603
|
+
if (!sarif)
|
|
604
|
+
log.warn(`[${file}] Network retrieval commands (curl, wget) found inside rules instructions.`);
|
|
110
605
|
fileIssues++;
|
|
111
606
|
}
|
|
112
607
|
// 3. Auto-delete/prune unsafe lines if setting is enabled
|
|
113
608
|
if (fileIssues > 0 && enableLocalSkillAuditDelete) {
|
|
114
609
|
const filteredLines = contentLines.filter((line) => {
|
|
115
610
|
const isSecret = line.match(/vk_[a-zA-Z0-9]{30,}/) || line.match(/sk-live-[a-zA-Z0-9]{30,}/);
|
|
116
|
-
const isUnsafeCmd = line.match(/rm\s+-rf\s+[
|
|
611
|
+
const isUnsafeCmd = line.match(/rm\s+-rf\s+[*/]/) || line.match(/curl\s+|wget\s+/);
|
|
117
612
|
if (isSecret || isUnsafeCmd) {
|
|
118
613
|
fileUpdated = true;
|
|
119
614
|
return false;
|
|
@@ -122,7 +617,8 @@ export async function runSkillAudit() {
|
|
|
122
617
|
});
|
|
123
618
|
if (fileUpdated) {
|
|
124
619
|
await fs.writeFile(fullPath, filteredLines.join('\n'), 'utf8');
|
|
125
|
-
|
|
620
|
+
if (!sarif)
|
|
621
|
+
log.success(`[${file}] Auto-pruned unsafe lines/rules during security audit.`);
|
|
126
622
|
}
|
|
127
623
|
}
|
|
128
624
|
issues += fileIssues;
|
|
@@ -133,7 +629,60 @@ export async function runSkillAudit() {
|
|
|
133
629
|
});
|
|
134
630
|
}
|
|
135
631
|
}
|
|
136
|
-
|
|
632
|
+
// ── Real skill directories (`.agents/skills`, `.claude/skills`) ──────
|
|
633
|
+
//
|
|
634
|
+
// Content-aware scan via `scanSkillContent` (`@intutic/shared-types`),
|
|
635
|
+
// report-only per that module's doc comment — a finding here is never
|
|
636
|
+
// treated as ground truth, only surfaced. `enableLocalSkillAuditDelete`
|
|
637
|
+
// gates pruning here exactly as it already gates the legacy rule-file
|
|
638
|
+
// pruning above: the same opt-in, now covering both audit paths.
|
|
639
|
+
const skillFiles = await discoverSkillFiles(workspaceRoot);
|
|
640
|
+
for (const { filePath, fullPath } of skillFiles) {
|
|
641
|
+
const entry = await auditSkillFile(filePath, fullPath, enableLocalSkillAuditDelete, sarif, semanticSkillAnalysisEnabled);
|
|
642
|
+
issues += entry.issuesDetected;
|
|
643
|
+
skillsReport.push(entry);
|
|
644
|
+
// ── Bundled scripts alongside this skill's SKILL.md (TD-356, Phase S2) ─
|
|
645
|
+
//
|
|
646
|
+
// `discoverSkillFiles` only ever found `SKILL.md`; a sibling `setup.sh`
|
|
647
|
+
// or `helper.py` was invisible to every consumer before this. Enumerate
|
|
648
|
+
// (bounded, symlink-skipping — see `discoverSkillBundledFiles`) and
|
|
649
|
+
// audit each one the same way: always hashed, content-scanned only when
|
|
650
|
+
// the language is recognized and the file is within the byte cap.
|
|
651
|
+
const skillDirPath = dirname(filePath);
|
|
652
|
+
const bundledFiles = await discoverSkillBundledFiles(workspaceRoot, skillDirPath);
|
|
653
|
+
const bundledEntries = [];
|
|
654
|
+
for (const script of bundledFiles) {
|
|
655
|
+
const scriptEntry = await auditScriptFile(script.filePath, script.fullPath, sarif);
|
|
656
|
+
issues += scriptEntry.issuesDetected;
|
|
657
|
+
skillsReport.push(scriptEntry);
|
|
658
|
+
bundledEntries.push(scriptEntry);
|
|
659
|
+
}
|
|
660
|
+
// ── Cisco skill-scanner engine (Phase S3, opt-in) ───────────────────
|
|
661
|
+
//
|
|
662
|
+
// One invocation per skill directory — see `lib/ciscoScanner.ts`'s
|
|
663
|
+
// module doc comment for why the real CLI cannot take more than one
|
|
664
|
+
// `--path` per invocation. Findings merge into this skill's existing
|
|
665
|
+
// entries (SKILL.md or a matching bundled-script row); the raw SARIF
|
|
666
|
+
// run is kept separately for verbatim passthrough below.
|
|
667
|
+
if (runCisco) {
|
|
668
|
+
const [ciscoResult] = await runCiscoScan([join(workspaceRoot, skillDirPath)]);
|
|
669
|
+
if (ciscoResult.ok) {
|
|
670
|
+
issues += mergeCiscoFindings([entry, ...bundledEntries], entry, ciscoResult, workspaceRoot);
|
|
671
|
+
ciscoRawSarifRuns.push(...ciscoResult.sarifRuns);
|
|
672
|
+
}
|
|
673
|
+
else if (!sarif) {
|
|
674
|
+
log.warn(`[cisco] ${skillDirPath}: ${ciscoResult.error}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (sarif) {
|
|
679
|
+
// The whole point: one JSON document, nothing else, on stdout. Cisco's
|
|
680
|
+
// combined run (if the engine ran) appends as a second `runs[]` entry,
|
|
681
|
+
// verbatim — see `buildSarifLog`'s doc comment.
|
|
682
|
+
const combinedCiscoRun = combineCiscoSarifRuns(ciscoRawSarifRuns);
|
|
683
|
+
console.log(JSON.stringify(buildSarifLog(skillsReport, combinedCiscoRun ? [combinedCiscoRun] : []), null, 2));
|
|
684
|
+
}
|
|
685
|
+
else if (issues === 0) {
|
|
137
686
|
log.success('Skill security audit passed. No credentials or critical safety risks detected.');
|
|
138
687
|
}
|
|
139
688
|
else {
|
|
@@ -150,14 +699,109 @@ export async function runSkillAudit() {
|
|
|
150
699
|
}
|
|
151
700
|
}
|
|
152
701
|
}
|
|
702
|
+
/**
|
|
703
|
+
* Lists staged files under the skill surface (`.agents/skills/**`,
|
|
704
|
+
* `.claude/skills/**`), ADDED/COPIED/MODIFIED only — mirroring the secret
|
|
705
|
+
* pre-commit scan's `git diff --cached` scope in `lib/gitHooks.ts`, so both
|
|
706
|
+
* checks agree on what "staged" means. A deleted skill file has nothing to
|
|
707
|
+
* scan.
|
|
708
|
+
*
|
|
709
|
+
* `execFileSync`, matching this package's existing git-invocation
|
|
710
|
+
* convention (`config/keychain.ts`, `commands/install-daemon.ts`) rather
|
|
711
|
+
* than a shelled-out string — no quoting to get wrong.
|
|
712
|
+
*/
|
|
713
|
+
function listStagedSkillSurfaceFiles(workspaceRoot) {
|
|
714
|
+
try {
|
|
715
|
+
const out = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM', '--', '.agents/skills', '.claude/skills'], { cwd: workspaceRoot, encoding: 'utf8' });
|
|
716
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
// Not a git repo, no staged changes, or git not on PATH — nothing to scan.
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Reads the STAGED content of a path via `git show :<path>` — the blob that
|
|
725
|
+
* will actually be committed, not whatever is currently on disk. A file
|
|
726
|
+
* edited again after `git add` would otherwise be scanned in a state that
|
|
727
|
+
* does not match what `git commit` is about to record.
|
|
728
|
+
*/
|
|
729
|
+
function readStagedBlob(workspaceRoot, filePath) {
|
|
730
|
+
try {
|
|
731
|
+
return execFileSync('git', ['show', `:${filePath}`], { cwd: workspaceRoot, encoding: 'utf8' });
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Warn-only skill-content scan over staged additions in the skill surface.
|
|
739
|
+
*
|
|
740
|
+
* TD-358. This is the CLI entry point `lib/gitHooks.ts`'s pre-commit hook
|
|
741
|
+
* shells out to (`intutic skill scan-staged`) — deliberately separate from
|
|
742
|
+
* `skill audit` above, which scans the whole workspace and is too slow and
|
|
743
|
+
* too broad for a commit-time check that must stay fast.
|
|
744
|
+
*
|
|
745
|
+
* Never refuses a commit and never throws: `scanSkillContent`'s
|
|
746
|
+
* false-positive rate against real, benign skill markdown is unmeasured (see
|
|
747
|
+
* `skillScan.ts`'s module doc comment), so this phase stages the signal as
|
|
748
|
+
* an advisory only. Contrast `lib/gitHooks.ts`'s secret scan, which DOES
|
|
749
|
+
* refuse — that check is high-precision, prefixed credential shapes with no
|
|
750
|
+
* known false positives; this one is not, and must not borrow that check's
|
|
751
|
+
* authority until it has earned it the same way.
|
|
752
|
+
*/
|
|
753
|
+
export async function runSkillScanStaged() {
|
|
754
|
+
const config = loadConfig();
|
|
755
|
+
const workspaceRoot = config?.workspaceRoot ?? process.cwd();
|
|
756
|
+
const staged = listStagedSkillSurfaceFiles(workspaceRoot);
|
|
757
|
+
if (staged.length === 0)
|
|
758
|
+
return;
|
|
759
|
+
let flaggedFiles = 0;
|
|
760
|
+
for (const filePath of staged) {
|
|
761
|
+
const content = readStagedBlob(workspaceRoot, filePath);
|
|
762
|
+
if (content === null)
|
|
763
|
+
continue; // vanished between diff and show — nothing to scan
|
|
764
|
+
const result = scanSkillContent(content);
|
|
765
|
+
if (result.clean)
|
|
766
|
+
continue;
|
|
767
|
+
flaggedFiles++;
|
|
768
|
+
for (const finding of result.findings) {
|
|
769
|
+
log.warn(`[${filePath}] ${finding.category} finding (${finding.patternId})` +
|
|
770
|
+
`${finding.excerpt ? `: ${finding.excerpt}` : ''} — advisory only, not blocking this commit.`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (flaggedFiles > 0) {
|
|
774
|
+
log.warn(`Skill-content scan flagged ${flaggedFiles} staged file(s) under .agents/skills or ` +
|
|
775
|
+
'.claude/skills. This is advisory only (TD-358) — the commit proceeds. Run ' +
|
|
776
|
+
'`intutic skill audit` for the full report.');
|
|
777
|
+
}
|
|
778
|
+
// No exit code is ever set here — the caller (the pre-commit hook) always
|
|
779
|
+
// treats this check as successful, by construction.
|
|
780
|
+
}
|
|
153
781
|
// ─── Loop Commands ───────────────────────────────────────────────────
|
|
154
|
-
|
|
782
|
+
// Exported so `commands/decision.ts` can share the exact same client
|
|
783
|
+
// resolution (creds, dev-mode precedence, base URL) rather than growing a
|
|
784
|
+
// second, easily-drifting copy of it.
|
|
785
|
+
export async function getClient(dev) {
|
|
155
786
|
const creds = await loadCredentials();
|
|
156
787
|
if (!creds) {
|
|
157
|
-
log.error(
|
|
788
|
+
log.error(NOT_AUTHENTICATED);
|
|
158
789
|
process.exit(1);
|
|
159
790
|
}
|
|
160
|
-
|
|
791
|
+
// `config?.devMode` is load-bearing and was missing here.
|
|
792
|
+
//
|
|
793
|
+
// Every other command that talks to the control plane resolves dev mode as
|
|
794
|
+
// `flag || config.devMode || INTUTIC_DEV` (sops, policy, budget, connect, exec).
|
|
795
|
+
// This one read only the flag and the env var, so a workspace configured with
|
|
796
|
+
// `devMode: true` still had `intutic loop start` resolve to https://api.intutic.ai
|
|
797
|
+
// — the loop commands, which are the governance feature, were the one family that
|
|
798
|
+
// ignored the workspace's own setting and silently addressed production.
|
|
799
|
+
//
|
|
800
|
+
// It went unnoticed because tools/cli/dist was stale: the built CLI predated this
|
|
801
|
+
// code and still read the config, so the bug only appeared once a workspace build
|
|
802
|
+
// regenerated dist from source.
|
|
803
|
+
const config = loadConfig();
|
|
804
|
+
const devMode = dev || config?.devMode || process.env.INTUTIC_DEV === '1';
|
|
161
805
|
const controlPlaneUrl = resolveControlPlaneUrl(devMode);
|
|
162
806
|
return createApiClient(controlPlaneUrl, creds.apiKey);
|
|
163
807
|
}
|
|
@@ -167,7 +811,7 @@ async function resolveLocalSops(sopsArg) {
|
|
|
167
811
|
const config = loadConfig();
|
|
168
812
|
const workspaceRoot = config?.workspaceRoot ?? process.cwd();
|
|
169
813
|
const sopsDir = join(workspaceRoot, '.intutic', 'sops');
|
|
170
|
-
let dirs
|
|
814
|
+
let dirs;
|
|
171
815
|
try {
|
|
172
816
|
const entries = await fs.readdir(sopsDir, { withFileTypes: true });
|
|
173
817
|
dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
@@ -220,14 +864,14 @@ export async function runLoopStart(opts) {
|
|
|
220
864
|
log.success(`Loop run active: ${pc.bold(res.loop.loopRunId)}`);
|
|
221
865
|
log.info(`Budget limit: $${res.loop.budgetLimitUsd} USD`);
|
|
222
866
|
// Write loop env file locally
|
|
223
|
-
const loopEnvPath = join(
|
|
867
|
+
const loopEnvPath = join(getIntuticDir(), 'env', 'loop.env');
|
|
224
868
|
await fs.mkdir(dirname(loopEnvPath), { recursive: true }).catch(() => { });
|
|
225
869
|
await fs.writeFile(loopEnvPath, `INTUTIC_LOOP_RUN_ID=${res.loop.loopRunId}\n`);
|
|
226
870
|
log.dim(`Wrote run context to ~/.intutic/env/loop.env`);
|
|
227
871
|
}
|
|
228
872
|
}
|
|
229
873
|
catch (err) {
|
|
230
|
-
log.error(`Failed to register loop: ${err.message}`);
|
|
874
|
+
log.error(`Failed to register loop: ${err instanceof Error ? err.message : String(err)}`);
|
|
231
875
|
}
|
|
232
876
|
}
|
|
233
877
|
export async function runLoopComplete(loopRunId, opts) {
|
|
@@ -238,12 +882,12 @@ export async function runLoopComplete(loopRunId, opts) {
|
|
|
238
882
|
if (res.ok) {
|
|
239
883
|
log.success(`Loop run ${loopRunId} completed.`);
|
|
240
884
|
// Clean env
|
|
241
|
-
const loopEnvPath = join(
|
|
885
|
+
const loopEnvPath = join(getIntuticDir(), 'env', 'loop.env');
|
|
242
886
|
await fs.rm(loopEnvPath, { force: true });
|
|
243
887
|
}
|
|
244
888
|
}
|
|
245
889
|
catch (err) {
|
|
246
|
-
log.error(`Failed to complete loop: ${err.message}`);
|
|
890
|
+
log.error(`Failed to complete loop: ${err instanceof Error ? err.message : String(err)}`);
|
|
247
891
|
}
|
|
248
892
|
}
|
|
249
893
|
export async function runLoopKill(loopRunId, opts) {
|
|
@@ -253,12 +897,12 @@ export async function runLoopKill(loopRunId, opts) {
|
|
|
253
897
|
const res = await client.post(`/api/v1/loops/${loopRunId}/kill`);
|
|
254
898
|
if (res.ok) {
|
|
255
899
|
log.warn(`Loop run ${loopRunId} marked as KILLED.`);
|
|
256
|
-
const loopEnvPath = join(
|
|
900
|
+
const loopEnvPath = join(getIntuticDir(), 'env', 'loop.env');
|
|
257
901
|
await fs.rm(loopEnvPath, { force: true });
|
|
258
902
|
}
|
|
259
903
|
}
|
|
260
904
|
catch (err) {
|
|
261
|
-
log.error(`Failed to kill loop: ${err.message}`);
|
|
905
|
+
log.error(`Failed to kill loop: ${err instanceof Error ? err.message : String(err)}`);
|
|
262
906
|
}
|
|
263
907
|
}
|
|
264
908
|
export async function runLoopList(opts) {
|
|
@@ -270,13 +914,49 @@ export async function runLoopList(opts) {
|
|
|
270
914
|
console.log(` ${pc.bold('Loop Run ID')} | ${pc.bold('Name')} | ${pc.bold('Status')} | ${pc.bold('Token Spend')} | ${pc.bold('Budget Limit')}`);
|
|
271
915
|
console.log(' ' + '-'.repeat(85));
|
|
272
916
|
for (const loop of res.loops) {
|
|
273
|
-
|
|
917
|
+
// PENDING_REVIEW is amber, not red: it needs a person, but nothing has
|
|
918
|
+
// gone wrong. Falling through to the red branch made a held run look
|
|
919
|
+
// killed, which is the opposite of "waiting for you".
|
|
920
|
+
const statusStr = loop.status === 'ACTIVE'
|
|
921
|
+
? pc.green(loop.status)
|
|
922
|
+
: loop.status === 'COMPLETED'
|
|
923
|
+
? pc.cyan(loop.status)
|
|
924
|
+
: loop.status === 'PENDING_REVIEW'
|
|
925
|
+
? pc.yellow(loop.status)
|
|
926
|
+
: pc.red(loop.status);
|
|
274
927
|
console.log(` ${loop.loopRunId.padEnd(21)} | ${loop.name.padEnd(14)} | ${statusStr.padEnd(17)} | $${parseFloat(loop.totalTokenCostUsd).toFixed(4).padEnd(10)} | $${parseFloat(loop.budgetLimitUsd).toFixed(2)}`);
|
|
275
928
|
}
|
|
276
929
|
}
|
|
277
930
|
}
|
|
278
931
|
catch (err) {
|
|
279
|
-
log.error(`Failed to list loops: ${err.message}`);
|
|
932
|
+
log.error(`Failed to list loops: ${err instanceof Error ? err.message : String(err)}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Approve or reject a run held for human review.
|
|
937
|
+
*
|
|
938
|
+
* The other half of `review_before:`. Without it a hold has no resolution from
|
|
939
|
+
* the terminal, and the hold is deliberately permanent — there is no reaper, so
|
|
940
|
+
* an unreviewed run stays blocked until a person acts.
|
|
941
|
+
*/
|
|
942
|
+
export async function runLoopReview(loopRunId, opts) {
|
|
943
|
+
if (opts.approve === opts.reject) {
|
|
944
|
+
log.error('Specify exactly one of --approve or --reject.');
|
|
945
|
+
process.exit(1);
|
|
946
|
+
}
|
|
947
|
+
const action = opts.approve ? 'approve' : 'reject';
|
|
948
|
+
const client = await getClient(opts.dev);
|
|
949
|
+
try {
|
|
950
|
+
const res = await client.post(`/api/v1/loops/${loopRunId}/review`, { action, note: opts.note });
|
|
951
|
+
if (res.ok) {
|
|
952
|
+
log.success(action === 'approve'
|
|
953
|
+
? `Loop run ${loopRunId} approved — it is ACTIVE again.`
|
|
954
|
+
: `Loop run ${loopRunId} rejected — it is KILLED.`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch (err) {
|
|
958
|
+
log.error(`Failed to resolve review: ${err instanceof Error ? err.message : String(err)}`);
|
|
959
|
+
process.exit(1);
|
|
280
960
|
}
|
|
281
961
|
}
|
|
282
962
|
export async function runLoopExec(commandAndArgs, opts) {
|
|
@@ -325,25 +1005,50 @@ export async function runLoopExec(commandAndArgs, opts) {
|
|
|
325
1005
|
shell: true,
|
|
326
1006
|
});
|
|
327
1007
|
child.on('close', async (code) => {
|
|
1008
|
+
// A non-zero exit is the agent failing, not governance stopping it. This used
|
|
1009
|
+
// to POST /kill for that case, which conflated the two: KILLED means the
|
|
1010
|
+
// circuit breaker or an operator intervened, and a run marked KILLED because
|
|
1011
|
+
// the wrapped process returned 1 makes budget-breach data unreadable. It also
|
|
1012
|
+
// meant the outcome was never recorded anywhere — the exit code was known
|
|
1013
|
+
// right here and thrown away, leaving no way to tell a successful trajectory
|
|
1014
|
+
// from a failed one afterwards.
|
|
1015
|
+
// A held run is not finished, it is waiting. Completing it would close the
|
|
1016
|
+
// review by side effect, with nothing recorded about who decided what — and
|
|
1017
|
+
// the route refuses it anyway (400). The agent exits non-zero when the proxy
|
|
1018
|
+
// returns 403, so without this check every hold ends in a confusing error.
|
|
1019
|
+
try {
|
|
1020
|
+
const current = await client.get(`/api/v1/loops/${loopRunId}`);
|
|
1021
|
+
if (current?.loop?.status === 'PENDING_REVIEW') {
|
|
1022
|
+
log.warn('This loop run is held for human review — it has not finished.');
|
|
1023
|
+
log.info(`Approve or reject it: intutic loop review ${loopRunId} --approve`);
|
|
1024
|
+
// loop.env is deliberately left in place: the resumed agent needs the
|
|
1025
|
+
// run id, and removing it here would orphan the run.
|
|
1026
|
+
process.exit(code || 0);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
// Status unknown — fall through and record the outcome as usual rather
|
|
1032
|
+
// than leaving the run open on a transient error.
|
|
1033
|
+
}
|
|
328
1034
|
if (code === 0) {
|
|
329
|
-
await client
|
|
330
|
-
|
|
1035
|
+
await client
|
|
1036
|
+
.post(`/api/v1/loops/${loopRunId}/complete`, { outcome: 'SUCCEEDED' })
|
|
1037
|
+
.catch(() => { });
|
|
1038
|
+
log.success('Loop command execution succeeded. Wrapper loop marked COMPLETED.');
|
|
331
1039
|
}
|
|
332
1040
|
else {
|
|
333
|
-
await client
|
|
334
|
-
|
|
1041
|
+
await client
|
|
1042
|
+
.post(`/api/v1/loops/${loopRunId}/complete`, { outcome: 'FAILED' })
|
|
1043
|
+
.catch(() => { });
|
|
1044
|
+
log.warn(`Loop command execution exited with code ${code}. Wrapper loop marked FAILED.`);
|
|
335
1045
|
}
|
|
336
1046
|
process.exit(code || 0);
|
|
337
1047
|
});
|
|
338
1048
|
}
|
|
339
1049
|
catch (err) {
|
|
340
|
-
log.error(`Loop execution wrapper failed: ${err.message}`);
|
|
1050
|
+
log.error(`Loop execution wrapper failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
341
1051
|
process.exit(1);
|
|
342
1052
|
}
|
|
343
1053
|
}
|
|
344
|
-
function dirname(filePath) {
|
|
345
|
-
const parts = filePath.split('/');
|
|
346
|
-
parts.pop();
|
|
347
|
-
return parts.join('/');
|
|
348
|
-
}
|
|
349
1054
|
//# sourceMappingURL=skill.js.map
|